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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { PlanTurnService } from "../../planning/plan-turn-service.js";
import { PlanRepo } from "../../planning/persistence/plan-repo.js";
import { ProviderTurnEventApplication } from "../../turns/provider-turn-event-application.js";
import { ProviderAvailabilityService } from "../../../providers/availability/provider-availability-service.js";
import { McodeSessionAuthority } from "../../../mcode-mcp/mcode-session-authority.js";
import type { WorkerOwnedProviderEventResult } from "../../../providers/composition/provider-host-ports.js";
import { addClient, removeClient, subscribeClientToThread } from "../../../../application/transport/push.js";

Expand Down Expand Up @@ -206,7 +207,7 @@ describe("AgentService container composition", () => {
});
});

it("admits a provider-started turn with its opening notice and releases it after Ended", async () => {
it("admits a provider-started turn with its opening notice and active lease, then releases it after Ended", async () => {
const sendTurn = vi.fn(async (request: TurnRequest) => {
await submitCodexEvent(workerRuntime!, request, 1, { type: "turnComplete", threadId: request.threadId,
turnExecutionId: request.turnExecutionId, providerId: "codex", reason: "end_turn", costUsd: null,
Expand All @@ -222,8 +223,19 @@ describe("AgentService container composition", () => {
await container.resolve(AgentService).sendMessage({ threadId: thread.id, content: "start a background task", provider: "codex", model: "gpt-5.5", permissionMode: "full" });
await waitFor(() => workerRuntime?.owner.current(thread.id) === undefined);

const authority = container.resolve(McodeSessionAuthority);
const { token } = authority.attach({ providerId: "codex", sessionId: `mcode-${thread.id}`, threadId: thread.id, threadControl: true });
const session = authority.resolve(token);
if (!session) throw new Error("Provider session was not attached");
expect(() => authority.authorizeThreadControl(session, "before-opening"))
.toThrow(expect.objectContaining({ reason: "no_active_turn" }));

const opening = await container.resolve(TurnRuntimeController).openProviderTurn({ threadId: thread.id, notice: "Background command finished" });
if (opening.kind !== "opened") throw new Error(`Provider turn was declined: ${opening.reason}`);
expect(authority.authorizeThreadControl(session, "provider-continuation")).toMatchObject({
sourceThreadId: thread.id,
sourceTurnId: opening.routing.turnId,
});
const adopted = { threadId: opening.routing.threadId, turnId: opening.routing.turnId, turnExecutionId: opening.routing.executionId } as TurnRequest;
await submitCodexEvent(workerRuntime!, adopted, 1, { type: "textDelta", threadId: thread.id,
turnExecutionId: adopted.turnExecutionId, delta: "the task finished", isFinalResponse: true });
Expand All @@ -239,6 +251,8 @@ describe("AgentService container composition", () => {
expect(notice).toMatchObject({ role: "user", systemNotice: { kind: "provider-turn" } });
expect(container.resolve(TurnRuntimeController).snapshot(thread.id)).toMatchObject({ phase: "completed", turnExecutionId: adopted.turnExecutionId });
expect(container.resolve(ThreadRepo).findById(thread.id)?.model).toBe("gpt-5.5");
expect(() => authority.authorizeThreadControl(session, "after-ended"))
.toThrow(expect.objectContaining({ reason: "no_active_turn" }));
});

it("admits a provider-started turn that opens while the ended turn is still releasing", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function buildService({
assertUsable = vi.fn(),
resolveProvider = vi.fn(),
threadStatus = "idle",
threadPermissionMode = null,
approvalReviewSupport,
threadControlMcp = {
activate: vi.fn(),
Expand All @@ -96,10 +97,11 @@ function buildService({
assertUsable?: ReturnType<typeof vi.fn>;
resolveProvider?: ReturnType<typeof vi.fn>;
threadStatus?: PersistedThreadStatus;
threadPermissionMode?: Thread["permission_mode"];
approvalReviewSupport?: ApprovalReviewSupport;
threadControlMcp?: { activate: ReturnType<typeof vi.fn>; revoke: ReturnType<typeof vi.fn> };
} = {}) {
const thread = makeThread({ status: threadStatus });
const thread = makeThread({ status: threadStatus, permission_mode: threadPermissionMode });
const database = createOwnedTestDatabase();
const { db, writer } = database;
db.run("INSERT INTO workspaces (id, name, path) VALUES (?, ?, ?)", ["ws-1", "Fixture", process.cwd()]);
Expand Down Expand Up @@ -395,10 +397,11 @@ describe("AgentService internal MCP provider allowlist", () => {
});

it.each([
"Spawn exactly one nested provider-native child named leaf_probe",
"Delegate this task to a provider-native subagent",
])("revokes thread control at turn start for child-agent wording: %s", async (content) => {
const { svc, threadControlMcp } = buildService();
"Farm this out to Codex",
"Call delegate_task exactly once to hand this review to Codex. Do not call any other tool.",
"Do not create an Mcode thread",
])("activates thread control whatever the message says: %s", async (content) => {
const { svc, threadControlMcp, providerStub } = buildService();

await svc.sendMessage({
threadId: THREAD_ID,
Expand All @@ -409,26 +412,33 @@ describe("AgentService internal MCP provider allowlist", () => {
provider: "codex",
});

expect(threadControlMcp.activate).not.toHaveBeenCalled();
expect(threadControlMcp.revoke).toHaveBeenCalledWith(`mcode-${THREAD_ID}`);
expect(threadControlMcp.revoke).not.toHaveBeenCalled();
expect(threadControlMcp.activate).toHaveBeenCalledWith(expect.objectContaining({
sessionId: `mcode-${THREAD_ID}`,
sourceThreadId: THREAD_ID,
permissionMode: "supervised",
}));
expect(providerStub.sendTurn).toHaveBeenCalledWith(expect.objectContaining({
threadControlEligible: true,
}));
});

it("activates thread control only for an explicit Mcode thread request", async () => {
const { svc, threadControlMcp } = buildService();
it("activates full thread control for a default command on a persisted full thread", async () => {
const { svc, threadControlMcp } = buildService({ threadPermissionMode: "full" });

await svc.sendMessage({
threadId: THREAD_ID,
content: "Create one Mcode thread named leaf_probe",
content: "Farm this out to Codex",
permissionMode: "default",
model: "claude-sonnet-4-6",
attachments: [],
provider: "codex",
});

expect(threadControlMcp.revoke).not.toHaveBeenCalled();
expect(threadControlMcp.activate).toHaveBeenCalledWith(expect.objectContaining({
sessionId: `mcode-${THREAD_ID}`,
eligible: true,
sourceThreadId: THREAD_ID,
permissionMode: "full",
}));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1067,12 +1067,7 @@ export class TurnRuntimeController implements TurnLifecycleControl, TurnRuntimeE

/** Apply the provider-neutral thread-control directive selected during admission. */
private applyThreadControlLease(directive: ThreadControlLeaseDirective | null): void {
if (!directive) return;
if (directive.kind === "revoke") {
this.threadControlMcp?.revoke(directive.sessionId);
return;
}
this.threadControlMcp?.activate({ ...directive, eligible: true });
if (directive) this.threadControlMcp?.activate(directive);
}

/** Send one attempt and hand only retryable failures to the existing retry owner. */
Expand Down Expand Up @@ -2198,6 +2193,7 @@ export class TurnRuntimeController implements TurnLifecycleControl, TurnRuntimeE

/** Record an adopted turn's dispatch state without sending a prompt the provider never received. */
private adoptPreparedProviderDispatch(prepared: PreparedTurnDispatch): void {
this.applyThreadControlLease(prepared.threadControl);
const dispatch = this.createRetryDispatch(prepared);
dispatch.dispatchStarted = true;
this.turnRetryDispatchByThread.set(prepared.lease.threadId, dispatch);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
type TurnRuntimeSnapshot,
} from "@mcode/contracts";
import { logger, validateBranchName } from "@mcode/shared";
import { isExplicitMcodeThreadRequest } from "@mcode/thread-orchestration";
import { broadcast } from "../../../application/transport/push.js";
import { publishSynthesizedAgentEvents } from "../canonical/synthesized-agent-event-publication.js";
import { AttachmentService } from "../../attachments/storage/attachment-service.js";
Expand Down Expand Up @@ -176,16 +175,13 @@ export interface TurnParentStartOwner {
}

/** The thread-control action selected during generic dispatch preparation. */
export type ThreadControlLeaseDirective =
| {
readonly kind: "activate";
readonly sessionId: string;
readonly sourceThreadId: string;
readonly sourceTurnId: string;
readonly sourceProviderId: ProviderId;
readonly permissionMode: "supervised" | "full";
}
| { readonly kind: "revoke"; readonly sessionId: string };
export interface ThreadControlLeaseDirective {
readonly sessionId: string;
readonly sourceThreadId: string;
readonly sourceTurnId: string;
readonly sourceProviderId: ProviderId;
readonly permissionMode: "supervised" | "full";
}

/** A provider-ready turn package with no persistence or provider-selection work left. */
export interface PreparedTurnDispatch {
Expand Down Expand Up @@ -1199,7 +1195,7 @@ export class TurnAdmissionDispatchCoordinator {
attachments: attachments.persisted.length > 0 ? attachments.persisted : undefined,
reasoningLevel: prepared.command.reasoningLevel,
...guardrails,
threadControlEligible: this.isThreadControlEligible(prepared),
threadControlEligible: supportsInternalThreadControl(prepared.providerId),
resumeFrom: this.resumeFrom(prepared, attachments),
...(prepared.command.resumeRequired ? { resumeRequired: true } : {}),
providerOptions: this.providerOptions(prepared, settings),
Expand Down Expand Up @@ -1297,21 +1293,14 @@ export class TurnAdmissionDispatchCoordinator {
return thread.sdk_session_id ?? undefined;
}

private isThreadControlEligible(prepared: PreparedCommand): boolean {
return supportsInternalThreadControl(prepared.providerId) && isExplicitMcodeThreadRequest(prepared.command.content);
}

private threadControlDirective(prepared: PreparedCommand, sourceTurnId: string): ThreadControlLeaseDirective | null {
if (!supportsInternalThreadControl(prepared.providerId)) return null;
const sessionId = `mcode-${prepared.command.threadId}`;
if (!this.isThreadControlEligible(prepared)) return { kind: "revoke", sessionId };
return {
kind: "activate",
sessionId,
sessionId: `mcode-${prepared.command.threadId}`,
sourceThreadId: prepared.command.threadId,
sourceTurnId,
sourceProviderId: prepared.providerId,
permissionMode: prepared.command.permissionMode === "full" ? "full" : "supervised",
permissionMode: this.effectivePermissionMode(prepared.command.permissionMode, prepared.thread.permission_mode ?? "supervised"),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,6 @@ describe("McodeSessionAuthority", () => {
expect(authority.signal("mcode-source")).toBeUndefined();
});

it("denies an ineligible turn from the start without inheriting a prior grant", () => {
const authority = new McodeSessionAuthority();
authority.activate(turn({ sourceTurnId: "child-turn", eligible: false }));
expect(() => authority.authorizeThreadControl(session, "child-mcp-call")).toThrow(McodeThreadControlDeniedError);

authority.activate(turn({ sourceTurnId: "explicit-turn", eligible: true }));
expect(authority.authorizeThreadControl(session, "parent-mcp-call")).toMatchObject({
sourceTurnId: "explicit-turn",
});

authority.activate(turn({ sourceTurnId: "next-child-turn", eligible: false }));
expect(() => authority.authorizeThreadControl(session, "next-child-mcp-call")).toThrow(McodeThreadControlDeniedError);
});

it("rejects unequal credential lengths before constant-time comparison", () => {
expect(constantTimeCredentialEqual("credential", "credential")).toBe(true);
expect(constantTimeCredentialEqual("credential", "credential-extra")).toBe(false);
Expand Down Expand Up @@ -146,18 +132,19 @@ describe("McodeSessionAuthority", () => {
expect(authority.signal("mcode-source")?.aborted).toBe(false);
});

it("refuses thread control without an active eligible lease and bounds the tool call id", () => {
it("refuses thread control without an active lease and bounds the tool call id", () => {
const authority = new McodeSessionAuthority();
const { token } = authority.attach(attachRequest());
const session = authority.resolve(token)!;

expect(() => authority.authorizeThreadControl(session, "call")).toThrow(McodeThreadControlDeniedError);
expect(() => authority.authorizeThreadControl(session, "call"))
.toThrow(expect.objectContaining({ reason: "no_active_turn" }));
authority.activate(turn());
expect(authority.authorizeThreadControl(session, "call")).toMatchObject({ sourceThreadId: "source" });
expect(() => authority.authorizeThreadControl(session, "")).toThrow(McodeThreadControlDeniedError);
expect(() => authority.authorizeThreadControl(session, "x".repeat(129))).toThrow(McodeThreadControlDeniedError);
authority.activate(turn({ eligible: false }));
expect(() => authority.authorizeThreadControl(session, "call")).toThrow(McodeThreadControlDeniedError);
expect(() => authority.authorizeThreadControl(session, ""))
.toThrow(expect.objectContaining({ reason: "invalid_tool_call" }));
expect(() => authority.authorizeThreadControl(session, "x".repeat(129)))
.toThrow(expect.objectContaining({ reason: "invalid_tool_call" }));
});

it("scopes thread-control authority to the resolving session", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const session: McodeSession = {
threadControl: true,
};

const DENIED = "Internal thread-control MCP request denied";
const NO_ACTIVE_TURN = /^Mcode thread control is unavailable because no user turn is active for this session\./;

const thread = {
workspaceId: "workspace-1",
Expand Down Expand Up @@ -58,17 +58,15 @@ function createService() {
} satisfies ThreadControlToolService;
}

function setup(options: { lease?: "active" | "ineligible" | "none"; threadControl?: boolean } = {}) {
function setup(options: { lease?: "active" | "none"; threadControl?: boolean } = {}) {
const authority = new McodeSessionAuthority();
const lease = options.lease ?? "active";
if (lease !== "none") {
if (options.lease !== "none") {
authority.activate({
sessionId: session.sessionId,
sourceThreadId: session.threadId,
sourceTurnId: "source-turn",
sourceProviderId: "claude",
permissionMode: "full",
eligible: lease === "active",
});
}
const service = createService();
Expand Down Expand Up @@ -157,12 +155,12 @@ describe("thread-control toolkit", () => {
});
});

it.each(["none", "ineligible"] as const)("refuses every call when the turn lease is %s", async (lease) => {
const { call, service } = setup({ lease });
it("refuses every call without a turn lease and says why", async () => {
const { call, service } = setup({ lease: "none" });

await expect(call("workspace_search", { query: "mcode" })).resolves.toMatchObject({
isError: true,
content: [{ type: "text", text: DENIED }],
content: [{ type: "text", text: expect.stringMatching(NO_ACTIVE_TURN) }],
});
expect(service.workspaceSearch).not.toHaveBeenCalled();
});
Expand All @@ -188,7 +186,7 @@ describe("thread-control toolkit", () => {

await expect(call("thread_search", { statuses: [] })).resolves.toMatchObject({
isError: true,
content: [{ type: "text", text: DENIED }],
content: [{ type: "text", text: expect.stringMatching(NO_ACTIVE_TURN) }],
});
});

Expand Down
33 changes: 18 additions & 15 deletions apps/server/src/features/mcode-mcp/mcode-session-authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ interface ActiveLease {
sourceProviderId: string;
permissionMode: "supervised" | "full";
active: boolean;
/** Whether the current user request explicitly authorized Mcode thread control. */
eligible: boolean;
controller: AbortController;
}

Expand Down Expand Up @@ -38,8 +36,6 @@ export interface ActivateMcodeSessionLease {
sourceTurnId: string;
sourceProviderId: string;
permissionMode: "supervised" | "full";
/** Explicit per-turn eligibility derived from the original user request. */
eligible?: boolean;
}

/** Opaque credential and stable session binding supplied to a provider transport. */
Expand All @@ -48,10 +44,18 @@ export interface McodeSessionCredential {
sessionId: string;
}

/** Refuses a thread-control call that has no active, eligible turn lease. */
/** Why a thread-control call was refused. */
export type McodeThreadControlDenial = "invalid_tool_call" | "no_active_turn";

const DENIAL_MESSAGES: Record<McodeThreadControlDenial, string> = {
invalid_tool_call: "Mcode thread control denied this call because its tool call id is empty or longer than 128 characters.",
no_active_turn: "Mcode thread control is unavailable because no user turn is active for this session. Thread-control tools work only while a user turn runs.",
};

/** Refuses a thread-control call that has no active turn lease. */
export class McodeThreadControlDeniedError extends Error {
constructor() {
super("Internal thread-control MCP request denied");
constructor(readonly reason: McodeThreadControlDenial) {
super(DENIAL_MESSAGES[reason]);
}
}

Expand Down Expand Up @@ -110,7 +114,6 @@ export class McodeSessionAuthority {
sourceTurnId: input.sourceTurnId,
sourceProviderId: input.sourceProviderId,
permissionMode: input.permissionMode,
eligible: input.eligible ?? true,
active: true,
controller: new AbortController(),
};
Expand Down Expand Up @@ -146,12 +149,10 @@ export class McodeSessionAuthority {

/**
* Builds the thread-control authority from the session's turn lease.
* @throws McodeThreadControlDeniedError when the lease is missing, revoked, or ineligible.
* @throws McodeThreadControlDeniedError when the lease is missing or revoked.
*/
authorizeThreadControl(session: McodeSession, sourceToolCallId: string): InternalThreadControlAuthority {
const authority = authorizeEntry(this.entries.get(session.sessionId), sourceToolCallId);
if (!authority) throw new McodeThreadControlDeniedError();
return authority;
return authorizeEntry(this.entries.get(session.sessionId), sourceToolCallId);
}

/** Returns the revocation signal for a session while its turn lease is active. */
Expand Down Expand Up @@ -182,10 +183,12 @@ function activeSignal(entry: SessionEntry | undefined): AbortSignal | undefined
function authorizeEntry(
entry: SessionEntry | undefined,
sourceToolCallId: string,
): InternalThreadControlAuthority | undefined {
if (sourceToolCallId.length < 1 || sourceToolCallId.length > 128) return undefined;
): InternalThreadControlAuthority {
if (sourceToolCallId.length < 1 || sourceToolCallId.length > 128) {
throw new McodeThreadControlDeniedError("invalid_tool_call");
}
const lease = entry?.active;
if (!lease?.active || !lease.eligible) return undefined;
if (!lease?.active) throw new McodeThreadControlDeniedError("no_active_turn");
return {
type: "internal",
userId: "local-user",
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/providers/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export interface TurnRequest<P extends ProviderId = ProviderId> {
maxBudgetUsd?: number;
/** Max agent turns for this Turn. Undefined or 0 disables. */
maxTurns?: number;
/** Whether this turn's user request explicitly authorizes Mcode thread control. */
/** Whether the server grants this turn Mcode thread control. */
threadControlEligible?: boolean;
/**
* SDK session id to resume from. When defined the Provider resumes that
Expand Down
Loading
Loading