Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/coderEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export const CODER_OMITTED_CAPABILITIES = {
databaseStorage: "No `t3 doctor` in the workspace helper; it compacts and reclaims space itself.",
ignoredFileListing:
"No Add to another machine: ignored files such as .env would pass through the laptop browser.",
peerLinks: "No linked environments: they need server-to-server /mcp and pairing.",
mcpModeLimitHeader: "No /mcp HTTP server; agents reach T3 tools over the workspace file bridge.",
linkFence: "No linked environments, so no work starts here from another one.",
} satisfies Partial<Record<keyof ExecutionEnvironmentCapabilities, string>>;

const platformArch = (): ExecutionEnvironmentDescriptor["platform"]["arch"] => {
Expand Down
21 changes: 18 additions & 3 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
OrchestratorMcpFailure,
PreviewAutomationUnavailableError,
type ProviderInstanceId,
type ProviderInteractionMode,
type RuntimeMode,
type ThreadId,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -35,9 +36,23 @@ export interface McpClientCaller {
readonly access: AuthMcpClientAccess;
}

/** Read-only clients map to the lowest mode because McpToolAccess blocks writes first. */
export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode =>
client === undefined || client.access === "read-only" ? "approval-required" : client.access;
export interface ClientModes {
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode;
}

/**
* The modes a client caller's writes are capped at: what it was approved
* with. A read-only client never reaches a write (`McpToolAccess` refuses it
* first), so it maps to the lowest runtime mode rather than to nothing.
* Coder: no client narrows them per request; `T3-Mode-Limit` serves linked
* environments, which are not carried.
*/
export const clientModeCeiling = (client: McpClientCaller | undefined): ClientModes => ({
runtimeMode:
client === undefined || client.access === "read-only" ? "approval-required" : client.access,
interactionMode: "default",
});

/** Thread callers default to their own thread; client callers need thread for tools that act as the caller. */
export interface McpInvocationScope {
Expand Down
62 changes: 56 additions & 6 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {
type EnvironmentId,
OrchestratorMcpEnvironmentTarget,
OrchestratorMcpFailure,
type ProviderInteractionMode,
type RuntimeMode,
type ThreadId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Predicate from "effect/Predicate";
import * as Ref from "effect/Ref";
import * as Struct from "effect/Struct";
import type * as Layer from "effect/Layer";
Expand All @@ -16,7 +19,7 @@ import {
type DispatchModeRefusal,
} from "../orchestration-v2/DispatchModeLimit.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts";
import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts";
import {
assertFullAccess,
assertLiveCaller,
Expand Down Expand Up @@ -241,14 +244,61 @@ export class HandlersLayer<Tools extends Record<string, Tool.Any>, EX = never, R
const checkedHandlers = <Handlers>(declarations: Declarations<Handlers>): Handlers =>
Struct.map(declarations, checkedHandler);

/**
* A tool runs in a linked environment when its parameters take this target
* as `environmentId`. Other tools may name an environment for their own
* reasons, such as the one to link or to move a thread to.
*/
const takesLinkedEnvironment = (tool: Tool.Any) => {
const parameters: unknown = tool.parametersSchema;
return (
Predicate.hasProperty(parameters, "fields") &&
Predicate.hasProperty(parameters.fields, "environmentId") &&
parameters.fields.environmentId === OrchestratorMcpEnvironmentTarget
);
};

const linksUnavailable = new OrchestratorMcpFailure({
code: "orchestration_error",
message: "This environment cannot reach linked environments.",
});

/**
* Coder: peer links are not carried, so upstream's forwarding of these calls to
* a linked environment is replaced by its own refusal. A call naming this
* environment runs here; the handler never sees `environmentId`, as upstream's.
*/
const refusingLinkedEnvironments = <Tools extends Record<string, Tool.Any>>(
toolkit: Toolkit.Toolkit<Tools>,
checked: Toolkit.HandlersFrom<Tools>,
): Toolkit.HandlersFrom<Tools> => {
const handlers: Record<string, unknown> = { ...checked };
for (const [name, tool] of Object.entries<Tool.Any>(toolkit.tools)) {
if (!takesLinkedEnvironment(tool)) continue;
const here = handlers[name] as (
params: object,
) => Effect.Effect<unknown, Tool.Failure<Tools[keyof Tools]>>;
handlers[name] = ({ environmentId, ...params }: { readonly environmentId?: EnvironmentId }) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext.McpInvocationContext;
if (environmentId === undefined || environmentId === scope.environmentId) {
return yield* here(params);
}
return yield* linksUnavailable;
});
}
return handlers as Toolkit.HandlersFrom<Tools>;
};

export const toLayer = <Tools extends Record<string, Tool.Any>, EX = never, RX = never>(
toolkit: Toolkit.Toolkit<Tools>,
build: Handlers<Tools> | Effect.Effect<Handlers<Tools>, EX, RX>,
): HandlersLayer<Tools, EX, Exclude<RX, Scope.Scope>> =>
handlersLayer(
): HandlersLayer<Tools, EX, Exclude<RX, Scope.Scope>> => {
const withDeclarations = (declarations: Handlers<Tools>) =>
refusingLinkedEnvironments(toolkit, checkedHandlers(declarations));
return handlersLayer(
toolkit.toLayer(
Effect.isEffect(build)
? Effect.map(build, checkedHandlers<Toolkit.HandlersFrom<Tools>>)
: checkedHandlers(build),
Effect.isEffect(build) ? Effect.map(build, withDeclarations) : withDeclarations(build),
),
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as ServerSettings from "../serverSettings.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
// Coder: no peer-link services to mock; `apps/server/src/peer` is not carried.
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import type * as McpInvocationContext from "./McpInvocationContext.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
Expand Down
208 changes: 195 additions & 13 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
ProviderThreadId,
ProviderTurnId,
RunId,
ThreadId,
type OrchestrationV2ServerCommand,
Expand All @@ -23,12 +25,16 @@ import {
import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";

import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
import * as EffectWorker from "../orchestration-v2/EffectWorker.ts";
import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts";
import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
Expand All @@ -51,9 +57,11 @@ import * as ProjectService from "../project/ProjectService.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
// Coder: no peer-link services to mock; `apps/server/src/peer` is not carried.
import type { McpInvocationScope } from "./McpInvocationContext.ts";
import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts";
import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
import { resolveRuntimeMode } from "./delegatedTaskModes.ts";
import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";

const decodeTarget = Schema.decodeUnknownEffect(OrchestratorMcpTarget);
Expand Down Expand Up @@ -2571,26 +2579,18 @@ describe("OrchestratorMcpService provider resolution", () => {

it.effect("refuses an explicit mode the target provider does not offer", () =>
Effect.gen(function* () {
const error = yield* OrchestratorMcpService.resolveRuntimeMode(
"full-access",
"auto-accept-edits",
muse,
).pipe(Effect.flip);
const error = yield* resolveRuntimeMode("full-access", "auto-accept-edits", muse).pipe(
Effect.flip,
);
assert.equal(error.code, "invalid_request");
assert.include(error.message, "approval-required, full-access");
}),
);

it.effect("steps an inherited mode down to the broadest one the provider offers", () =>
Effect.gen(function* () {
assert.equal(
yield* OrchestratorMcpService.resolveRuntimeMode("full-access", "inherit", muse),
"full-access",
);
assert.equal(
yield* OrchestratorMcpService.resolveRuntimeMode("auto", undefined, muse),
"approval-required",
);
assert.equal(yield* resolveRuntimeMode("full-access", "inherit", muse), "full-access");
assert.equal(yield* resolveRuntimeMode("auto", undefined, muse), "approval-required");
}),
);
});
Expand Down Expand Up @@ -2671,3 +2671,185 @@ describe("OrchestratorMcpService thread lookup", () => {
}),
);
});

describe("OrchestratorMcpService thread stop", () => {
const driver = ProviderDriverKind.make("codex");
const instanceId = ProviderInstanceId.make("codex");
const modelSelection = { instanceId, model: "gpt-5.4" };
const threadId = ThreadId.make("thread:mcp-stop");
const client: McpInvocationScope = {
environmentId: EnvironmentId.make("environment:mcp-stop"),
requestNamespace: "client:mcp-stop",
thread: undefined,
client: { sessionId: "mcp-stop", label: "Claude Code", access: "full-access" },
capabilities: new Set(["orchestration"]),
issuedAt: 1,
};

// A provider whose turns run until they are interrupted.
const liveAdapter = (cwd: string): ProviderAdapter.ProviderAdapterV2["Service"] => ({
instanceId,
driver,
getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }),
openSession: (input) =>
Effect.gen(function* () {
const now = yield* DateTime.now;
const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
return {
instanceId,
driver,
providerSessionId: input.providerSessionId,
providerSession: {
id: input.providerSessionId,
driver,
providerInstanceId: instanceId,
status: "ready",
cwd,
model: modelSelection.model,
capabilities: CodexProviderCapabilitiesV2,
createdAt: now,
updatedAt: now,
lastError: null,
},
events: Stream.fromQueue(events),
ensureThread: ({ threadId: appThreadId }) =>
Effect.succeed({
id: ProviderThreadId.make(`provider-thread:${appThreadId}`),
driver,
providerInstanceId: instanceId,
providerSessionId: input.providerSessionId,
appThreadId,
ownerNodeId: null,
nativeThreadRef: { driver, nativeId: `native:${appThreadId}`, strength: "strong" },
nativeConversationHeadRef: null,
status: "idle",
firstRunOrdinal: null,
lastRunOrdinal: null,
handoffIds: [],
forkedFrom: null,
createdAt: now,
updatedAt: now,
}),
resumeThread: ({ providerThread }) => Effect.succeed(providerThread),
startTurn: (turn) =>
Queue.offer(events, {
type: "provider_turn.updated",
driver,
providerTurn: {
id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`),
providerThreadId: turn.providerThread.id,
nodeId: turn.rootNodeId,
runAttemptId: turn.attemptId,
nativeTurnRef: null,
ordinal: turn.providerTurnOrdinal,
status: "running",
startedAt: now,
completedAt: null,
},
}).pipe(Effect.asVoid),
steerTurn: () => Effect.die("unused"),
interruptTurn: () => Effect.void,
respondToRuntimeRequest: () => Effect.die("unused"),
readThreadSnapshot: () => Effect.die("unused"),
rollbackThread: () => Effect.die("unused"),
forkThread: () => Effect.die("unused"),
};
}),
});

it.effect.each([
{ name: "a running turn", queued: false },
{ name: "a running turn with a queued message", queued: true },
])("t3_thread_interrupt stop reports the run it interrupted on $name", ({ queued }) =>
Effect.scoped(
Effect.gen(function* () {
const cwd = yield* checkpointWorkspace("mcp-thread-stop");
const layerAdapters = ProviderAdapterRegistry.layerSingle(liveAdapter(cwd));
const layerDatabase = SqlitePersistence.layerMemory;
const layerOrchestrator = ProviderReplayHarness.layerWithRegistry(
{ name: "mcp-thread-stop" },
layerAdapters,
{ databaseLayer: layerDatabase, runEffectWorker: false },
);
const layerThreads = ThreadManagementService.layer.pipe(
Layer.provideMerge(layerOrchestrator),
Layer.provide(ServerSettings.layerTest()),
Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
);
const layerTest = layerOrchestratorService.pipe(
Layer.provideMerge(layerThreads),
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
layerAdapters,
ServerSettings.layerTest(),
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
),
),
);
yield* Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
const send = (text: string, type: "start_immediately" | "queue_after_active") =>
orchestrator.dispatch({
type: "message.dispatch",
commandId: CommandId.make(`send:${text}`),
threadId,
messageId: MessageId.make(`message:${text}`),
text,
attachments: [],
dispatchMode: { type },
createdBy: "user",
creationSource: "web",
});
yield* orchestrator.dispatch({
type: "thread.create",
commandId: CommandId.make("create"),
threadId,
projectId: ProjectId.make("project:mcp-stop"),
title: "Stop",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: cwd,
createdBy: "user",
creationSource: "web",
});
const running = yield* orchestrator.streamDomainEvents.pipe(
Stream.filter(
(event) =>
event.type === "provider-turn.updated" && event.payload.status === "running",
),
Stream.take(1),
Stream.runDrain,
Effect.forkScoped,
);
yield* send("work", "start_immediately");
yield* worker.drain();
yield* Fiber.join(running);
if (queued) yield* send("later", "queue_after_active");
const before = yield* orchestrator.getThreadProjection(threadId);
const runningRun = before.runs.find((run) => run.status === "running")!;
assert.equal(before.runs.filter((run) => run.status === "queued").length, queued ? 1 : 0);

const stopped = yield* service.interruptThread(client, {
threadId,
stop: true,
clientRequestId: "stop",
});
assert.deepEqual(stopped, {
threadId,
runId: runningRun.id,
status: "interrupt_requested",
});
}).pipe(Effect.provide(layerTest));
}),
),
);
});
Loading