From 1b1ad0466d0f213fb7c3686b7ae78df198162bf2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:24 -0700 Subject: [PATCH 1/9] feat(server): /mcp narrows a client's modes from T3-Mode-Limit (#16653) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.test.ts | 146 ++++++++++++++++++ apps/server/src/mcp/McpHttpServer.ts | 35 ++++- apps/server/src/mcp/McpInvocationContext.ts | 55 ++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 7 +- apps/server/src/mcp/threadAccess.ts | 5 +- 5 files changed, 235 insertions(+), 13 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 8d7fb85a9e9b8..23b9acf9f15e6 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,5 +1,6 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -8,6 +9,8 @@ import { OrchestratorMcpFailure, PreviewTabId, ProviderInstanceId, + ProviderInteractionMode, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -940,3 +943,146 @@ it.effect("lists pull request watch tools only to threads that are not delegated }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect( + "narrows an OAuth client's modes to the T3-Mode-Limit it sends, and never widens them", + () => + Effect.scoped( + Effect.gen(function* () { + const clientToken = "client-payload.client-signature"; + const clientScope: McpInvocationContext.McpInvocationScope = { + environmentId, + requestNamespace: "client:linked", + thread: undefined, + client: { sessionId: "linked", label: "T3 Code · laptop", access: "auto" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + // Starts a thread at the modes the gate hands it, so the outcome is those modes. + const StartsToolkit = Toolkit.make( + Tool.make("starts", { + description: "Reports the modes a started thread would run with.", + parameters: Schema.Struct({ + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), + }), + success: Schema.Struct({ modes: Schema.String }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ], + }), + ); + const serverLayer = McpHttpServer.toolkitRegistration( + StartsToolkit, + McpToolAccess.toLayer(StartsToolkit, { + starts: McpToolAccess.startsThreads( + (input) => input, + (_, modes) => + Effect.succeed({ modes: `${modes.runtimeMode}/${modes.interactionMode}` }), + ), + }), + ).pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + resolve: () => Effect.succeed(undefined), + }), + ), + Layer.provide( + Layer.succeed(McpHttpServer.McpClientAuthenticator, { + authenticate: (request) => + Effect.succeed( + request.headers.authorization === `Bearer ${clientToken}` + ? clientScope + : undefined, + ), + }), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const decodeOutcome = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array(Schema.Struct({ text: Schema.String })), + }), + }), + ), + ); + const decodeText = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Union([ + Schema.Struct({ modes: Schema.String }), + Schema.Struct({ code: Schema.String }), + ]), + ), + ); + /** Starts a thread as the client, with `limit` as T3-Mode-Limit; the HTTP status or the outcome. */ + const start = (limit: string | undefined, args: Record = {}) => + Effect.gen(function* () { + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${clientToken}`, + ...(limit === undefined ? {} : { "t3-mode-limit": limit }), + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + if (initialize.status !== 200) return initialize.status; + const response = yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "starts", arguments: args }, + }), + "application/json", + ), + }); + const body = yield* decodeOutcome(yield* response.text); + const outcome = yield* decodeText( + body.result.content.map((part) => part.text).join(""), + ); + return "code" in outcome ? outcome.code : outcome.modes; + }); + + // Approved for auto: without the header the client keeps its ceiling. + expect(yield* start(undefined)).toBe("auto/default"); + // The header narrows both modes, and asking above it is refused. + expect(yield* start("approval-required/plan")).toBe("approval-required/plan"); + expect(yield* start("approval-required/plan", { runtimeMode: "auto" })).toBe( + "runtime_mode_escalation_denied", + ); + expect(yield* start("auto-accept-edits/plan", { interactionMode: "default" })).toBe( + "interaction_mode_escalation_denied", + ); + // A broader header changes nothing: the approved ceiling still holds. + expect(yield* start("full-access/default")).toBe("auto/default"); + expect(yield* start("full-access/default", { runtimeMode: "full-access" })).toBe( + "runtime_mode_escalation_denied", + ); + // A malformed header is refused before anything runs. + expect(yield* start("auto")).toBe(400); + expect(yield* start("auto/default/extra")).toBe(400); + expect(yield* start("root/default")).toBe(400); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 0d8837f0ab875..6220f7c5cde89 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -16,7 +16,9 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { OrchestratorMcpFailure, PreviewAutomationError, + ProviderInteractionMode, type RunId, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; @@ -150,6 +152,35 @@ const normalizeMcpHttpResponse = ( // Session tokens are `.`; registry tokens are a bare base64url secret. const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes("."); +const decodeModeLimit = Schema.decodeUnknownOption( + Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode }), +); +const parseModeLimit = (value: string) => { + const [runtimeMode, interactionMode, ...rest] = value.trim().split("/"); + return rest.length === 0 ? decodeModeLimit({ runtimeMode, interactionMode }) : Option.none(); +}; + +const invalidModeLimit = HttpServerResponse.jsonUnsafe( + { + error: "invalid_mode_limit", + message: "T3-Mode-Limit must be /, e.g. auto/default.", + }, + { status: 400, headers: { "cache-control": "no-store" } }, +); + +/** Applies a client's requested mode limit; undefined when the header is malformed. */ +const withModeLimit = ( + invocation: McpInvocationContext.McpInvocationScope, + request: HttpServerRequest.HttpServerRequest, +): McpInvocationContext.McpInvocationScope | undefined => { + const header = request.headers[McpInvocationContext.MODE_LIMIT_HEADER]; + if (header === undefined || invocation.client === undefined) return invocation; + const limit = parseModeLimit(header); + return Option.isNone(limit) + ? undefined + : { ...invocation, client: { ...invocation.client, narrowedTo: limit.value } }; +}; + const makeMcpAuthMiddleware = Effect.gen(function* () { const registry = yield* McpSessionRegistry.McpSessionRegistry; const clients = yield* Effect.serviceOption(McpClientAuthenticator); @@ -175,8 +206,10 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token), }); } + const limited = withModeLimit(invocation, request); + if (limited === undefined) return invalidModeLimit; return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, limited), Effect.map(normalizeMcpHttpResponse), ); }) satisfies McpAuthMiddleware; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index cc45af6bae4df..3553f0c3a776b 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -5,6 +5,7 @@ import { OrchestratorMcpFailure, PreviewAutomationUnavailableError, type ProviderInstanceId, + type ProviderInteractionMode, type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; @@ -33,11 +34,59 @@ export interface McpClientCaller { readonly sessionId: string; readonly label: string; readonly access: AuthMcpClientAccess; + /** + * Narrower modes the client asked for on this request (`T3-Mode-Limit`): + * another environment passing on the limits of the agent it calls for. + */ + readonly narrowedTo?: ClientModes | undefined; } -/** 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; +/** + * `T3-Mode-Limit: /` lets an OAuth client cap + * a request below what it was approved with: another environment passes on + * the limits of the agent it calls for. It can only narrow (see + * `clientModeCeiling`), so a client gains nothing by sending a broad one. + */ +export const MODE_LIMIT_HEADER = "t3-mode-limit"; + +export interface ClientModes { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +const runtimeModeRank: Record = { + "approval-required": 0, + "auto-accept-edits": 1, + auto: 2, + "full-access": 3, +}; +const interactionModeRank: Record = { plan: 0, default: 1 }; + +/** + * The modes a client caller's writes are capped at: what it was approved + * with, narrowed by what it asked for on this request. A read-only client + * never reaches a write (`McpToolAccess` refuses it first), so it maps to the + * lowest runtime mode rather than to nothing. + */ +export const clientModeCeiling = (client: McpClientCaller | undefined): ClientModes => { + const approved: ClientModes = { + runtimeMode: + client === undefined || client.access === "read-only" ? "approval-required" : client.access, + interactionMode: "default", + }; + const asked = client?.narrowedTo; + if (asked === undefined) return approved; + return { + runtimeMode: + runtimeModeRank[asked.runtimeMode] < runtimeModeRank[approved.runtimeMode] + ? asked.runtimeMode + : approved.runtimeMode, + interactionMode: + interactionModeRank[asked.interactionMode] < interactionModeRank[approved.interactionMode] + ? asked.interactionMode + : approved.interactionMode, + }; +}; /** Thread callers default to their own thread; client callers need thread for tools that act as the caller. */ export interface McpInvocationScope { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 54c9717671087..1ea11b9a4afd9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -106,7 +106,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { - clientRuntimeModeCeiling, + clientModeCeiling, type McpInvocationScope, type McpThreadInvocationScope, requireThreadScope, @@ -1153,10 +1153,7 @@ const make = Effect.gen(function* () { if (scope.thread === undefined) { return { parent: undefined, - limits: { - runtimeMode: clientRuntimeModeCeiling(scope.client), - interactionMode: "default", - } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, + limits: clientModeCeiling(scope.client), } as const; } const parent = yield* loadProjection(scope.thread.threadId); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 9f3a8a57c05b2..1c0e3e543e5a7 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -68,10 +68,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { scope, threads, caller: undefined, - limits: { - runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client), - interactionMode: "default", - }, + limits: McpInvocationContext.clientModeCeiling(scope.client), } satisfies Caller; } const caller = yield* threads From 888e444c1afe4619660b12cbd224e4e95ee7b2cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:24 -0700 Subject: [PATCH 2/9] feat(server): t3_thread_launch takes clientRequestId (#16654) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/mcp/toolkits/project/handlers.test.ts | 165 +++++++++++++++++- .../src/mcp/toolkits/project/handlers.ts | 49 +++++- apps/server/src/mcp/toolkits/project/tools.ts | 4 +- .../ThreadLaunchService.test.ts | 34 ++++ .../orchestration-v2/ThreadLaunchService.ts | 16 +- .../src/provider/agentContextBudget.json | 2 +- packages/contracts/src/orchestratorMcp.ts | 13 +- packages/contracts/src/threadMetadataMcp.ts | 2 +- 8 files changed, 268 insertions(+), 17 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 4212fe5b16dfc..7232a3ebdaa1b 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -6,10 +6,13 @@ import { type Project as ProjectRecord, type ModelSelection, type RuntimeMode, + type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; @@ -44,6 +47,10 @@ const clientLaunchHarness = (input: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus", }; + // The project's own default model, which a test can clear between launches. + const projectDefaults: { model: ModelSelection | null } = { + model: input.overrideModel === undefined ? modelSelection : null, + }; const layerDependencies = Layer.mergeAll( ServerSettings.layerTest({ ...(input.defaultRuntimeMode === undefined @@ -71,7 +78,22 @@ const clientLaunchHarness = (input: { issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), - Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(ThreadManagement.ThreadManagementService)({ + // A thread exists once a launch started it. + getThreadShell: (threadId) => { + const launch = input.launched.find((candidate) => candidate.threadId === threadId); + return Effect.succeed( + launch === undefined + ? null + : ({ + id: launch.threadId, + projectId: launch.projectId, + modelSelection: launch.modelSelection, + deletedAt: null, + } as unknown as OrchestrationV2ThreadShell), + ); + }, + }), Layer.mock(ThreadLaunch.ThreadLaunchService)({ checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void), launch: (launch) => { @@ -98,7 +120,7 @@ const clientLaunchHarness = (input: { ? Option.some({ id, workspaceRoot: input.workspaceRoot ?? "/projects/client-target", - defaultModelSelection: input.overrideModel === undefined ? modelSelection : null, + defaultModelSelection: projectDefaults.model, } as unknown as ProjectRecord) : Option.none(), ), @@ -115,7 +137,7 @@ const clientLaunchHarness = (input: { ), Layer.provideMerge(NodeServices.layer), ); - return { projectId, modelSelection, dependencies: layerDependencies }; + return { projectId, modelSelection, projectDefaults, dependencies: layerDependencies }; }; it.effect( @@ -200,6 +222,100 @@ it.effect( }), ); +it.effect("a launch retried with the same clientRequestId replays the first one", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launch = (clientRequestId: string | undefined) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + message: "Fix the bug", + ...(clientRequestId === undefined ? {} : { clientRequestId }), + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + yield* launch("fix-1"); + yield* launch("fix-1"); + yield* launch("fix-2"); + yield* launch(undefined); + yield* launch(undefined); + const ids = launched.map((input) => [input.commandId, input.threadId]); + // The same key sends the same command and thread ids, which the launch + // service replays instead of starting a second thread. + expect(ids[0]).toEqual(ids[1]); + expect(ids[0]?.[0]).toMatch(/^mcp:launch:[0-9a-f]{64}$/); + expect(ids[2]).not.toEqual(ids[0]); + // Without a key every launch is new. + expect(ids[3]).not.toEqual(ids[4]); + }), +); + +it.effect("a launch key of any length or content gives a short id and never a defect", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launch = (clientRequestId: string) => + toolkit + .handle("t3_thread_launch", { title: "Fix", projectId, clientRequestId }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + // Percent-encoding would grow this six times; scratch folders are named from the id. + yield* launch("\u00e9".repeat(256)); + expect(launched.map((input) => input.commandId)).toEqual([ + expect.stringMatching(/^mcp:launch:[0-9a-f]{64}$/), + ]); + + const loneSurrogate = yield* launch("key-\ud800").pipe(Effect.exit); + expect(Exit.isFailure(loneSurrogate) && Cause.hasDies(loneSurrogate.cause)).toBe(false); + expect(launched).toHaveLength(1); + }), +); + +it.effect("a retried launch replays after the project loses its default model", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, modelSelection, projectDefaults, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launch = toolkit + .handle("t3_thread_launch", { title: "Fix", projectId, clientRequestId: "fix-1" }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + yield* launch; + projectDefaults.model = null; + const retried = (yield* launch).at(-1)?.result; + expect(retried).toMatchObject({ projectId, modelSelection }); + expect(launched.map((input) => input.threadId)).toEqual([ + launched[0]?.threadId, + launched[0]?.threadId, + ]); + }), +); + it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -302,3 +418,46 @@ it.effect("a launch into a new worktree creates nothing when its base ref has no expect(launched).toHaveLength(1); }), ); + +it.effect("a retried worktree launch replays after its base ref is gone", () => + Effect.gen(function* () { + const launched: Array = []; + const base = { exists: true }; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + checkWorktreeBase: ({ projectId, workspaceStrategy }) => + base.exists || workspaceStrategy.type !== "worktree" + ? Effect.void + : Effect.fail( + new ThreadLaunch.ThreadLaunchBaseRefError({ + projectId, + projectTitle: "Client target", + workspaceRoot: "/projects/client-target", + baseRef: workspaceStrategy.baseRef, + }), + ), + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launch = toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + clientRequestId: "fix-worktree", + workspaceStrategy: { type: "worktree", baseRef: "feature", startFromOrigin: false }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + yield* launch; + base.exists = false; + expect((yield* launch).at(-1)?.result).toMatchObject({ projectId }); + expect(launched.map((input) => input.threadId)).toEqual([ + launched[0]?.threadId, + launched[0]?.threadId, + ]); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index aaf37468f8cde..af97cd3694f11 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,5 +1,13 @@ -import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + ThreadId, + OrchestratorMcpFailure, + ProjectId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; @@ -48,6 +56,18 @@ const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* ( }); }); +/** A fixed-length id, so a long key fits in the scratch folder name derived from it. */ +const launchCommandId = Effect.fn("mcp.launchCommandId")(function* ( + namespace: string, + key: string, +) { + const crypto = yield* Crypto.Crypto; + // @effect-diagnostics-next-line preferSchemaOverJson:off - a JSON pair keeps the caller apart from the key. + const bytes = new TextEncoder().encode(JSON.stringify([namespace, key])); + const digest = yield* crypto.digest("SHA-256", bytes).pipe(Effect.mapError(unavailable)); + return CommandId.make(`mcp:launch:${Hex.encode(digest)}`); +}); + const access = Effect.gen(function* () { yield* readCaller(); return yield* Project.ProjectService; @@ -58,8 +78,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { const context = yield* readCaller(); - const { caller } = context; - const commandId = yield* newCommandId(); + const { caller, scope } = context; + // A retry with the same key replays the first launch: the ids derive + // from the caller and the key, and the launch service replays a + // command it already accepted. + const commandId = + input.clientRequestId === undefined + ? yield* newCommandId() + : yield* launchCommandId(scope.requestNamespace, input.clientRequestId); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); const attachments = input.attachments ?? []; @@ -95,7 +121,17 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { Effect.mapError(unavailable), Effect.map(Option.getOrUndefined), ); - if (input.workspaceStrategy?.type === "existing_worktree") { + // A retry of an accepted launch replays it: it keeps the model it started + // with and skips the checks only a new launch needs, which the project + // may no longer pass, such as a base branch deleted since. + const launched = + input.clientRequestId === undefined + ? null + : yield* context.threads.getThreadShell(threadId).pipe(Effect.mapError(unavailable)); + const launchedModel = + launched === null || launched.deletedAt !== null ? undefined : launched.modelSelection; + const replaying = launchedModel !== undefined; + if (!replaying && input.workspaceStrategy?.type === "existing_worktree") { const project = yield* readProject; if (project === undefined) return yield* new OrchestratorMcpFailure({ @@ -106,7 +142,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { } // Reject unresolved base refs before accepting a launch; worktree provisioning happens later. const strategy = input.workspaceStrategy; - if (strategy?.type === "worktree") + if (!replaying && strategy?.type === "worktree") yield* ThreadLaunch.ThreadLaunchService.pipe( Effect.flatMap((launches) => launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }), @@ -127,7 +163,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { // project's effective defaults, as the composer does. Its mode stays // within the ceiling it was approved with. const projectDefaults = - caller === undefined + caller === undefined && !replaying ? yield* ServerSettings.ServerSettingsService.pipe( Effect.flatMap((settings) => settings.getSettings), Effect.mapError(unavailable), @@ -141,6 +177,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { : undefined; const modelSelection = input.modelSelection ?? + launchedModel ?? caller?.modelSelection ?? projectDefaults?.defaultModelSelection ?? undefined; diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 6acd7b1a4a27e..72c1055372497 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -13,6 +13,7 @@ import { ProjectCreatePayload, ProjectUpdatePayload, ProjectId, + OrchestratorMcpClientRequestId, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -101,7 +102,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - "Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and modes to inherit yours (outside a thread, pass `projectId` or `scratch`); modes cannot exceed yours. No retry key: after an error or lost reply, check `t3_thread_list` before retrying. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.", + "Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and modes to inherit yours (outside a thread, pass `projectId` or `scratch`); modes cannot exceed yours. After an error or lost reply, retry with the same `clientRequestId`. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.", parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -126,6 +127,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), ), attachments: Schema.optional(Schema.Array(McpAttachmentInput).check(Schema.isMaxLength(8))), + clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }), success: Schema.Struct({ threadId: ThreadId, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index a091d96feda69..f3a7ede997239 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1423,6 +1423,40 @@ it.effect("replays an accepted launch after the project disables the instance", }).pipe(Effect.provide(harness.layer)); }); +it.effect("an agent's launch never starts work above the modes it asks for", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + // The first launch, at full access, creates the thread without a message. + const created = yield* launches.launch( + launchInput({ command: "command:launch:narrowed-retry", thread: "thread:narrowed" }), + ); + // The same key comes back from a narrower agent, now carrying a message, + // as a retry or a concurrent launch that lost the create would. + const failed = yield* launches + .launch({ + ...launchInput({ + command: "command:launch:narrowed-retry", + thread: "thread:narrowed", + message: "Run this", + }), + runtimeMode: "approval-required", + interactionMode: "plan", + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.flip); + if (failed._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${failed._tag}`); + } + assert.equal(failed.operation, "dispatch-message"); + const projection = yield* threads.getThreadProjection(created.threadId); + assert.equal(projection.runs.length, 0); + assert.equal(projection.messages.length, 0); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index ff032f757bbe2..763ef25b43626 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -49,6 +49,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type * as Orchestrator from "./Orchestrator.ts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { DispatchModeLimit } from "./DispatchModeLimit.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; export type ThreadLaunchWorkspaceStrategy = @@ -929,7 +930,6 @@ const make = Effect.gen(function* () { if (project.id !== input.projectId) { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } - let runId: RunId | null = null; let messageWasAlreadyAccepted = false; if (input.initialMessage !== undefined) { @@ -962,7 +962,19 @@ const make = Effect.gen(function* () { createdBy: input.createdBy, creationSource: input.creationSource, }) - .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))); + .pipe( + // An agent's launch can land on a thread that a replay, or a concurrent + // launch with the same key, created with broader modes. The orchestrator + // refuses the message under the thread's lock; the user's own clients + // launch into their own drafts, whatever modes those hold. + input.createdBy === "agent" + ? Effect.provideService(DispatchModeLimit, { + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }) + : (effect) => effect, + Effect.mapError(mapError(input, "dispatch-message", threadId)), + ); // A concurrent retry can read no receipt and still replay the dispatch. messageWasAlreadyAccepted ||= dispatched.replayed === true; const runCreated = dispatched.storedEvents.find( diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index a54335979eb4c..38ac884823a10 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 4857, "codexAdditionalContextBytes": 5409, - "mcpToolListJsonBytes": 42328 + "mcpToolListJsonBytes": 42349 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index bb2820ad44282..6ec8593d193b1 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -30,6 +30,7 @@ import { ThreadPullRequestKey, ThreadPullRequestLinkSource, } from "./threadPullRequest.ts"; +import { hasOnlyPairedSurrogates } from "./threadMetadataMcp.ts"; import { ThreadTitleRegeneration } from "./threadTitle.ts"; import { OrchestrationV2Actor, @@ -42,9 +43,15 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)); const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)); -const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( - Schema.isMaxLength(256), -).annotate({ description: "Reuse it to retry the same call. A new call gets a new key." }); +export const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)) + .check( + Schema.makeFilter((value) => + hasOnlyPairedSurrogates(value) + ? true + : "clientRequestId must contain valid paired Unicode surrogates.", + ), + ) + .annotate({ description: "Reuse it to retry the same call. A new call gets a new key." }); // OpenCode 1.15 serializes nested MCP union objects as JSON strings; decode that form at the boundary. const OrchestratorMcpScheduleFromJsonString = Schema.fromJsonString( diff --git a/packages/contracts/src/threadMetadataMcp.ts b/packages/contracts/src/threadMetadataMcp.ts index 8c921ae9ba60a..e5332ac56ab47 100644 --- a/packages/contracts/src/threadMetadataMcp.ts +++ b/packages/contracts/src/threadMetadataMcp.ts @@ -10,7 +10,7 @@ import { import { ThreadLinkedPullRequest } from "./threadPullRequest.ts"; import { ThreadTitleRegeneration } from "./threadTitle.ts"; -function hasOnlyPairedSurrogates(value: string): boolean { +export function hasOnlyPairedSurrogates(value: string): boolean { for (let index = 0; index < value.length; index += 1) { const codeUnit = value.charCodeAt(index); if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { From 4005759adf35c0355892ea1083ecd9426526a12c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:25 -0700 Subject: [PATCH 3/9] feat(server): link to another environment (#16655) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/auth/RpcAuthorization.ts | 4 + apps/server/src/binCli.ts | 2 + apps/server/src/cli/environment.ts | 158 ++++++ .../src/environment/ServerEnvironment.ts | 2 + .../src/observability/RpcInstrumentation.ts | 3 + apps/server/src/peer/PeerLinks.test.ts | 463 +++++++++++++++++ apps/server/src/peer/PeerLinks.ts | 489 ++++++++++++++++++ apps/server/src/peer/PeerMcpClient.ts | 253 +++++++++ apps/server/src/persistence/Migrations.ts | 2 + .../persistence/Migrations/070_PeerLinks.ts | 24 + .../reconcileV2PreviewMigration.test.ts | 2 + apps/server/src/server.ts | 4 + apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 9 + packages/contracts/src/environment.ts | 8 + packages/contracts/src/index.ts | 1 + packages/contracts/src/peerLink.ts | 66 +++ packages/contracts/src/rpc.ts | 32 ++ 18 files changed, 1524 insertions(+) create mode 100644 apps/server/src/cli/environment.ts create mode 100644 apps/server/src/peer/PeerLinks.test.ts create mode 100644 apps/server/src/peer/PeerLinks.ts create mode 100644 apps/server/src/peer/PeerMcpClient.ts create mode 100644 apps/server/src/persistence/Migrations/070_PeerLinks.ts create mode 100644 packages/contracts/src/peerLink.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5cbc70925a161..a8cdbccf68fec 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -5,6 +5,7 @@ import { authScopeRequiredResponse, AssetCreateUrlInput, AuthAccessReadScope, + AuthAccessWriteScope, ServerSettingsPatch, ProviderInstanceMutation, requiredScopesForServerSettingsPatch, @@ -86,6 +87,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope, [WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, + [WS_METHODS.peerLinksList]: AuthAccessReadScope, + [WS_METHODS.peerLinksLink]: AuthAccessWriteScope, + [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope, diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 2f7c24536caaa..b26b4fd9f0dbc 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -10,6 +10,7 @@ import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts"; import { authCommand } from "./cli/auth.ts"; +import { environmentCommand } from "./cli/environment.ts"; import { appCommand } from "./cli/app.ts"; import { browserCommand } from "./cli/browser.ts"; import { connectCommand } from "./cli/connect.ts"; @@ -75,6 +76,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => appCommand, pairCommand, authCommand, + environmentCommand, browserCommand, projectCommand, threadCommand, diff --git a/apps/server/src/cli/environment.ts b/apps/server/src/cli/environment.ts new file mode 100644 index 0000000000000..d831bc44e828e --- /dev/null +++ b/apps/server/src/cli/environment.ts @@ -0,0 +1,158 @@ +import { AuthMcpClientAccess, EnvironmentId, type PeerLinkSummary } from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import * as Argument from "effect/cli/Argument"; +import * as Command from "effect/cli/Command"; +import * as Flag from "effect/cli/Flag"; +import * as GlobalFlag from "effect/cli/GlobalFlag"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PeerLinks from "../peer/PeerLinks.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { authLocationFlags, type CliAuthLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +/** + * Links live in this environment's database and secret store, so these + * commands work whether or not its server is running. + */ +const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* ( + flags: CliAuthLocationFlags, + run: (links: PeerLinks.PeerLinks["Service"]) => Effect.Effect, + options?: { readonly quietLogs?: boolean }, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* PeerLinks.PeerLinks.pipe( + Effect.flatMap(run), + Effect.provide( + PeerLinks.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerSecretStore.layer, + SqlitePersistence.layerConfig, + FetchHttpClient.layer, + ), + ), + Layer.provide(ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provide(ServerConfig.layer(config)), + Layer.provide( + Layer.succeed(References.MinimumLogLevel, options?.quietLogs ? "Error" : config.logLevel), + ), + ), + ), + ); +}); + +const jsonFlag = Flag.Boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +const encodeLinks = Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(Schema.toEncoded(Schema.Unknown))), +); + +const formatLinks = (links: ReadonlyArray) => + links.length === 0 + ? "No linked environments.\n" + : `${links + .map( + (link) => + `${link.label} (${link.environmentId})\n ${link.status}, access ${link.access}, expires ${DateTime.formatIso(link.expiresAt)}\n ${link.urls.join(", ")}${link.lastError === null ? "" : `\n last error: ${link.lastError}`}`, + ) + .join("\n\n")}\n`; + +const environmentLinkCommand = Command.make("link", { + ...authLocationFlags, + url: Argument.String("url").pipe( + Argument.withDescription( + "Where the other environment answers: its T3 Connect, Tailscale, LAN or loopback address.", + ), + ), + alternateUrl: Flag.String("url").pipe( + Flag.withDescription("Another address for the same environment, tried after the first."), + Flag.atLeast(0), + ), + pairingCode: Flag.String("pairing-code").pipe( + Flag.withDescription("A pairing code from the other environment (`t3 pair` there)."), + ), + access: Flag.Literals("access", AuthMcpClientAccess.literals).pipe( + Flag.withDescription( + "What this environment's agents may do there: read-only, or the broadest runtime mode.", + ), + Flag.withDefault("approval-required" as const), + ), +}).pipe( + Command.withDescription( + "Link another T3 Code environment, so agents here can work there. It shows up there under Settings → Connections, where it can be revoked.", + ), + Command.withHandler((flags) => + runWithPeerLinks(flags, (links) => + Effect.gen(function* () { + const linked = yield* links.link({ + url: flags.url, + alternateUrls: flags.alternateUrl, + pairingCode: flags.pairingCode, + access: flags.access, + }); + yield* Console.log( + `Linked ${linked.label} (${linked.environmentId}) with ${linked.access} access, until ${DateTime.formatIso(linked.expiresAt)}.\n`, + ); + }), + ), + ), +); + +const environmentListCommand = Command.make("list", { ...authLocationFlags, json: jsonFlag }).pipe( + Command.withDescription("List linked environments and whether each answers now."), + Command.withHandler((flags) => + runWithPeerLinks( + flags, + (links) => + Effect.gen(function* () { + const listed = yield* links.list; + yield* Console.log(flags.json ? `${yield* encodeLinks(listed)}\n` : formatLinks(listed)); + }), + { quietLogs: flags.json }, + ), + ), +); + +const environmentUnlinkCommand = Command.make("unlink", { + ...authLocationFlags, + environmentId: Argument.String("environment-id").pipe( + Argument.withDescription("The linked environment's id, from `t3 environment list`."), + ), +}).pipe( + Command.withDescription( + "Forget a linked environment here. Revoke it in that environment's Connections to end its session there too.", + ), + Command.withHandler((flags) => + runWithPeerLinks(flags, (links) => + Effect.gen(function* () { + const removed = yield* links.unlink(EnvironmentId.make(flags.environmentId)); + yield* Console.log( + removed + ? `Forgot ${flags.environmentId}. Revoke it in that environment's Connections to end its session there.\n` + : `No linked environment ${flags.environmentId}.\n`, + ); + }), + ), + ), +); + +export const environmentCommand = Command.make("environment").pipe( + Command.withDescription("Link other T3 Code environments, so agents here can work there."), + Command.withSubcommands([ + environmentLinkCommand, + environmentListCommand, + environmentUnlinkCommand, + ]), +); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 4678aff95b736..04c857b636010 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -265,6 +265,8 @@ export const make = Effect.gen(function* () { : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), serverBrowser: true, + peerLinks: true, + mcpModeLimitHeader: true, }, }; diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 4a23878e4a8a2..4f32e7d8f15ed 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -59,6 +59,9 @@ const RPC_AGGREGATES = { [WS_METHODS.serverGetStorageCleanupReport]: "server", [WS_METHODS.serverGetDatabaseStorage]: "server", [WS_METHODS.serverGetSettings]: "server", + [WS_METHODS.peerLinksList]: "peerLinks", + [WS_METHODS.peerLinksLink]: "peerLinks", + [WS_METHODS.peerLinksUnlink]: "peerLinks", [WS_METHODS.serverUpdateSettings]: "server", [WS_METHODS.serverSearchAcpRegistry]: "server", [WS_METHODS.serverPrepareAcpRegistryAgent]: "server", diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts new file mode 100644 index 0000000000000..ee5ca4960bd85 --- /dev/null +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -0,0 +1,463 @@ +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentHttpApi, + EnvironmentId, + type ExecutionEnvironmentDescriptor, + OrchestratorMcpFailure, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { Tool, Toolkit } from "effect/ai"; +import { + FetchHttpClient, + HttpClient, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/http"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as AuthHttp from "../auth/http.ts"; +import * as McpOAuth from "../auth/McpOAuth.ts"; +import * as McpOAuthHttp from "../auth/mcpOAuthHttp.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerHttp from "../http.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import * as McpToolAccess from "../mcp/McpToolAccess.ts"; +import * as Sqlite from "../persistence/Sqlite.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import * as PeerMcpClient from "./PeerMcpClient.ts"; + +class PeerTestApi extends HttpApi.make("environment") + .add(EnvironmentHttpApi.groups.metadata) + .add(EnvironmentHttpApi.groups.mcpOAuth) {} + +const descriptorOf = ( + environmentId: string, + label: string, + capabilities: Partial = {}, +): ExecutionEnvironmentDescriptor => ({ + environmentId: EnvironmentId.make(environmentId), + label, + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities }, +}); + +/** What B's probe saw: the calling client and the modes it may start work with. */ +const Probe = Schema.Struct({ caller: Schema.String, modes: Schema.String }); + +const ProbeToolkit = Toolkit.make( + Tool.make("probe", { + description: "Reports the calling client and the modes it may start work with.", + success: Probe, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [McpInvocationContext.McpInvocationContext], + }), + Tool.make("refuse", { + description: "Always refuses.", + success: Probe, + failure: OrchestratorMcpFailure, + failureMode: "return", + }), +); + +/** + * Environment B as it runs: its descriptor, MCP OAuth, and `/mcp` behind the + * real client authenticator, on a real socket. The descriptor can be swapped, + * as when B's address comes to answer as another environment. + */ +const serveB = (initial: ExecutionEnvironmentDescriptor) => + Effect.gen(function* () { + const descriptor = yield* Ref.make(initial); + const bearers = yield* Ref.make>([]); + const probes = yield* Ref.make(0); + const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), + getDescriptor: Ref.get(descriptor), + }); + const authContext = yield* EnvironmentAuth.layer.pipe( + Layer.provide(Sqlite.layerMemory), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(ServerEnvironment.layerIdentity), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-b-" })), + Layer.provideMerge(NodeServices.layer), + Layer.fresh, + Layer.build, + ); + // Every bearer B receives, on any route, so a test can show one was never sent. + const layerRecordBearers = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.url.startsWith("/.well-known/t3/environment")) { + yield* Ref.update(probes, (count) => count + 1); + } + const authorization = request.headers.authorization; + if (authorization !== undefined) { + yield* Ref.update(bearers, (seen) => [...seen, authorization]); + } + return yield* httpEffect; + }), + { global: true }, + ); + const layerRoutes = Layer.mergeAll( + HttpApiBuilder.layer(PeerTestApi).pipe( + Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), + Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), + Layer.provide(AuthHttp.layerAuthenticatedAuth), + ), + McpHttpServer.toolkitRegistration( + ProbeToolkit, + McpToolAccess.toLayer(ProbeToolkit, { + probe: McpToolAccess.reads(() => + McpInvocationContext.McpInvocationContext.pipe( + Effect.map((scope) => { + const modes = McpInvocationContext.clientModeCeiling(scope.client); + return { + caller: scope.client?.label ?? "none", + modes: `${modes.runtimeMode}/${modes.interactionMode}`, + }; + }), + ), + ), + refuse: McpToolAccess.reads(() => + Effect.fail( + new OrchestratorMcpFailure({ code: "thread_not_found", message: "No such thread." }), + ), + ), + }), + ).pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + resolve: () => Effect.succeed(undefined), + }), + ), + Layer.provide(McpOAuth.layerMcpClientAuthenticator), + ), + ).pipe( + Layer.provide(layerRecordBearers), + Layer.provide(layerEnvironment), + Layer.provide(Layer.succeedContext(authContext)), + ); + yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const address = (yield* HttpServer.HttpServer).address; + if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); + return { + url: `http://127.0.0.1:${address.port}`, + auth: Context.get(authContext, EnvironmentAuth.EnvironmentAuth), + descriptor, + bearers, + /** How often its descriptor was asked for, as a probe of whether it answers. */ + probes, + }; + }); + +/** Environment A: its own database and secret store, reaching B over plain HTTP. */ +const makeA = (descriptor: ExecutionEnvironmentDescriptor) => + Effect.gen(function* () { + const context = yield* PeerMcpClient.layer.pipe( + Layer.provideMerge(PeerLinks.layer), + Layer.provide( + Layer.mergeAll( + Sqlite.layerMemory, + ServerSecretStore.layer, + FetchHttpClient.layer, + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(descriptor.environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-a-" })), + Layer.provide(NodeServices.layer), + Layer.fresh, + Layer.build, + ); + return { + links: Context.get(context, PeerLinks.PeerLinks), + peer: Context.get(context, PeerMcpClient.PeerMcpClient), + }; + }); + +const laptop = descriptorOf("environment-laptop", "Laptop"); +const box = descriptorOf("environment-box", "Box"); +const probe = ( + a: Effect.Success>, + limits: PeerMcpClient.PeerCallLimits = { runtimeMode: "full-access", interactionMode: "default" }, + tool = "probe", +) => + a.peer.call({ + environmentId: box.environmentId, + tool, + arguments: {}, + limits, + success: Probe, + }); + +const linkedSessions = (b: Effect.Success>) => + b.auth + .listSessions() + .pipe( + Effect.map((sessions) => + sessions.filter((session) => session.client.label?.startsWith("T3 Code · ")), + ), + ); + +it.effect("links with a pairing code, and the peer holds its agents to the limits they carry", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + + const refused = yield* a.links + .link({ url: b.url, pairingCode: "not-a-code", access: "auto" }) + .pipe(Effect.flip); + expect(refused.reason).toBe("pairing_rejected"); + expect(yield* linkedSessions(b)).toEqual([]); + + const pairing = yield* b.auth.issuePairingCredential(); + const linked = yield* a.links.link({ + url: b.url, + pairingCode: pairing.credential, + access: "auto", + }); + expect(linked).toMatchObject({ + environmentId: box.environmentId, + label: "Box", + urls: [b.url], + access: "auto", + }); + // B lists the link under A's name, where it can be revoked. + expect((yield* linkedSessions(b)).map((session) => session.client.label)).toEqual([ + "T3 Code · Laptop", + ]); + expect( + (yield* a.links.list).map(({ environmentId, status }) => [environmentId, status]), + ).toEqual([[box.environmentId, "reachable"]]); + + // The caller's limits narrow the link's access; they never widen it. + expect( + yield* probe(a, { runtimeMode: "approval-required", interactionMode: "plan" }), + ).toEqual({ caller: "T3 Code · Laptop", modes: "approval-required/plan" }); + expect(yield* probe(a)).toEqual({ caller: "T3 Code · Laptop", modes: "auto/default" }); + + // A refusal keeps the peer's reason. + const notFound = yield* probe(a, undefined, "refuse").pipe(Effect.flip); + expect(notFound).toMatchObject({ code: "thread_not_found", message: "No such thread." }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("two links racing to one peer leave the stored access matching the token", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + const [first, second] = yield* Effect.all( + [b.auth.issuePairingCredential(), b.auth.issuePairingCredential()], + { concurrency: 2 }, + ); + yield* Effect.all( + [ + a.links.link({ url: b.url, pairingCode: first.credential, access: "read-only" }), + a.links.link({ url: b.url, pairingCode: second.credential, access: "full-access" }), + ], + { concurrency: 2 }, + ); + const [stored] = yield* a.links.list; + // The token sent is the one issued with the access the row records. + const { modes } = yield* probe(a); + expect(modes).toBe( + stored!.access === "read-only" ? "approval-required/default" : "full-access/default", + ); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +/** A bare HTTP server on a socket of its own, answering with `handle`. */ +const serveRaw = ( + handle: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect, +) => + Layer.build(Layer.fresh(NodeHttpServer.layerTest)).pipe( + Effect.flatMap((server) => + Effect.gen(function* () { + yield* HttpServer.serve(Effect.flatMap(HttpServerRequest.HttpServerRequest, handle)).pipe( + Layer.build, + ); + const address = (yield* HttpServer.HttpServer).address; + if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); + return `http://127.0.0.1:${address.port}`; + }).pipe(Effect.provideContext(server)), + ), + ); + +it.effect("never follows a peer's redirect with the pairing code", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const received = yield* Ref.make>([]); + const elsewhere = yield* serveRaw((request) => + Ref.update(received, (seen) => [...seen, `${request.method} ${request.url}`]).pipe( + Effect.as(HttpServerResponse.empty({ status: 404 })), + ), + ); + // Answers as B, then sends every linking request on to another address. + const front = yield* serveRaw((request) => + request.url.startsWith("/.well-known/t3/environment") + ? HttpClient.get(`${b.url}${request.url}`).pipe( + Effect.flatMap((response) => response.text), + Effect.map((body) => + HttpServerResponse.text(body, { contentType: "application/json" }), + ), + Effect.provide(FetchHttpClient.layer), + Effect.orDie, + ) + : Effect.succeed( + HttpServerResponse.empty({ + status: 307, + headers: { location: `${elsewhere}${request.url}` }, + }), + ), + ); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links + .link({ url: front, pairingCode: pairing.credential, access: "auto" }) + .pipe(Effect.flip); + expect(yield* Ref.get(received)).toEqual([]); + expect(yield* a.links.list).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("never sends a link's token to an address that stops answering as that peer", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + yield* probe(a); + expect((yield* Ref.get(b.bearers)).length).toBeGreaterThan(0); + + // The address now answers as another environment, as a reused LAN address would. + yield* Ref.set(b.descriptor, descriptorOf("environment-impostor", "Impostor")); + yield* Ref.set(b.bearers, []); + const failed = yield* probe(a).pipe(Effect.flip); + expect(failed.message).toContain("Box did not answer"); + expect(yield* Ref.get(b.bearers)).toEqual([]); + expect((yield* a.links.list).map((link) => link.status)).toEqual(["unreachable"]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("reads one stored link without asking the peer", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + yield* Ref.set(b.probes, 0); + + const stored = yield* a.links.get(box.environmentId); + expect(Option.map(stored, (link) => link.label)).toEqual(Option.some("Box")); + expect(Option.isNone(yield* a.links.get(laptop.environmentId))).toBe(true); + expect(yield* Ref.get(b.probes)).toBe(0); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("tells the agent to link again once the peer revokes the link or it expires", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + yield* probe(a); + + const [session] = yield* linkedSessions(b); + yield* b.auth.revokeSession(session!.sessionId); + const revoked = yield* probe(a).pipe(Effect.flip); + expect(revoked.code).toBe("capability_denied"); + expect(revoked.message).toContain("revoked there"); + + yield* TestClock.adjust(Duration.days(31)); + expect((yield* a.links.list).map((link) => link.status)).toEqual(["expired"]); + yield* Ref.set(b.bearers, []); + const expired = yield* probe(a).pipe(Effect.flip); + expect(expired.code).toBe("capability_denied"); + expect(expired.message).toContain("expired"); + expect(yield* Ref.get(b.bearers)).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect( + "refuses to link itself, plain http off this machine and the tailnet, or a peer that cannot hold a linked agent to its limits", + () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB( + descriptorOf("environment-box", "Box", { mcpModeLimitHeader: false }), + ); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + const older = yield* a.links + .link({ url: b.url, pairingCode: pairing.credential, access: "auto" }) + .pipe(Effect.flip); + expect(older.reason).toBe("incompatible"); + + // Plain http to a LAN address would carry the code and token in clear. + const lan = yield* a.links + .link({ + url: "http://192.168.1.20:3773", + pairingCode: pairing.credential, + access: "auto", + }) + .pipe(Effect.flip); + expect(lan.message).toContain("plain http"); + const lanAlternate = yield* a.links + .link({ + url: b.url, + alternateUrls: ["http://192.168.1.20:3773"], + pairingCode: pairing.credential, + access: "auto", + }) + .pipe(Effect.flip); + expect(lanAlternate.message).toContain("plain http"); + + yield* Ref.set(b.descriptor, laptop); + const self = yield* a.links + .link({ url: b.url, pairingCode: pairing.credential, access: "auto" }) + .pipe(Effect.flip); + expect(self.reason).toBe("self"); + // Neither attempt spent the pairing code on a session. + expect(yield* linkedSessions(b)).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts new file mode 100644 index 0000000000000..4d438700d4a5e --- /dev/null +++ b/apps/server/src/peer/PeerLinks.ts @@ -0,0 +1,489 @@ +import { + AuthMcpClientAccess, + AuthMcpRegistrationError, + AuthMcpTokenError, + EnvironmentHttpApi, + EnvironmentId, + type PeerLink, + type PeerLinkCreateInput, + PeerLinkError, + type PeerLinkStatus, + type PeerLinkSummary, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Base64Url from "effect/encoding/Base64Url"; +import * as Hex from "effect/encoding/Hex"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpApiClient from "effect/http-api/HttpApiClient"; +import { isLocalLoopbackHost, isTailnetHost } from "@t3tools/shared/hostClassification"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; + +/** The redirect registered for a link. Nothing ever listens there: the code comes back in the response. */ +const LINK_REDIRECT_URI = "http://127.0.0.1/t3code-peer-link"; +/** + * How long a peer's descriptor may take before a route counts as unreachable. + * A tailnet or relayed route to a machine that is waking up takes longer. + */ +const probeTimeout = (origin: string) => + isLocalLoopbackHost(new URL(origin).hostname) ? Duration.seconds(3) : Duration.seconds(10); + +/** A usable link: where to reach the peer and the token to send there. */ +export interface ResolvedPeerLink { + readonly link: PeerLink; + /** The first address that answered as this peer. */ + readonly url: string; + readonly token: string; +} + +/** + * Links from this environment to others. Linking signs this environment in to + * the peer's `/mcp` as an outside agent with a pairing code from the peer, the + * same MCP OAuth flow an outside agent uses, without a browser. The peer's + * session token is kept in the server secret store; the table keeps the rest. + */ +export class PeerLinks extends Context.Service< + PeerLinks, + { + readonly link: (input: PeerLinkCreateInput) => Effect.Effect; + readonly list: Effect.Effect, PeerLinkError>; + /** The stored link, without asking the peer whether it answers. */ + readonly get: ( + environmentId: EnvironmentId, + ) => Effect.Effect, PeerLinkError>; + /** Forgets the link here. The peer still lists the session until it is revoked there. */ + readonly unlink: (environmentId: EnvironmentId) => Effect.Effect; + /** An address that answers as the linked peer, with its token, ready to call. */ + readonly resolve: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + /** Records the outcome of a call, so listing shows whether the peer answered last time. */ + readonly recordOutcome: ( + environmentId: EnvironmentId, + outcome: { readonly error: string | null }, + ) => Effect.Effect; + } +>()("t3/peer/PeerLinks") {} + +interface PeerLinkRow { + readonly environment_id: string; + readonly secret_name: string; + readonly label: string; + readonly urls_json: string; + readonly access: string; + readonly linked_at: string; + readonly expires_at: string; + readonly last_reached_at: string | null; + readonly last_error: string | null; +} + +const decodeUrls = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.String))); +const encodeUrls = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String))); +const decodeAccess = Schema.decodeUnknownEffect(AuthMcpClientAccess); + +/** Fails with a fixed storage error that keeps its cause; the log gets the details. */ +const failStorage = (cause: unknown) => + Effect.logWarning("Peer link storage failed", cause).pipe( + Effect.andThen( + Effect.fail( + new PeerLinkError({ + reason: "storage", + message: + "Linked environments could not be read or saved here. The server log has details.", + cause, + }), + ), + ), + ); + +/** + * The base URL a peer is reached at, normalised to its origin. The pairing + * code and then the link's token travel to it, so plain http is accepted only + * where the network already encrypts: this machine, or a Tailscale address. + */ +const peerOrigin = (url: string) => { + const parsed = URL.parse(url); + if (parsed === null || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) { + return Effect.fail( + new PeerLinkError({ + reason: "unreachable", + message: `${url} is not an http or https address.`, + }), + ); + } + if ( + parsed.protocol === "http:" && + !isLocalLoopbackHost(parsed.hostname) && + !isTailnetHost(parsed.hostname) + ) { + return Effect.fail( + new PeerLinkError({ + reason: "unreachable", + message: `${url} is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.`, + }), + ); + } + return Effect.succeed(parsed.origin); +}; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; + const httpClient = yield* HttpClient.HttpClient; + const environment = yield* ServerEnvironment.ServerEnvironment; + + /** A fresh name for one link's token; the row records it. */ + const newSecretName = crypto.randomBytes(16).pipe( + Effect.map((bytes) => `peer-link-${Hex.encode(bytes)}`), + Effect.orDie, + ); + + // Only the origin `peerOrigin` checked may receive a pairing code or token: + // a redirect could forward them anywhere, including plain http. + const peerHttpClient = HttpClient.transform(httpClient, (response) => + Effect.provideService(response, FetchHttpClient.RequestInit, { redirect: "manual" }), + ); + + const clientFor = (origin: string) => + HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe( + Effect.provideService(HttpClient.HttpClient, peerHttpClient), + ); + + /** The environment answering at `origin`, or nothing when it does not answer as T3 Code. */ + const describe = (origin: string) => + clientFor(origin).pipe( + Effect.flatMap((client) => client.metadata.descriptor()), + Effect.timeout(probeTimeout(origin)), + Effect.option, + ); + + const toLink = Effect.fn("PeerLinks.toLink")(function* (row: PeerLinkRow) { + return { + environmentId: EnvironmentId.make(row.environment_id), + label: row.label, + urls: yield* decodeUrls(row.urls_json), + access: yield* decodeAccess(row.access), + linkedAt: DateTime.makeUnsafe(row.linked_at), + expiresAt: DateTime.makeUnsafe(row.expires_at), + lastReachedAt: row.last_reached_at === null ? null : DateTime.makeUnsafe(row.last_reached_at), + lastError: row.last_error, + } satisfies PeerLink; + }, Effect.catch(failStorage)); + + const readRows = sql` + SELECT * FROM peer_environment_links ORDER BY label, environment_id + `.pipe(Effect.catch(failStorage)); + + const readRow = (environmentId: EnvironmentId) => + sql` + SELECT * FROM peer_environment_links WHERE environment_id = ${environmentId} + `.pipe( + Effect.catch(failStorage), + Effect.map((rows) => Option.fromNullishOr(rows[0])), + ); + + /** Signs in to the peer's `/mcp` with a pairing code: register, decide, exchange. */ + const signIn = Effect.fn("PeerLinks.signIn")( + function* (origin: string, input: PeerLinkCreateInput, clientName: string) { + const client = yield* clientFor(origin); + // Only the peer's own refusals are rejections. A dropped connection or + // an answer this version cannot read is classified below. + const registered = yield* client.mcpOAuth + .register({ payload: { client_name: clientName, redirect_uris: [LINK_REDIRECT_URI] } }) + .pipe( + // A plain Schema.Error has no tag to catch by. + Effect.catchIf(Schema.is(AuthMcpRegistrationError), (error) => + Effect.fail( + new PeerLinkError({ + reason: "pairing_rejected", + message: `The peer refused to register this environment: ${error.message}`, + }), + ), + ), + ); + const verifier = Base64Url.encode(yield* crypto.randomBytes(32).pipe(Effect.orDie)); + const challenge = Base64Url.encode( + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)).pipe(Effect.orDie), + ); + const state = Base64Url.encode(yield* crypto.randomBytes(16).pipe(Effect.orDie)); + const resource = `${origin}/mcp`; + const decided = yield* client.mcpOAuth + .decision({ + payload: { + authorization: { + response_type: "code", + client_id: registered.client_id, + redirect_uri: LINK_REDIRECT_URI, + code_challenge: challenge, + code_challenge_method: "S256", + state, + resource, + }, + decision: { _tag: "pairing-code", access: input.access, code: input.pairingCode }, + }, + }) + .pipe( + Effect.catchTags({ + AuthMcpApprovalError: (error) => + Effect.fail( + new PeerLinkError({ reason: "pairing_rejected", message: error.message }), + ), + }), + ); + const redirect = URL.parse(decided.redirectTo); + if (redirect === null) { + return yield* new PeerLinkError({ + reason: "incompatible", + message: `${origin} answered the sign-in with an address this version cannot read. Update both environments.`, + }); + } + const code = redirect.searchParams.get("code"); + if (code === null || redirect.searchParams.get("state") !== state) { + return yield* new PeerLinkError({ + reason: "pairing_rejected", + message: + redirect.searchParams.get("error_description") ?? + "The peer did not approve the link. Check the pairing code and try again.", + }); + } + return yield* client.mcpOAuth + .token({ + payload: { + grant_type: "authorization_code", + code, + redirect_uri: LINK_REDIRECT_URI, + client_id: registered.client_id, + code_verifier: verifier, + resource, + }, + }) + .pipe( + Effect.catchIf(Schema.is(AuthMcpTokenError), (error) => + Effect.fail( + new PeerLinkError({ + reason: "pairing_rejected", + message: `The peer refused the sign-in: ${error.message}`, + }), + ), + ), + ); + }, + (effect, origin) => + effect.pipe( + // Endpoint errors are mapped above; what is left is the transport or an + // answer this version cannot decode. + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new PeerLinkError({ + reason: "incompatible", + message: `${origin} answered in a shape this version does not understand. Update both environments.`, + cause, + }), + ), + HttpClientError: (cause) => + Effect.fail( + new PeerLinkError({ + reason: "unreachable", + message: `${origin} stopped answering while linking.`, + cause, + }), + ), + }), + ), + ); + + const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) { + const origins = yield* Effect.forEach([input.url, ...(input.alternateUrls ?? [])], peerOrigin); + const primary = origins[0]!; + const descriptor = yield* describe(primary); + if (Option.isNone(descriptor)) { + return yield* new PeerLinkError({ + reason: "not_a_t3_environment", + message: `Nothing at ${primary} answered as a T3 Code environment.`, + }); + } + const peer = descriptor.value; + const self = yield* environment.getDescriptor; + if (peer.environmentId === self.environmentId) { + return yield* new PeerLinkError({ + reason: "self", + message: "That address is this environment.", + }); + } + if (peer.capabilities.mcpModeLimitHeader !== true) { + return yield* new PeerLinkError({ + reason: "incompatible", + message: `${peer.label} runs a T3 Code version that cannot keep a linked agent's limits. Update it, then link again.`, + }); + } + const issued = yield* signIn(primary, input, `T3 Code · ${self.label}`); + const now = yield* DateTime.now; + const linked: PeerLink = { + environmentId: peer.environmentId, + label: peer.label, + urls: origins, + access: input.access, + linkedAt: now, + expiresAt: DateTime.add(now, { seconds: issued.expires_in }), + lastReachedAt: now, + lastError: null, + }; + // The token goes under a name of its own before the row points at it, + // so the row and its token are replaced together. + const secretName = yield* newSecretName; + yield* secrets + .set(secretName, new TextEncoder().encode(issued.access_token)) + .pipe(Effect.catch(failStorage)); + const urlsJson = yield* encodeUrls(origins).pipe(Effect.catch(failStorage)); + const previous = yield* readRow(peer.environmentId); + yield* sql` + INSERT INTO peer_environment_links ( + environment_id, secret_name, label, urls_json, access, linked_at, expires_at, + last_reached_at, last_error + ) VALUES ( + ${linked.environmentId}, ${secretName}, ${linked.label}, ${urlsJson}, ${linked.access}, + ${DateTime.formatIso(linked.linkedAt)}, ${DateTime.formatIso(linked.expiresAt)}, + ${DateTime.formatIso(now)}, NULL + ) + ON CONFLICT (environment_id) DO UPDATE SET + secret_name = excluded.secret_name, + label = excluded.label, + urls_json = excluded.urls_json, + access = excluded.access, + linked_at = excluded.linked_at, + expires_at = excluded.expires_at, + last_reached_at = excluded.last_reached_at, + last_error = NULL + `.pipe(Effect.catch(failStorage)); + // Linking again replaces the old token. A racing link may leave its own + // behind; it is unused either way. + if (Option.isSome(previous) && previous.value.secret_name !== secretName) { + yield* secrets.remove(previous.value.secret_name).pipe(Effect.ignore); + } + return linked; + }); + + const statusOf = (link: PeerLink, now: DateTime.Utc) => + DateTime.isLessThanOrEqualTo(link.expiresAt, now) + ? Effect.succeed("expired") + : Effect.findFirst(link.urls, (url) => + describe(url).pipe( + Effect.map( + (found) => Option.isSome(found) && found.value.environmentId === link.environmentId, + ), + ), + ).pipe( + Effect.map((answered): PeerLinkStatus => + Option.isSome(answered) ? "reachable" : "unreachable", + ), + ); + + const list: PeerLinks["Service"]["list"] = Effect.gen(function* () { + const links = yield* Effect.forEach(yield* readRows, toLink); + const now = yield* DateTime.now; + return yield* Effect.forEach( + links, + (link) => statusOf(link, now).pipe(Effect.map((status) => ({ ...link, status }))), + { concurrency: "unbounded" }, + ); + }); + + const get: PeerLinks["Service"]["get"] = (environmentId) => + readRow(environmentId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeedNone, + onSome: (row) => Effect.asSome(toLink(row)), + }), + ), + ); + + const unlink: PeerLinks["Service"]["unlink"] = Effect.fn("PeerLinks.unlink")( + function* (environmentId) { + const deleted = yield* sql<{ secret_name: string }>` + DELETE FROM peer_environment_links WHERE environment_id = ${environmentId} + RETURNING secret_name + `.pipe(Effect.catch(failStorage)); + for (const row of deleted) { + yield* secrets.remove(row.secret_name).pipe(Effect.catch(failStorage)); + } + return deleted.length > 0; + }, + ); + + const resolve: PeerLinks["Service"]["resolve"] = Effect.fn("PeerLinks.resolve")( + function* (environmentId) { + const row = yield* readRow(environmentId); + if (Option.isNone(row)) { + return yield* new PeerLinkError({ + reason: "unknown_link", + message: `This environment is not linked to ${environmentId}. t3_environment_links lists the linked ones.`, + }); + } + const link = yield* toLink(row.value); + if (DateTime.isLessThanOrEqualTo(link.expiresAt, yield* DateTime.now)) { + return yield* new PeerLinkError({ + reason: "expired", + message: `The link to ${link.label} expired. Link it again with a new pairing code from ${link.label}.`, + }); + } + const stored = yield* secrets.get(row.value.secret_name).pipe(Effect.catch(failStorage)); + if (Option.isNone(stored)) { + return yield* new PeerLinkError({ + reason: "unknown_link", + message: `The link to ${link.label} lost its credential. Link it again.`, + }); + } + // The token goes only to an address that proves it is this peer, so a + // reused LAN address or a moved hostname never receives it. + const url = yield* Effect.findFirst(link.urls, (candidate) => + describe(candidate).pipe( + Effect.map( + (found) => Option.isSome(found) && found.value.environmentId === environmentId, + ), + ), + ); + if (Option.isNone(url)) { + return yield* new PeerLinkError({ + reason: "unreachable", + message: `${link.label} did not answer at ${link.urls.join(", ")}.`, + }); + } + return { link, url: url.value, token: new TextDecoder().decode(stored.value) }; + }, + ); + + const recordOutcome: PeerLinks["Service"]["recordOutcome"] = (environmentId, outcome) => + DateTime.now.pipe( + Effect.flatMap((now) => + outcome.error === null + ? sql` + UPDATE peer_environment_links + SET last_reached_at = ${DateTime.formatIso(now)}, last_error = NULL + WHERE environment_id = ${environmentId} + ` + : sql` + UPDATE peer_environment_links SET last_error = ${outcome.error} + WHERE environment_id = ${environmentId} + `, + ), + Effect.asVoid, + Effect.ignoreCause({ log: true }), + ); + + return PeerLinks.of({ link, list, get, unlink, resolve, recordOutcome }); +}); + +export const layer = Layer.effect(PeerLinks, make); diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts new file mode 100644 index 0000000000000..710b7c26c6e70 --- /dev/null +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -0,0 +1,253 @@ +import { + type EnvironmentId, + OrchestratorMcpFailure, + type PeerLinkError, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as HttpBody from "effect/http/HttpBody"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as PeerLinks from "./PeerLinks.ts"; + +/** The only MCP protocol version a T3 Code `/mcp` registers. */ +const PROTOCOL_VERSION = "2025-06-18"; + +/** The limits a call carries to the peer: the calling agent's modes here. */ +export interface PeerCallLimits { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +/** + * Calls a linked environment's T3 MCP tools as this environment's outside + * agent. Each call carries the calling agent's limits in `T3-Mode-Limit`, so + * the peer enforces the narrower of those and the link's own access. + */ +export class PeerMcpClient extends Context.Service< + PeerMcpClient, + { + /** Calls `tool` on the peer and decodes its structured result with `success`. */ + readonly call: >(input: { + readonly environmentId: EnvironmentId; + readonly tool: string; + readonly arguments: Record; + readonly limits: PeerCallLimits; + readonly success: S; + }) => Effect.Effect; + } +>()("t3/peer/PeerMcpClient") {} + +const ToolCallResponse = Schema.Struct({ + result: Schema.optional( + Schema.Struct({ + content: Schema.Array( + Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) }), + ), + structuredContent: Schema.optional(Schema.Unknown), + isError: Schema.optional(Schema.Boolean), + }), + ), + error: Schema.optional(Schema.Struct({ code: Schema.Number, message: Schema.String })), +}); +const decodeToolCallResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(ToolCallResponse)); +const decodeFailure = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ code: OrchestratorMcpFailure.fields.code, message: Schema.String }), + ), +); + +/** A peer link problem, as the agent that asked hears it. */ +const fromLinkError = (error: PeerLinkError) => + new OrchestratorMcpFailure({ + code: + error.reason === "unknown_link" + ? "invalid_request" + : error.reason === "expired" + ? "capability_denied" + : "orchestration_error", + message: error.message, + }); + +/** The body of an MCP response: JSON, or a single server-sent event carrying JSON. */ +const responseJson = (contentType: string | undefined, text: string) => + contentType?.includes("text/event-stream") === true + ? text + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .join("") + : text; + +const make = Effect.gen(function* () { + const links = yield* PeerLinks.PeerLinks; + const httpClient = yield* HttpClient.HttpClient; + // One MCP session per peer and address, reused across calls. The peer keeps + // sessions in memory, so a restart there answers 404 and we start a new one. + const sessions = yield* Ref.make>(new Map()); + + const post = (url: string, token: string, body: unknown, headers: Record) => + httpClient.execute( + HttpClientRequest.post(`${url}/mcp`).pipe( + HttpClientRequest.setHeaders({ + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + ...headers, + }), + HttpClientRequest.setBody(HttpBody.text(JSON.stringify(body), "application/json")), + ), + ); + + const openSession = Effect.fn("PeerMcpClient.openSession")(function* ( + url: string, + token: string, + limitHeader: Record, + ) { + const initialized = yield* post( + url, + token, + { + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "t3code-peer", version: "1" }, + }, + }, + limitHeader, + ); + const sessionId = initialized.headers["mcp-session-id"]; + if (initialized.status !== 200 || sessionId === undefined) { + return yield* new OrchestratorMcpFailure({ + code: initialized.status === 401 ? "capability_denied" : "orchestration_error", + message: + initialized.status === 401 + ? "The linked environment no longer accepts this link. It may have been revoked there; link it again." + : `The linked environment refused to start an MCP session (HTTP ${initialized.status}).`, + }); + } + yield* post( + url, + token, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { ...limitHeader, "mcp-session-id": sessionId, "mcp-protocol-version": PROTOCOL_VERSION }, + ); + return sessionId; + }); + + const call: PeerMcpClient["Service"]["call"] = Effect.fn("PeerMcpClient.call")( + function* (input) { + const resolved = yield* links + .resolve(input.environmentId) + .pipe(Effect.mapError(fromLinkError)); + const limitHeader = { + [McpInvocationContext.MODE_LIMIT_HEADER]: `${input.limits.runtimeMode}/${input.limits.interactionMode}`, + }; + const sessionKey = `${input.environmentId} ${resolved.url}`; + const callOnce = (sessionId: string) => + post( + resolved.url, + resolved.token, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: input.tool, arguments: input.arguments }, + }, + { ...limitHeader, "mcp-session-id": sessionId, "mcp-protocol-version": PROTOCOL_VERSION }, + ); + const existing = (yield* Ref.get(sessions)).get(sessionKey); + const sessionId = existing ?? (yield* openSession(resolved.url, resolved.token, limitHeader)); + let response = yield* callOnce(sessionId); + if (response.status === 404) { + const renewed = yield* openSession(resolved.url, resolved.token, limitHeader); + yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, renewed)); + response = yield* callOnce(renewed); + } else if (existing === undefined) { + yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, sessionId)); + } + if (response.status === 401) { + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: + "The linked environment no longer accepts this link. It may have been revoked there; link it again.", + }); + } + if (response.status !== 200) { + return yield* new OrchestratorMcpFailure({ + code: "orchestration_error", + message: `The linked environment answered HTTP ${response.status}.`, + }); + } + const body = yield* decodeToolCallResponse( + responseJson(response.headers["content-type"], yield* response.text), + ).pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "The linked environment answered in an unexpected shape.", + }), + ), + ); + if (body.error !== undefined || body.result === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: body.error?.message ?? "The linked environment returned no result.", + }); + } + const text = body.result.content.map((part) => part.text ?? "").join(""); + if (body.result.isError === true) { + // A refusal keeps the peer's own code, so the agent sees why it was refused. + const refused = decodeFailure(text); + return yield* refused._tag === "Some" + ? new OrchestratorMcpFailure({ code: refused.value.code, message: refused.value.message }) + : new OrchestratorMcpFailure({ + code: "orchestration_error", + message: text || "The linked environment refused the call.", + }); + } + const success = Schema.fromJsonString(input.success); + return yield* Schema.decodeEffect(success)(text).pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: + "The linked environment's answer did not match this version. Update both environments.", + }), + ), + ); + }, + (effect, input) => + effect.pipe( + Effect.tapError((error) => + links.recordOutcome(input.environmentId, { error: error.message }), + ), + Effect.tap(() => links.recordOutcome(input.environmentId, { error: null })), + // Recorded above with the transport's own message; the agent hears it plainly. + Effect.catchTags({ + HttpClientError: () => + Effect.fail( + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "The linked environment stopped answering.", + }), + ), + }), + ), + ); + + return PeerMcpClient.of({ call }); +}); + +export const layer = Layer.effect(PeerMcpClient, make); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index f85cb14e309b1..9b352d271b271 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -72,6 +72,7 @@ import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts" import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; import Migration0068 from "./Migrations/068_ShellStopRequestIndex.ts"; import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; +import Migration0070 from "./Migrations/070_PeerLinks.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -144,6 +145,7 @@ const migrationEntries = [ [67, "WebhookDedupe", Migration0067], [68, "ShellStopRequestIndex", Migration0068], [69, "ThreadMessageSearch", Migration0069], + [70, "PeerLinks", Migration0070], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/070_PeerLinks.ts b/apps/server/src/persistence/Migrations/070_PeerLinks.ts new file mode 100644 index 0000000000000..5551ccdd07a25 --- /dev/null +++ b/apps/server/src/persistence/Migrations/070_PeerLinks.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Other environments this one signed in to as an outside MCP agent. The + // session token itself is kept in the server secret store under + // `secret_name`, which is unique to each link, so a row always names its + // own token even when two links to one environment race. + yield* sql` + CREATE TABLE IF NOT EXISTS peer_environment_links ( + environment_id TEXT PRIMARY KEY, + secret_name TEXT NOT NULL, + label TEXT NOT NULL, + urls_json TEXT NOT NULL, + access TEXT NOT NULL, + linked_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_reached_at TEXT, + last_error TEXT + ) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index e5a9b86fccbb0..3e699b30dfe29 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -49,6 +49,7 @@ describe("V2 preview upgrade", () => { [67, "WebhookDedupe"], [68, "ShellStopRequestIndex"], [69, "ThreadMessageSearch"], + [70, "PeerLinks"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -141,6 +142,7 @@ describe("V2 preview upgrade", () => { [67, "WebhookDedupe"], [68, "ShellStopRequestIndex"], [69, "ThreadMessageSearch"], + [70, "PeerLinks"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f461a99186b31..5cd4a4a78fcb4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -67,6 +67,8 @@ import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceR import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; +import * as PeerLinks from "./peer/PeerLinks.ts"; +import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -740,6 +742,8 @@ const layerMakeRoutes = Layer.mergeAll( ), ), Layer.provide(PreviewBrowser.layer), + // Links to other environments, shared by Settings (WebSocket) and agents (/mcp). + Layer.provide(PeerMcpClient.layer.pipe(Layer.provideMerge(PeerLinks.layer))), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), Layer.provide(layerCommandReadiness), diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index f0d2e104ca864..fee7487265763 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -54,6 +54,7 @@ import * as ModelManifest from "./provider/ModelManifest.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; +import * as PeerLinks from "./peer/PeerLinks.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -419,6 +420,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({}), Layer.mock(OtlpEndpointCheck.OtlpEndpointCheck)({}), Layer.mock(PairingGrantStore.PairingGrantStore)({}), + Layer.mock(PeerLinks.PeerLinks)({}), Layer.mock(PortScanner.PortDiscovery)({}), Layer.mock(PreviewManager.PreviewManager)({}), Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e5696e6f5cd17..0bf9b91bed3df 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2,6 +2,7 @@ import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; +import * as PeerLinks from "./peer/PeerLinks.ts"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -1343,6 +1344,7 @@ export const layerWsRpc = ( const storageCleanup = yield* StorageCleanup.StorageCleanup; const databaseStorage = yield* DatabaseStorage.DatabaseStorage; const serverSettings = yield* ServerSettings.ServerSettingsService; + const peerLinks = yield* PeerLinks.PeerLinks; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -2535,6 +2537,11 @@ export const layerWsRpc = ( [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, [WS_METHODS.serverGetDatabaseStorage]: () => databaseStorage.status, + [WS_METHODS.peerLinksList]: (_input) => + peerLinks.list.pipe(Effect.map((links) => ({ links }))), + [WS_METHODS.peerLinksLink]: (input) => peerLinks.link(input), + [WS_METHODS.peerLinksUnlink]: ({ environmentId }) => + peerLinks.unlink(environmentId).pipe(Effect.map((removed) => ({ removed }))), [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), [WS_METHODS.serverUpdateSettings]: @@ -3305,6 +3312,7 @@ export const layer = Layer.unwrap( const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; + const peerLinks = yield* PeerLinks.PeerLinks; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", @@ -3392,6 +3400,7 @@ export const layer = Layer.unwrap( reviewGuides, ), ), + Layer.provide(Layer.succeed(PeerLinks.PeerLinks, peerLinks)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7b47d88202c7d..e0986dbb3408c 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -134,6 +134,14 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), /** Hosts headless Chromium tabs streamed over /api/preview-stream for clients without a local runtime. */ serverBrowser: Schema.optionalKey(Schema.Boolean), + /** Server links to other environments (`PeerLink`) and its agents can work + in them. Absent on servers from before linking, which clients must not + offer linking for. */ + peerLinks: Schema.optionalKey(Schema.Boolean), + /** Server's `/mcp` narrows an OAuth client's modes from `T3-Mode-Limit`, so + a linked environment's calls keep its agent's limits. Another environment + links to this one only when it is set. */ + mcpModeLimitHeader: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 38c28ab65a2ba..f9e2e0f5b5937 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -41,6 +41,7 @@ export * from "./guestShare.ts"; export * from "./guestStream.ts"; export * from "./applicationEvent.ts"; export * from "./orchestratorMcp.ts"; +export * from "./peerLink.ts"; export * from "./threadMetadataMcp.ts"; export * from "./threadPullRequest.ts"; export * from "./threadSearch.ts"; diff --git a/packages/contracts/src/peerLink.ts b/packages/contracts/src/peerLink.ts new file mode 100644 index 0000000000000..022b1b9b9f8c5 --- /dev/null +++ b/packages/contracts/src/peerLink.ts @@ -0,0 +1,66 @@ +import * as Schema from "effect/Schema"; + +import { AuthMcpClientAccess } from "./auth.ts"; +import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * A link from this environment to another one (the peer). This environment + * signed in to the peer's `/mcp` as an outside agent, with a pairing code from + * the peer, so its agents can work there. The peer enforces the access chosen + * at linking, and revokes the link from its own Connections. + */ +export const PeerLink = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + /** Addresses the peer was linked at, tried in order. */ + urls: Schema.Array(TrimmedNonEmptyString), + access: AuthMcpClientAccess, + linkedAt: Schema.DateTimeUtc, + expiresAt: Schema.DateTimeUtc, + lastReachedAt: Schema.NullOr(Schema.DateTimeUtc), + lastError: Schema.NullOr(Schema.String), +}); +export type PeerLink = typeof PeerLink.Type; + +/** Whether a link can be used now, as worked out when it is listed. */ +export const PeerLinkStatus = Schema.Literals(["reachable", "unreachable", "expired"]); +export type PeerLinkStatus = typeof PeerLinkStatus.Type; + +export const PeerLinkSummary = Schema.Struct({ + ...PeerLink.fields, + status: PeerLinkStatus, +}); +export type PeerLinkSummary = typeof PeerLinkSummary.Type; + +export const PeerLinkCreateInput = Schema.Struct({ + /** Where the peer answers: an https, Tailscale, LAN or loopback base URL. */ + url: TrimmedNonEmptyString, + /** More addresses for the same peer, tried after `url`. */ + alternateUrls: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)), + /** A pairing code from the peer (`t3 pair` there, or its Connections settings). */ + pairingCode: TrimmedNonEmptyString, + access: AuthMcpClientAccess, +}); +export type PeerLinkCreateInput = typeof PeerLinkCreateInput.Type; + +export const PeerLinkRemoveInput = Schema.Struct({ environmentId: EnvironmentId }); +export type PeerLinkRemoveInput = typeof PeerLinkRemoveInput.Type; + +export const PeerLinkRemoveResult = Schema.Struct({ removed: Schema.Boolean }); +export type PeerLinkRemoveResult = typeof PeerLinkRemoveResult.Type; + +/** Why linking or calling a peer failed, in words a user can act on. */ +export class PeerLinkError extends Schema.TaggedError()("PeerLinkError", { + reason: Schema.Literals([ + "unreachable", + "not_a_t3_environment", + "incompatible", + "self", + "pairing_rejected", + "expired", + "unknown_link", + "storage", + ]), + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), +}) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1e191ec9ccbf9..e0a6486668e10 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -16,6 +16,14 @@ import { McpAppToolInfoInput, McpAppUpdateModelContextInput, } from "./mcpApps.ts"; +import { + PeerLink, + PeerLinkCreateInput, + PeerLinkError, + PeerLinkRemoveInput, + PeerLinkRemoveResult, + PeerLinkSummary, +} from "./peerLink.ts"; import { ChatGptReconnectProfileInput, ChatGptReconnectProfile, @@ -486,6 +494,9 @@ export const WS_METHODS = { serverRunStorageCleanup: "server.runStorageCleanup", serverGetStorageCleanupReport: "server.getStorageCleanupReport", serverGetDatabaseStorage: "server.getDatabaseStorage", + peerLinksList: "peerLinks.list", + peerLinksLink: "peerLinks.link", + peerLinksUnlink: "peerLinks.unlink", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -768,6 +779,24 @@ const WsServerGetDatabaseStorageRpc = Rpc.make(WS_METHODS.serverGetDatabaseStora error: EnvironmentAuthorizationError, }); +const WsPeerLinksListRpc = Rpc.make(WS_METHODS.peerLinksList, { + payload: Schema.Struct({}), + success: Schema.Struct({ links: Schema.Array(PeerLinkSummary) }), + error: Schema.Union([PeerLinkError, EnvironmentAuthorizationError]), +}); + +const WsPeerLinksLinkRpc = Rpc.make(WS_METHODS.peerLinksLink, { + payload: PeerLinkCreateInput, + success: PeerLink, + error: Schema.Union([PeerLinkError, EnvironmentAuthorizationError]), +}); + +const WsPeerLinksUnlinkRpc = Rpc.make(WS_METHODS.peerLinksUnlink, { + payload: PeerLinkRemoveInput, + success: PeerLinkRemoveResult, + error: Schema.Union([PeerLinkError, EnvironmentAuthorizationError]), +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1945,6 +1974,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetDatabaseStorageRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsPeerLinksListRpc, + WsPeerLinksLinkRpc, + WsPeerLinksUnlinkRpc, WsServerDiscoverSourceControlRpc, WsServerSearchAcpRegistryRpc, WsServerPrepareAcpRegistryAgentRpc, From cb988502aaf5e085b3fdaba94b1356aed545852c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:25 -0700 Subject: [PATCH 4/9] feat(server): agents use linked environments through any T3 tool (#16684) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.ts | 2 +- apps/server/src/mcp/McpToolAccess.test.ts | 113 ++++ apps/server/src/mcp/McpToolAccess.ts | 225 +++++-- .../src/mcp/OrchestratorMcpService.test.ts | 188 ++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 45 ++ ...OrchestratorMcpToolkit.integration.test.ts | 2 + apps/server/src/mcp/toolkits/core.test.ts | 6 + .../src/mcp/toolkits/environment/tools.ts | 3 + .../src/mcp/toolkits/orchestrator/handlers.ts | 8 + .../mcp/toolkits/orchestrator/tools.test.ts | 29 + .../src/mcp/toolkits/orchestrator/tools.ts | 31 +- .../src/mcp/toolkits/project/handlers.test.ts | 2 + .../src/mcp/toolkits/project/handlers.ts | 4 +- apps/server/src/mcp/toolkits/project/tools.ts | 28 +- .../src/mcp/toolkits/pullRequests/tools.ts | 9 +- apps/server/src/mcp/toolkits/thread/tools.ts | 12 +- .../server/src/mcp/toolkits/worktree/tools.ts | 2 + .../Adapters/ClaudeAdapterV2.ts | 1 + apps/server/src/peer/PeerForwarding.test.ts | 625 ++++++++++++++++++ apps/server/src/peer/PeerForwarding.ts | 192 ++++++ apps/server/src/peer/PeerLinks.test.ts | 196 +----- apps/server/src/peer/PeerLinks.testkit.ts | 177 +++++ .../src/provider/AgentContextBudget.test.ts | 2 + .../src/provider/agentContextBudget.json | 6 +- apps/server/src/server.ts | 8 +- docs/internals/remote.md | 36 +- packages/client-runtime/src/t3ToolSummary.ts | 3 + packages/contracts/src/orchestratorMcp.ts | 49 ++ .../src/server/orchestrationInstructions.ts | 3 +- packages/shared/src/t3McpToolPresentation.ts | 5 + 30 files changed, 1778 insertions(+), 234 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/orchestrator/tools.test.ts create mode 100644 apps/server/src/peer/PeerForwarding.test.ts create mode 100644 apps/server/src/peer/PeerForwarding.ts create mode 100644 apps/server/src/peer/PeerLinks.testkit.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6220f7c5cde89..4d3805809df3d 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1084,7 +1084,7 @@ const layerEnvironmentToolkit = toolkitRegistration( EnvironmentHandlers.layer, ).pipe(Layer.provide(ThreadCommandExecutor.layer)); -const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); +export const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); export const layerAttachmentToolkit = toolkitRegistration( AttachmentToolkit, diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index d1a21fbb71957..e3c49f5cbdf5c 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -1,18 +1,22 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, ProviderInstanceId, ProviderInteractionMode, RuntimeMode, ThreadId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; +import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -339,3 +343,112 @@ it.effect("refuses a change when the calling thread cannot be read", () => ), ), ); + +const box = EnvironmentId.make("environment-box"); +const here = EnvironmentId.make("environment"); +const linkedProbe = { + ...probe, + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + threadId: Schema.optional(ThreadId), + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), + }), +}; +const LinkedProbeToolkit = Toolkit.make( + Tool.make("reads", linkedProbe), + Tool.make("writes", linkedProbe), + Tool.make("writes_with_capability", linkedProbe), + Tool.make("writes_threads", linkedProbe), + Tool.make("starts_threads", linkedProbe), + Tool.make("writes_environment", linkedProbe), +); +/** Runs here, and tells whether `environmentId` reached the handler. */ +const ranHere =

(params: P) => + Effect.succeed({ ran: "environmentId" in params ? "saw environmentId" : "here" }); + +const linkedProbeServer = McpHttpServer.toolkitRegistration( + LinkedProbeToolkit, + McpToolAccess.toLayer(LinkedProbeToolkit, { + reads: McpToolAccess.reads(ranHere), + writes: McpToolAccess.writes(ranHere), + writes_with_capability: McpToolAccess.writesWithCapability(ranHere), + writes_threads: McpToolAccess.writesThreads((input) => [input.threadId], ranHere), + starts_threads: McpToolAccess.startsThreads((input) => input, ranHere), + writes_environment: McpToolAccess.writesEnvironment(ranHere), + }), +).pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null), + }), + ), + // Stands in for the linked environment, answering with where the call went. + Layer.provide( + Layer.mock(PeerForwarding.PeerForwarding)({ + call: (_scope, tool, environmentId) => + Effect.succeed({ ran: `${tool.name} in ${environmentId}` } as never), + }), + ), +); + +it.effect.each([ + ["reads", supervised, { environmentId: box }, "reads in environment-box"], + ["reads", supervised, { environmentId: here }, "here"], + ["reads", supervised, {}, "here"], + + // The caller is checked here before anything goes. + ["writes", readOnlyClient, { environmentId: box }, "capability_denied"], + ["writes", ended, { environmentId: box }, "parent_not_active"], + ["writes", supervised, { environmentId: box }, "writes in environment-box"], + ["writes_with_capability", readOnlyClient, { environmentId: box }, "capability_denied"], + [ + "starts_threads", + plan, + { environmentId: box, interactionMode: "default" }, + "interaction_mode_escalation_denied", + ], + ["starts_threads", plan, { environmentId: box }, "starts_threads in environment-box"], + ["writes_environment", supervised, { environmentId: box }, "capability_denied"], + [ + "writes_environment", + fullAccess, + { environmentId: box }, + "writes_environment in environment-box", + ], + ["writes_environment", fullAccess, { environmentId: here }, "here"], + + // A thread id names a thread where the call runs, and is checked there. + [ + "writes_threads", + supervised, + { environmentId: box, threadId: fullAccessThreadId }, + "writes_threads in environment-box", + ], + [ + "writes_threads", + supervised, + { environmentId: here, threadId: fullAccessThreadId }, + "runtime_mode_escalation_denied", + ], +] as const)("a linked %s from %o with %o: %s", ([tool, scope, args, expected]) => + call(tool, scope, args).pipe( + Effect.map((outcome) => expect(outcome).toBe(expected)), + Effect.provide(linkedProbeServer), + ), +); + +it.effect("refuses a linked environment to a tool that acts as its calling thread", () => + Effect.gen(function* () { + const CallerToolkit = Toolkit.make(Tool.make("acts_as_caller", linkedProbe)); + const built = yield* Layer.build( + McpToolAccess.HandlersLayer.layer( + McpToolAccess.toLayer(CallerToolkit, { + acts_as_caller: McpToolAccess.actsAsCaller(ranHere), + }), + ), + ).pipe(Effect.scoped, Effect.exit); + expect(Exit.isFailure(built) && Cause.hasDies(built.cause)).toBe(true); + }), +); diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 5ff44be5c0a72..d2e218dea0d5a 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -1,10 +1,14 @@ import { + type EnvironmentId, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, type ProviderInteractionMode, type RuntimeMode, type ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +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"; @@ -15,6 +19,7 @@ import { DispatchModeLimit, type DispatchModeRefusal, } from "../orchestration-v2/DispatchModeLimit.ts"; +import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts"; import { @@ -38,8 +43,19 @@ const refuseOutsideConstruction = (token: symbol) => { let declare: ( handle: (params: P) => Effect.Effect, + remote: RemoteCheck

| undefined, ) => Declaration<(params: P) => Effect.Effect>; +/** + * What a call bound for a linked environment checks here before it goes: the + * caller, not the thread or project it names, which live there and are + * checked there. `undefined` for tools that act as the calling thread itself. + */ +type RemoteCheck

= ( + params: P, +) => Effect.Effect>; +let remoteCheckOf: (declaration: Declaration) => RemoteCheck | undefined; + type CheckedHandlerOf = D extends Declaration ? Handler : never; interface CheckedHandler extends Struct.Lambda { @@ -55,13 +71,20 @@ let handlersLayer: , EX, RX>( /** t3code/no-raw-mcp-registration confines Effect's unchecked registration API to McpHttpServer. */ export class Declaration { readonly #handle: Handler; - private constructor(token: typeof builtHere, handle: Handler) { + readonly #remote: RemoteCheck | undefined; + private constructor( + token: typeof builtHere, + handle: Handler, + remote: RemoteCheck | undefined, + ) { refuseOutsideConstruction(token); this.#handle = handle; + this.#remote = remote; } static { - declare = (handle) => new Declaration(builtHere, handle); + declare = (handle, remote) => new Declaration(builtHere, handle, remote); checkedHandler = Struct.lambda((declaration) => declaration.#handle); + remoteCheckOf = (declaration) => declaration.#remote; } } @@ -94,55 +117,68 @@ const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( ); export const reads = (handle: (params: P) => Effect.Effect) => - declare((params: P) => handle(params)); + declare( + (params: P) => handle(params), + () => Effect.void, + ); export const readsAsCaller = (handle: (params: P) => Effect.Effect) => - declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params)))); + declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params))), undefined); export const actsAsCaller = (handle: (params: P) => Effect.Effect) => - declare((params: P) => - requireThreadCaller.pipe( - Effect.andThen(writingCaller), - Effect.flatMap(() => handle(params)), - ), + declare( + (params: P) => + requireThreadCaller.pipe( + Effect.andThen(writingCaller), + Effect.flatMap(() => handle(params)), + ), + undefined, ); export const writes = (handle: (params: P) => Effect.Effect) => - declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); + declare( + (params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params))), + () => orchestratingCaller, + ); /** Requires handler capability checks, not thread control. */ export const writesWithCapability = (handle: (params: P) => Effect.Effect) => - declare((params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params)))); + declare( + (params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params))), + () => writingCaller, + ); export const writesThreads = ( threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, ) => - declare((params: P) => - Effect.gen(function* () { - const caller = yield* writingCaller; - 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); + declare( + (params: P) => + Effect.gen(function* () { + const caller = yield* writingCaller; + 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); + } } - } - // 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.catch((error) => - Effect.flatMap(Ref.get(refused), (refusal) => - Effect.fail( - refusal === undefined ? error : escalationDenied(refusal), + // 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.catch((error) => + Effect.flatMap(Ref.get(refused), (refusal) => + Effect.fail( + refusal === undefined ? error : escalationDenied(refusal), + ), ), ), - ), - ); - }), + ); + }), + () => writingCaller, ); const escalationDenied = (refusal: DispatchModeRefusal) => @@ -165,31 +201,46 @@ export const startsThreads = ( readonly interactionMode?: ProviderInteractionMode | undefined; }, handle: (params: P, modes: StartedModes) => Effect.Effect, -) => - declare((params: P) => - Effect.gen(function* () { - const { limits } = yield* writingCaller; - const requested = modes(params); - const started: StartedModes = { - runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode), - interactionMode: yield* resolveInteractionMode( - limits.interactionMode, - requested.interactionMode, - ), - }; - return yield* handle(params, started); - }), +) => { + const started = Effect.fn("McpToolAccess.started")(function* (params: P) { + const { limits } = yield* writingCaller; + const requested = modes(params); + return { + runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode), + interactionMode: yield* resolveInteractionMode( + limits.interactionMode, + requested.interactionMode, + ), + } satisfies StartedModes; + }); + return declare( + (params: P) => Effect.flatMap(started(params), (modes) => handle(params, modes)), + started, ); +}; /** One tool with two access rules, picked per call, such as create versus update. */ export const either = ( useFirst: (params: P) => boolean, first: Declaration<(params: P) => Effect.Effect>, second: Declaration<(params: P) => Effect.Effect>, -) => - declare((params: P): Effect.Effect => - useFirst(params) ? checkedHandler(first)(params) : checkedHandler(second)(params), +) => { + const remotes = [remoteCheckOf(first), remoteCheckOf(second)] as ReadonlyArray< + RemoteCheck

| undefined + >; + return declare( + (params: P): Effect.Effect => + useFirst(params) ? checkedHandler(first)(params) : checkedHandler(second)(params), + (params) => + remotes[useFirst(params) ? 0 : 1]?.(params) ?? + Effect.fail( + new OrchestratorMcpFailure({ + code: "invalid_request", + message: "This action acts as the calling thread, which is here. Omit environmentId.", + }), + ), ); +}; const fullAccessRequired = "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; @@ -204,7 +255,10 @@ export const writesEnvironment = ( const check = orchestratingCaller.pipe( Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)), ); - return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check)))); + return declare( + (params: P) => check.pipe(Effect.flatMap(() => handle(params, check))), + () => check, + ); }; type CheckServices = Effect.Services; @@ -241,14 +295,73 @@ export class HandlersLayer, EX = never, R const checkedHandlers = (declarations: Declarations): 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.", +}); + +/** + * Every tool that takes a linked environment runs there the same way: its + * declaration checks the caller here, and the same call goes to the other + * environment, which checks the rest. The handler here never sees + * `environmentId`, so it cannot leak into what it writes. + */ +const routed = Effect.fn("McpToolAccess.routed")(function* >( + toolkit: Toolkit.Toolkit, + declarations: Handlers, + checked: Toolkit.HandlersFrom, +) { + // Optional so toolkits that never leave this environment build without it. + const forwarding = yield* Effect.serviceOption(PeerForwarding.PeerForwarding); + const handlers: Record = { ...checked }; + for (const [name, declaration] of Object.entries>(declarations)) { + const tool = toolkit.tools[name]; + if (tool === undefined || !takesLinkedEnvironment(tool)) continue; + const remote = remoteCheckOf(declaration); + if (remote === undefined) + return yield* Effect.die( + `${name} takes a linked environment, but it acts as its calling thread, which is here.`, + ); + const here = handlers[name] as ( + params: object, + ) => Effect.Effect>; + handlers[name] = ({ environmentId, ...params }: { readonly environmentId?: EnvironmentId }) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const target = PeerForwarding.remoteTarget(scope, environmentId); + if (target === undefined) return yield* here(params); + yield* (remote as RemoteCheck)(params); + if (Option.isNone(forwarding)) return yield* linksUnavailable; + return yield* forwarding.value.call(scope, tool, target, { ...params, environmentId }); + }); + } + return handlers as Toolkit.HandlersFrom; +}); + export const toLayer = , EX = never, RX = never>( toolkit: Toolkit.Toolkit, build: Handlers | Effect.Effect, EX, RX>, -): HandlersLayer> => - handlersLayer( +): HandlersLayer> => { + // Checked as soon as they are given, so a forged declaration fails here. + const withDeclarations = (declarations: Handlers) => + routed(toolkit, declarations, checkedHandlers(declarations)); + return handlersLayer( toolkit.toLayer( - Effect.isEffect(build) - ? Effect.map(build, checkedHandlers>) - : checkedHandlers(build), + Effect.isEffect(build) ? Effect.flatMap(build, withDeclarations) : withDeclarations(build), ), ); +}; diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 6ce02cbb32e2a..e3f3063d59592 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -14,6 +14,8 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, RunId, ThreadId, type OrchestrationV2ServerCommand, @@ -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"; @@ -2671,3 +2677,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(); + 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)); + }), + ), + ); +}); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 1ea11b9a4afd9..481c2c9704525 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2907,6 +2907,51 @@ const make = Effect.gen(function* () { yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); const key = yield* requestKey(input.clientRequestId); + // A peer from before `stop` drops it when decoding, so it interrupts + // the run instead, as this call did then. + if (input.stop === true) { + const commandId = stableCommandId({ scope, requestKey: key, operation: "thread-stop" }); + // The cause goes to the log; the caller learns only that the stop failed. + const stopFailed = (error: unknown) => + Effect.logWarning("orchestrator-mcp.thread-stop-failed", { + threadId: input.threadId, + error, + }).pipe( + Effect.andThen( + Effect.fail( + failure("orchestration_error", `Unable to stop thread ${input.threadId}.`), + ), + ), + ); + const stopped = yield* threadManagement + .dispatch({ + type: "thread.stop", + commandId, + threadId: input.threadId, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }) + .pipe(Effect.catch(stopFailed)); + // A retry with the same clientRequestId repeats only the stops that failed. + yield* threadManagement + .stopDelegatedTasks({ threadId: input.threadId, commandId, reason: input.reason }) + .pipe(Effect.catch(stopFailed)); + // The run this stop interrupted, from its own events, so a retry + // reports the same run even if another has started since. Stop also + // holds queued runs and marks runs it could not interrupt, with no + // provider turn; neither is the run it interrupted. + const interrupted = stopped.storedEvents.find( + ({ event }) => + (event.type === "run.updated" && event.payload.status === "interrupted") || + (event.type === "turn-item.updated" && + event.payload.type === "run_interrupt_request" && + event.payload.providerTurnId !== null), + )?.event.runId; + return { + threadId: input.threadId, + runId: interrupted ?? null, + status: interrupted === undefined ? "no_active_run" : "interrupt_requested", + } satisfies OrchestratorMcpThreadInterruptResult; + } const result = yield* threadManagement .interruptThread({ projectId: target.thread.projectId, diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index d47fb11fe6a2b..6f89e37d4cddb 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -46,6 +46,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as PeerForwarding from "../peer/PeerForwarding.ts"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), @@ -220,6 +221,7 @@ describe("orchestrator MCP toolkit", () => { Layer.provide(layerProviderRegistry), Layer.provide(ServerSettings.layerTest()), Layer.provide(layerUnusedScheduledTaskStub), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provideMerge( SecretRequests.layer.pipe( diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index bab0eeac369dc..96a4a3345ce78 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -54,6 +54,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { dispatchFailure } from "../threadAccess.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; +import * as PeerForwarding from "../../peer/PeerForwarding.ts"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), @@ -72,6 +73,7 @@ const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadR const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), ); const threadId = ThreadId.make("mcp-core-thread"); @@ -598,6 +600,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), ), @@ -655,6 +658,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode ), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), ), ), ), @@ -739,6 +743,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), ), ), ), @@ -829,6 +834,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), ), diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts index d684ff02e3402..9e10380295de2 100644 --- a/apps/server/src/mcp/toolkits/environment/tools.ts +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -2,6 +2,7 @@ import { BackgroundActivityProfile, BackgroundActivityProfileSelection, ExecutionEnvironmentDescriptor, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, ServerSettings, ServerSettingsPatch, @@ -41,6 +42,7 @@ const EnvironmentReadTool = Tool.make("t3_environment_read", { ...shared, description: "Read this server's identity and preferences. Providers and models: `orchestrator_capabilities`.", + parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget }), success: Schema.Struct({ environmentId: ExecutionEnvironmentDescriptor.fields.environmentId, label: Schema.String, @@ -56,6 +58,7 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update" description: "Update environment-wide preferences. Live full-access or default caller thread only. Omitted fields are unchanged; `customInstructions: ''` clears it.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode, newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin, enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks, diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 2d829fe428e9a..1dfd6a70f0487 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -2,6 +2,7 @@ import { OrchestratorMcpFailure } from "@t3tools/contracts"; import { OrchestratorToolkit } from "./tools.ts"; import * as Effect from "effect/Effect"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import { readCaller } from "../../threadAccess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; @@ -179,6 +180,13 @@ const handlers = { return yield* service.waitForThread(scope, input); }), ), + t3_environment_links: McpToolAccess.reads(() => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const forwarding = yield* PeerForwarding.PeerForwarding; + return yield* forwarding.links(scope); + }), + ), t3_thread_interrupt: McpToolAccess.writesThreads( (input) => [input.threadId], (input) => diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts new file mode 100644 index 0000000000000..ea0caa4ce166a --- /dev/null +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import { Tool } from "effect/ai"; + +import { OrchestratorToolkit } from "./tools.ts"; + +// Oversized page sizes clamp on decode, so the schema has no `maximum`; Codex also drops +// bounds from its TypeScript view. The description is the only place a model sees a limit. +it("states t3_thread_read page size limits in the parameter descriptions", () => { + const schema = Tool.getJsonSchema(OrchestratorToolkit.tools.t3_thread_read) as { + readonly properties: Readonly>; + }; + const limits = { limit: "100", runLimit: "50", maxCharsPerItem: "50000" }; + for (const [name, maximum] of Object.entries(limits)) { + expect(schema.properties[name]?.description, name).toContain(maximum); + } +}); + +it("refuses a t3_thread_update whose fields do not fit its action", () => { + const decode = Schema.decodeUnknownExit( + OrchestratorToolkit.tools.t3_thread_update.parametersSchema, + ); + expect(Exit.isFailure(decode({ action: "rename" }))).toBe(true); + expect(Exit.isFailure(decode({ action: "regenerate_title", title: "New" }))).toBe(true); + expect(Exit.isSuccess(decode({ action: "rename", title: "New", environmentId: undefined }))).toBe( + true, + ); +}); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 469f5695ec00b..7dbf65e011f10 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -2,12 +2,14 @@ import { OrchestratorMcpCapabilitiesInput, OrchestratorMcpCapabilitiesResult, OrchestrationV2StopWorkflowInput, + OrchestratorMcpEnvironmentLinksResult, OrchestratorMcpCreateThreadsInput, OrchestratorMcpCreateThreadsResult, OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, OrchestratorMcpDeleteScheduledTaskInput, OrchestratorMcpDeleteScheduledTaskResult, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpRequestSecretInput, OrchestratorMcpRequestSecretResult, OrchestratorMcpFailure, @@ -34,8 +36,10 @@ import { } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; @@ -45,6 +49,8 @@ const dependencies = [ ThreadManagementService.ThreadManagementService, OrchestratorMcpService.OrchestratorMcpService, ]; +/** Listing this environment's links. */ +const linkDependencies = [...dependencies, PeerForwarding.PeerForwarding]; const threadMetadataDependencies = [ McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, @@ -107,7 +113,10 @@ const TaskCancelTool = Tool.make("task_cancel", { const WorkflowStopTool = Tool.make("t3_workflow_stop", { description: "Stop a running Claude workflow in a thread. `subagentId`: coordinator from `t3_thread_read` 'activity'. Stops only the workflow, not the thread.", - parameters: OrchestrationV2StopWorkflowInput, + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + ...OrchestrationV2StopWorkflowInput.fields, + }), success: Schema.Struct({}), failure: OrchestratorMcpFailure, failureMode: "return", @@ -231,7 +240,11 @@ const ThreadReadTool = Tool.make("t3_thread_read", { const ThreadUpdateTool = Tool.make("t3_thread_update", { description: "Rename a thread (`action:'rename'` with `title`) or regenerate its title. Omit `threadId` for this thread. Workspace and branch cannot change here.", - parameters: ThreadMetadataMcpUpdateInput, + // Keeps the input's action/field check, which rebuilding the struct would drop. + parameters: ThreadMetadataMcpUpdateInput.mapFields( + Struct.assign({ environmentId: OrchestratorMcpEnvironmentTarget }), + { unsafePreserveChecks: true }, + ), success: ThreadMetadataMcpUpdateResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -280,6 +293,19 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { .annotate(Tool.Title, "Interrupt a T3 thread") .annotate(Tool.Destructive, true); +const EnvironmentLinksTool = Tool.make("t3_environment_links", { + description: + "List the other T3 Code environments this one is linked to, such as the user's other machines, and whether each answers now. Pass one's environmentId to any tool that takes environmentId, such as t3_thread_launch, t3_thread_send, t3_pending_request_respond, t3_queue_list or t3_project_list, to act there; delegate_task takes it as target.environmentId. A linked environment lets this one's agents do at most its access there, and never more than the calling agent may do here. An expired link has to be linked again from a new pairing code.", + success: OrchestratorMcpEnvironmentLinksResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: linkDependencies, +}) + .annotate(Tool.Title, "List linked environments") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + export const OrchestratorToolkit = Toolkit.make( OrchestratorCapabilitiesTool, DelegateTaskTool, @@ -298,4 +324,5 @@ export const OrchestratorToolkit = Toolkit.make( ThreadSendTool, ThreadWaitTool, ThreadInterruptTool, + EnvironmentLinksTool, ); diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 7232a3ebdaa1b..3e9fe6a21f93c 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -26,6 +26,7 @@ import * as ServerConfig from "../../../config.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; +import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -125,6 +126,7 @@ const clientLaunchHarness = (input: { : Option.none(), ), }), + Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)), ).pipe( diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index af97cd3694f11..c380775e29984 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -21,6 +21,7 @@ import * as ServerSettings from "../../../serverSettings.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -77,8 +78,9 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input) => input, (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; const context = yield* readCaller(); - const { caller, scope } = context; + const { caller } = context; // A retry with the same key replays the first launch: the ids derive // from the caller and the key, and the launch service replays a // command it already accepted. diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 72c1055372497..b4bddfcd0b5ed 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -14,6 +14,7 @@ import { ProjectUpdatePayload, ProjectId, OrchestratorMcpClientRequestId, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -48,6 +49,7 @@ const ProjectListTool = Tool.make("t3_project_list", { ...shared, description: "List projects. Pages can shift.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, cursor: Schema.optional(NonNegativeInt), limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), }), @@ -61,7 +63,10 @@ const ProjectListTool = Tool.make("t3_project_list", { const ProjectReadTool = Tool.make("t3_project_read", { ...shared, description: "Read one project: workspace and saved scripts.", - parameters: Schema.Struct({ projectId: ProjectId }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + projectId: ProjectId, + }), }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); @@ -70,6 +75,7 @@ const ProjectCreateTool = Tool.make("t3_project_create", { description: "Register a project folder. `createWorkspaceRootIfMissing` creates it; a registered folder is rejected. No `workspaceRoot`: makes a git repo from `title` with README, icon, first commit (`commitError` on failure; project still exists). Clone first with `t3_project_clone` if needed.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, ...ProjectCreatePayload.fields, workspaceRoot: Schema.optional(ProjectCreatePayload.fields.workspaceRoot), }), @@ -79,18 +85,29 @@ const ProjectCreateTool = Tool.make("t3_project_create", { const ProjectUpdateTool = Tool.make("t3_project_update", { ...shared, description: "Update one project's settings. Omitted fields are unchanged.", - parameters: Schema.Struct({ projectId: ProjectId, ...ProjectUpdatePayload.fields }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + projectId: ProjectId, + ...ProjectUpdatePayload.fields, + }), }).annotate(Tool.Destructive, true); const ProjectDeleteTool = Tool.make("t3_project_delete", { ...shared, description: "Delete one project. Nonempty needs `force: true`. Folder on disk stays.", - parameters: Schema.Struct({ projectId: ProjectId, force: Schema.optionalKey(Schema.Boolean) }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + projectId: ProjectId, + force: Schema.optionalKey(Schema.Boolean), + }), }).annotate(Tool.Destructive, true); const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, description: - "Clone a repository (`destinationPath` new or empty). Register after with `t3_project_create`.", - parameters: SourceControlCloneRepositoryInput, + "Clone a repository (`destinationPath` new or empty). Register after with `t3_project_create`, with the same `environmentId`.", + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + ...SourceControlCloneRepositoryInput.fields, + }), success: SourceControlCloneRepositoryResult, dependencies: [ ...shared.dependencies, @@ -104,6 +121,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { description: "Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and modes to inherit yours (outside a thread, pass `projectId` or `scratch`); modes cannot exceed yours. After an error or lost reply, retry with the same `clientRequestId`. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: Schema.optional(ProjectId), scratch: Schema.optional( Schema.Boolean.annotate({ diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index ef314b863ede0..866580fc5e029 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -1,5 +1,6 @@ import { McpCapabilityUnavailableError, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, PositiveInt, PullRequestReviewGuide, @@ -33,6 +34,7 @@ const guideDependencies = [ ]; export const PullRequestTargetInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional( ThreadId.annotate({ description: "Thread. Default: this thread.", @@ -299,6 +301,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { description: "List PRs linked to a thread, with last known state and stack order (bottom to top). Call only when you need that list. Never as an end-of-work check.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), }), success: ListThreadPullRequestsResult, @@ -375,6 +378,7 @@ const WritePullRequestGuidesTool = Tool.make("write_pull_request_guides", { description: "Write review guides for up to 20 PRs (the tour in the PR Guide tab). Returns once runs start. Each run takes minutes in a temporary checkout. A run for the same head is joined. Read with `read_pull_request_guide`. Fails while guides are off.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, pullRequests: Schema.Array(GuideTarget).check(Schema.isMinLength(1), Schema.isMaxLength(20)), }), success: WritePullRequestGuidesResult, @@ -390,7 +394,10 @@ const WritePullRequestGuidesTool = Tool.make("write_pull_request_guides", { const ReadPullRequestGuideTool = Tool.make("read_pull_request_guide", { description: "Read a PR's stored review guide and run state. `guide` is null when none exists. Never starts a run.", - parameters: GuideTarget, + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + ...GuideTarget.fields, + }), success: ReadPullRequestGuideResult, failure: PullRequestToolError, dependencies: guideDependencies, diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 1e73f180bdf0f..b14b1bd387f1a 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpEnvironmentTarget, ScheduledTaskId, ScheduledTask, OrchestrationSearchThreadsInput, @@ -32,6 +33,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: "Pin, snooze, settle, archive, or mark a thread unread. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread applies at turn end. A failed turn or queued message leaves it active.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), action: Schema.Literals([ "pin", @@ -89,6 +91,7 @@ const QueueTool = Tool.make("t3_queue", { "reorder", "promote_to_steer", ]), + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), queuedRunId: Schema.optional(RunId), text: Schema.optional(Schema.String.check(Schema.isMaxLength(100000))), @@ -126,6 +129,7 @@ const PendingRequestTool = Tool.make("t3_pending_request", { "Pending user questions in a thread. Omit `threadId` for this thread. list: their ids; approvals are not listed. read: one by `requestId`. respond: answer one with `answers`; this cannot approve a permission request.", parameters: Schema.Struct({ action: Schema.Literals(["list", "read", "respond"]), + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), requestId: Schema.optional(RuntimeRequestId), answers: Schema.optional(ProviderUserInputAnswers), @@ -150,6 +154,7 @@ const ThreadConfigureTool = Tool.make("t3_thread_configure", { description: "Read a thread's provider, model, and modes, or set its provider, model, and options with `modelSelection`. Omit `threadId` for this thread. Permission modes stay. Values from `orchestrator_capabilities`.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), modelSelection: Schema.optional(ModelSelection), }), @@ -162,6 +167,7 @@ const ThreadTransferTool = Tool.make("t3_thread_transfer", { "Move context between threads. Omit `threadId` for this thread. fork: copy it from `sourcePoint` (default latest stable run) into a new thread with the same setup; no turn runs yet. merge_back: merge it from `sourcePoint` into a related `targetThreadId` in the same project. status: its context transfers.", parameters: Schema.Struct({ action: Schema.Literals(["fork", "merge_back", "status"]), + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), sourcePoint: Schema.optional(OrchestrationV2ThreadForkSourcePoint), targetThreadId: Schema.optional(ThreadId), @@ -187,6 +193,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { description: "Search active threads by title, branch, PR, project, then message text. Skips subagent threads. `projectId`: one project, else yours, else all outside a thread. Not exhaustive.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, ...OrchestrationSearchThreadsInput.fields, projectId: Schema.optional(ProjectId), }), @@ -212,7 +219,10 @@ const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", { ...commandTool, description: "Run one scheduled task now. Needs a full-access or default caller. Returns its thread, or null if not started. Success means started, not finished.", - parameters: Schema.Struct({ taskId: ScheduledTaskId }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + taskId: ScheduledTaskId, + }), success: Schema.Struct({ taskId: ScheduledTaskId, threadId: ScheduledTask.fields.threadId, diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 2bd2fc19ed80b..81a39a5e30a42 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -1,5 +1,6 @@ import { WorktreeMcpFailure, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, VcsListRefsInput, VcsListRefsResult, @@ -62,6 +63,7 @@ const WorktreeListTool = Tool.make("t3_worktree_list", { description: "List branch refs and checkout paths for a thread's workspace. Omit `threadId` for this thread. Detached worktrees with no branch are left out.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), query: VcsListRefsInput.fields.query, cursor: VcsListRefsInput.fields.cursor, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index e1ff63efe0443..5644051d9577d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1594,6 +1594,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_thread_search", "mcp__t3-code__t3_preview_list", "mcp__t3-code__t3_environment_read", + "mcp__t3-code__t3_environment_links", "mcp__t3-code__html_preview", "mcp__t3-code__html_render", ]; diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts new file mode 100644 index 0000000000000..eec5e9940f387 --- /dev/null +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -0,0 +1,625 @@ +import * as DocumentText from "../provider/DocumentText.ts"; +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + type OrchestrationV2Run, + type SourceControlCloneRepositoryInput, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as ServerConfig from "../config.ts"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { liveThreadShell } from "../mcp/McpToolAccess.testkit.ts"; +import * as PeerForwarding from "./PeerForwarding.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import { descriptorOf, layerLinkingEnvironment, linkTo, servePeer } from "./PeerLinks.testkit.ts"; + +const laptop = descriptorOf("environment-laptop", "Laptop"); +const box = descriptorOf("environment-box", "Box"); +const boxProject = ProjectId.make("project:box"); +const boxThread = ThreadId.make("thread:box-auto"); +const boxRun = RunId.make("run:box-auto"); + +/** What B's thread service was asked to do. */ +interface Seen { + readonly sends: ReadonlyArray; + readonly waits: ReadonlyArray; + readonly launches: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly clones: ReadonlyArray; + /** Commands dispatched and delegated-task stops, as ` `. */ + readonly stops: ReadonlyArray; +} +const nothingSeen: Seen = { + sends: [], + waits: [], + launches: [], + projects: [], + clones: [], + stops: [], +}; + +/** + * B's threads: one `auto` thread in `boxProject`, whose run finishes on the + * third wait. Every send and wait is recorded. + */ +const boxThreads = (seen: Ref.Ref) => { + const shell = { ...liveThreadShell(boxThread, { runtimeMode: "auto" }), projectId: boxProject }; + const run = (status: OrchestrationV2Run["status"]) => + ({ id: boxRun, status }) as unknown as OrchestrationV2Run; + const projection = { thread: shell, runs: [run("running")], runtimeRequests: [] } as never; + return Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => Effect.succeed(threadId === boxThread ? shell : null), + getProjectThreadRecords: () => Effect.succeed(projection), + getThreadRecords: () => Effect.succeed(projection), + listProjectThreads: () => Effect.succeed([shell] as never), + sendToThread: (input) => + Ref.update(seen, (current) => ({ ...current, sends: [...current.sends, input] })).pipe( + Effect.as({ run: run("running"), delivery: "started" } as never), + ), + dispatch: (command) => + Ref.update(seen, (current) => ({ + ...current, + stops: [ + ...current.stops, + `${command.type} ${"threadId" in command ? command.threadId : ""}`, + ], + })).pipe( + // The stop asks the provider to interrupt the thread's running turn. + Effect.as({ + sequence: 0, + storedEvents: [ + { + sequence: 1, + commandId: command.commandId, + event: { + type: "turn-item.updated", + runId: boxRun, + payload: { + type: "run_interrupt_request", + providerTurnId: "provider-turn:box-auto", + }, + }, + }, + ], + } as never), + ), + stopDelegatedTasks: (input) => + Ref.update(seen, (current) => ({ + ...current, + stops: [...current.stops, `delegated-tasks.stop ${input.threadId}`], + })), + waitForThread: (input) => + Ref.updateAndGet(seen, (current) => ({ ...current, waits: [...current.waits, input] })).pipe( + Effect.map(({ waits }) => ({ + threadId: input.threadId, + run: run(waits.length < 3 ? "running" : "completed"), + timedOut: waits.length < 3, + })), + ), + }); +}; + +/** B launches whatever it is asked to, and records it. */ +const boxLaunches = (seen: Ref.Ref) => + Layer.mock(ThreadLaunch.ThreadLaunchService)({ + launch: (input) => + Ref.update(seen, (current) => ({ ...current, launches: [...current.launches, input] })).pipe( + Effect.as({ + threadId: input.threadId, + projection: { + thread: { + id: input.threadId, + projectId: input.projectId, + modelSelection: input.modelSelection, + title: input.title, + }, + runs: [], + }, + resumed: false, + } as unknown as ThreadLaunch.ThreadLaunchResult), + ), + }); + +/** B clones and registers whatever it is asked to, and records it. */ +const boxProjects = (seen: Ref.Ref) => + Layer.merge( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedNone, + create: (input) => + Ref.update(seen, (current) => ({ + ...current, + projects: [...current.projects, input], + })).pipe( + Effect.as({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-18T00:00:00.000Z", + updatedAt: "2026-10-18T00:00:00.000Z", + deletedAt: null, + }), + ), + }), + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: (input) => + Ref.update(seen, (current) => ({ ...current, clones: [...current.clones, input] })).pipe( + Effect.as({ + cwd: input.destinationPath, + remoteUrl: "https://github.com/pingdotgg/t3code.git", + repository: null, + }), + ), + }), + ); + +/** The orchestrator and project toolkits on B's real `/mcp`, behind its real OAuth. */ +const serveBox = (seen: Ref.Ref) => + servePeer( + box, + Layer.mergeAll( + McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerProjectRegistration, + McpHttpServer.layerThreadToolkit, + ).pipe( + Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Layer.provide(NodeCrypto.layer), + Layer.provide(boxThreads(seen)), + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), + Layer.provide(boxLaunches(seen)), + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(boxProjects(seen)), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-box-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Layer.provide(NodeServices.layer), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + ), + ); + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "peer-forwarding-test", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "peer-forwarding-test", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +/** + * Environment A: its own orchestrator toolkit with forwarding, and a thread + * caller whose modes the test picks. Its local threads never matter here. + */ +const makeLaptop = Effect.gen(function* () { + const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); + const layerHere = Layer.mergeAll( + McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerProjectRegistration, + McpHttpServer.layerThreadToolkit, + ).pipe( + Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), + Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-laptop-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Layer.provide(NodeServices.layer), + Layer.provide(PeerForwarding.layer), + Layer.provide(Layer.succeedContext(linking)), + Layer.provide(NodeCrypto.layer), + Layer.provideMerge( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed( + liveThreadShell( + threadId, + threadId === ThreadId.make("thread:laptop-plan") + ? { runtimeMode: "full-access", interactionMode: "plan" } + : { runtimeMode: "full-access" }, + ), + ), + getProjectThreadRecords: ({ threadId }) => + Effect.succeed({ + thread: liveThreadShell(threadId, { runtimeMode: "full-access" }), + } as never), + }), + ), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(ServerSettings.layerTest()), + Layer.fresh, + ); + const here = yield* Layer.build(layerHere); + const server = Context.get(here, McpServer.McpServer); + const scopeOf = ( + caller: string, + capabilities: ReadonlySet = new Set(["orchestration"]), + ): McpInvocationContext.McpInvocationScope => ({ + environmentId: laptop.environmentId, + requestNamespace: `provider-session:${caller}`, + thread: { + threadId: ThreadId.make(caller), + providerSessionId: `provider-session:${caller}`, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities, + issuedAt: 1, + }); + const call = ( + caller: string, + name: string, + args: Record, + capabilities?: ReadonlySet, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + scopeOf(caller, capabilities), + ), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + return { links: Context.get(linking, PeerLinks.PeerLinks), call }; +}); + +const failureOf = (result: McpSchema.CallToolResult) => { + const text = result.content[0]; + return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; +}; + +it.effect("an agent here works on a linked environment's threads within its own modes", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + + const listed = yield* a.call("thread:laptop-full", "t3_environment_links", {}); + expect(listed.structuredContent).toMatchObject({ + environmentId: laptop.environmentId, + links: [ + { environmentId: box.environmentId, label: "Box", status: "reachable", access: "auto" }, + ], + }); + + const threads = yield* a.call("thread:laptop-full", "t3_thread_list", { + environmentId: box.environmentId, + projectId: boxProject, + }); + expect(threads.isError).toBe(false); + expect(threads.structuredContent).toMatchObject({ + projectId: boxProject, + threads: [{ threadId: boxThread, runtimeMode: "auto" }], + }); + + // A full-access agent here is held to the link's `auto` there, which + // the box's `auto` thread is within. + const sent = yield* a.call("thread:laptop-full", "t3_thread_send", { + environmentId: box.environmentId, + threadId: boxThread, + message: "Carry on there.", + clientRequestId: "send-1", + }); + expect(sent.isError).toBe(false); + expect(sent.structuredContent).toMatchObject({ threadId: boxThread, delivery: "started" }); + + // A plan-mode agent here may not steer that thread there: the box + // refuses it, because the call carries the agent's own modes. + const planned = yield* a.call("thread:laptop-plan", "t3_thread_send", { + environmentId: box.environmentId, + threadId: boxThread, + message: "Change course.", + }); + expect(failureOf(planned)).toMatchObject({ code: "interaction_mode_escalation_denied" }); + expect((yield* Ref.get(seen)).sends).toHaveLength(1); + + // stop=true stops the thread there as its Stop button does: the whole + // thread and the tasks it delegated, not just the running turn. + const stopped = yield* a.call("thread:laptop-full", "t3_thread_interrupt", { + environmentId: box.environmentId, + threadId: boxThread, + stop: true, + clientRequestId: "stop-1", + }); + expect(stopped.structuredContent).toMatchObject({ + threadId: boxThread, + runId: boxRun, + status: "interrupt_requested", + }); + expect((yield* Ref.get(seen)).stops).toEqual([ + `thread.stop ${boxThread}`, + `delegated-tasks.stop ${boxThread}`, + ]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("keeps a forwarded wait short and its retries scoped to the caller", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + + const waited = yield* a.call("thread:laptop-full", "t3_thread_wait", { + environmentId: box.environmentId, + threadId: boxThread, + timeoutMs: 600_000, + }); + expect(waited.structuredContent).toEqual({ + threadId: boxThread, + runId: boxRun, + status: "completed", + timedOut: false, + }); + // Three pieces of at most 50 s each, the later ones pinned to the run + // the first one found. + expect( + (yield* Ref.get(seen)).waits.map(({ timeoutMs, runId }) => [timeoutMs, runId]), + ).toEqual([ + [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, undefined], + [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun], + [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun], + ]); + + const send = (caller: string, clientRequestId: string) => + a.call(caller, "t3_thread_send", { + environmentId: box.environmentId, + threadId: boxThread, + message: "Once.", + clientRequestId, + }); + yield* send("thread:laptop-full", "same-key"); + yield* send("thread:laptop-full", "same-key"); + yield* send("thread:laptop-other", "same-key"); + const [first, retried, other] = (yield* Ref.get(seen)).sends.map((input) => input.commandId); + // The box sees every caller here as the one link, so the key is scoped + // to its caller before it leaves: a retry matches, another agent's same + // key does not. + expect(retried).toBe(first); + expect(other).not.toBe(first); + expect(first).not.toContain("same-key"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("launches in a linked environment with the caller's modes, retry-safe", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "full-access"); + const codex = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }; + const launch = (caller: string, args: Record = {}) => + a.call(caller, "t3_thread_launch", { + environmentId: box.environmentId, + projectId: boxProject, + title: "Run the suite there", + modelSelection: codex, + message: "Run the suite.", + clientRequestId: "launch-1", + ...args, + }); + + const first = yield* launch("thread:laptop-plan"); + expect(first.isError).toBe(false); + expect(first.structuredContent).toMatchObject({ + projectId: boxProject, + modelSelection: codex, + }); + const retried = yield* launch("thread:laptop-plan"); + const [launched, relaunched] = (yield* Ref.get(seen)).launches; + // The thread there runs with the caller's own modes here, below the + // link's full access, and a retry asks for the same thread. + expect(launched).toMatchObject({ runtimeMode: "full-access", interactionMode: "plan" }); + expect(relaunched?.threadId).toBe(launched?.threadId); + expect(retried.structuredContent).toEqual(first.structuredContent); + + // An escalation past the caller's own modes is refused here. + const escalated = yield* launch("thread:laptop-plan", { interactionMode: "default" }); + expect(failureOf(escalated)).toMatchObject({ code: "interaction_mode_escalation_denied" }); + // A pending upload lives here, so it cannot go along. + yield* Ref.set(b.bearers, []); + const withUpload = yield* launch("thread:laptop-full", { + clientRequestId: "launch-2", + attachments: [ + { type: "image", id: "upload-1", name: "a.png", mimeType: "image/png", sizeBytes: 1 }, + ], + }); + expect(failureOf(withUpload)).toMatchObject({ code: "invalid_request" }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + expect((yield* Ref.get(seen)).launches).toHaveLength(2); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("brings a repository to a linked environment, only through full access", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + const clone = { repository: "pingdotgg/t3code", destinationPath: "/home/box/t3code" }; + const register = { title: "t3code", workspaceRoot: "/home/box/t3code" }; + + yield* linkTo(a.links, b, "full-access"); + const cloned = yield* a.call("thread:laptop-full", "t3_project_clone", { + environmentId: box.environmentId, + ...clone, + }); + expect(cloned.structuredContent).toMatchObject({ cwd: clone.destinationPath }); + const created = yield* a.call("thread:laptop-full", "t3_project_create", { + environmentId: box.environmentId, + ...register, + }); + expect(created.structuredContent).toMatchObject(register); + expect((yield* Ref.get(seen)).clones).toEqual([clone]); + expect((yield* Ref.get(seen)).projects).toMatchObject([register]); + + // A plan-mode agent here may not, and nothing leaves. + yield* Ref.set(b.bearers, []); + const planned = yield* a.call("thread:laptop-plan", "t3_project_create", { + environmentId: box.environmentId, + ...register, + }); + expect(failureOf(planned)).toMatchObject({ code: "capability_denied" }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + + // Nor may a full-access agent through a link with less: the box holds it. + yield* linkTo(a.links, b, "auto"); + for (const [name, args] of [ + ["t3_project_clone", clone], + ["t3_project_create", register], + ] as const) { + const refused = yield* a.call("thread:laptop-full", name, { + environmentId: box.environmentId, + ...args, + }); + expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" }); + } + expect((yield* Ref.get(seen)).clones).toHaveLength(1); + expect((yield* Ref.get(seen)).projects).toHaveLength(1); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("runs any tool that takes a linked environment there", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + + const there = yield* a.call("thread:laptop-full", "t3_thread_configure", { + environmentId: box.environmentId, + threadId: boxThread, + }); + expect(there.structuredContent).toMatchObject({ threadId: boxThread, runtimeMode: "auto" }); + const archived = yield* a.call("thread:laptop-full", "t3_thread_organize", { + environmentId: box.environmentId, + threadId: boxThread, + action: "archive", + }); + expect(archived.isError).toBe(false); + expect((yield* Ref.get(seen)).stops).toEqual([`thread.archive ${boxThread}`]); + + // This environment's own id keeps the call here. + const own = yield* a.call("thread:laptop-full", "t3_thread_configure", { + environmentId: laptop.environmentId, + }); + expect(own.structuredContent).toMatchObject({ + threadId: "thread:laptop-full", + runtimeMode: "full-access", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("refuses what a linked environment cannot take before anything leaves", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const b = yield* serveBox(seen); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + yield* Ref.set(b.bearers, []); + + const unknown = yield* a.call("thread:laptop-full", "t3_thread_read", { + environmentId: EnvironmentId.make("environment-elsewhere"), + threadId: boxThread, + }); + expect(failureOf(unknown)).toMatchObject({ code: "invalid_request" }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + + // The box sees only the link's credential, so one that may not control + // threads here may not reach them there either. + const previewOnly = new Set(["preview"]); + for (const [name, args] of [ + ["t3_environment_links", {}], + ["t3_project_list", { environmentId: box.environmentId }], + ["t3_thread_list", { environmentId: box.environmentId, projectId: boxProject }], + ["t3_thread_read", { environmentId: box.environmentId, threadId: boxThread }], + ["t3_thread_wait", { environmentId: box.environmentId, threadId: boxThread }], + [ + "t3_thread_send", + { environmentId: box.environmentId, threadId: boxThread, message: "Carry on there." }, + ], + [ + "t3_thread_organize", + { environmentId: box.environmentId, threadId: boxThread, action: "archive" }, + ], + [ + "t3_thread_launch", + { + environmentId: box.environmentId, + projectId: boxProject, + title: "Start there", + modelSelection: { instanceId: "codex", model: "gpt-5" }, + message: "Start there.", + }, + ], + ] as const) { + const refused = yield* a.call("thread:laptop-full", name, args, previewOnly); + expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" }); + } + expect(yield* Ref.get(b.bearers)).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts new file mode 100644 index 0000000000000..09963c76c6417 --- /dev/null +++ b/apps/server/src/peer/PeerForwarding.ts @@ -0,0 +1,192 @@ +import { + type EnvironmentId, + OrchestratorMcpFailure, + type OrchestratorMcpEnvironmentLinksResult, + OrchestratorMcpThreadWaitInput, + OrchestratorMcpThreadWaitResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Tool from "effect/ai/Tool"; + +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { readCaller, unavailable } from "../mcp/threadAccess.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import * as PeerMcpClient from "./PeerMcpClient.ts"; + +/** + * A peer's `/mcp` holds a wait open for its whole timeout without sending + * anything, and T3 Connect's edge drops a request idle for about 100 s, so a + * forwarded wait goes out in pieces no longer than this. + */ +export const FORWARDED_WAIT_CHUNK_MS = 50_000; +const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; +const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; + +type Scope = McpInvocationContext.McpInvocationScope; + +/** + * Runs T3 MCP tools in a linked environment for an agent here. The tool's own + * declaration checks the agent here first; the linked environment then holds + * the call to the link's access, narrowed to the agent's modes, which every + * forwarded call carries. + */ +export class PeerForwarding extends Context.Service< + PeerForwarding, + { + /** + * Runs `tool` in the linked environment `environmentId` with `params`. A + * `t3_thread_wait` goes in pieces short enough to survive any route. + */ + readonly call: ( + scope: Scope, + tool: T, + environmentId: EnvironmentId, + params: Tool.Parameters, + ) => Effect.Effect, OrchestratorMcpFailure>; + /** `t3_thread_wait` there, in pieces short enough to survive any route. */ + readonly waitForThread: ( + scope: Scope, + environmentId: EnvironmentId, + input: OrchestratorMcpThreadWaitInput, + ) => Effect.Effect; + /** The environments this one links to, as an agent sees them. */ + readonly links: ( + scope: Scope, + ) => Effect.Effect; + } +>()("t3/peer/PeerForwarding") {} + +/** The linked environment a call names, or nothing when it names this one. */ +export const remoteTarget = (scope: Scope, environmentId: EnvironmentId | undefined) => + environmentId === scope.environmentId ? undefined : environmentId; + +const make = Effect.gen(function* () { + const peers = yield* PeerMcpClient.PeerMcpClient; + const links = yield* PeerLinks.PeerLinks; + const crypto = yield* Crypto.Crypto; + const threads = yield* ThreadManagement.ThreadManagementService; + + /** + * The caller's own limits here, which travel with every call it forwards. + * The peer sees only the link's credential, so a credential that may not + * control threads here is refused here. + */ + const callerLimits = (scope: Scope) => + readCaller().pipe( + Effect.map((caller) => caller.limits), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(ThreadManagement.ThreadManagementService, threads), + ); + + /** + * Every caller here reaches the peer as the one link session, so a retry + * key is scoped to its caller before it leaves: two agents here reusing a + * key never collide there, and one agent retrying gets its first result. + */ + const scopedRequestId = (namespace: string, clientRequestId: string) => + crypto.digest("SHA-256", new TextEncoder().encode(`${namespace}\n${clientRequestId}`)).pipe( + Effect.map((digest) => `fwd:${Hex.encode(digest)}`), + Effect.orDie, + ); + + /** Tool and contract schemas are plain data: they encode and decode without services. */ + const forward = Effect.fn("PeerForwarding.forward")(function* ( + scope: Scope, + toolName: string, + parameters: Schema.Top, + success: Schema.Top, + environmentId: EnvironmentId, + params: unknown, + ) { + const limits = yield* callerLimits(scope); + const encoded = (yield* Schema.encodeUnknownEffect(parameters as Schema.Encoder)( + params, + ).pipe(Effect.mapError(unavailable))) as Record; + const { environmentId: _target, ...args } = encoded; + // A pending upload lives here, so it cannot go with the call. + if (Array.isArray(args.attachments) && args.attachments.length > 0) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "A launch in a linked environment cannot carry attachments yet.", + }); + } + if (typeof args.clientRequestId === "string") { + args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId); + } + return yield* peers.call({ + environmentId, + tool: toolName, + arguments: args, + limits, + success: success as Schema.Decoder, + }); + }); + + const waitForThread: PeerForwarding["Service"]["waitForThread"] = Effect.fn( + "PeerForwarding.waitForThread", + )(function* (scope, environmentId, input) { + let remaining = Math.min( + MAX_WAIT_TIMEOUT_MS, + Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + ); + let runId = input.runId; + while (true) { + const chunk = Math.min(remaining, FORWARDED_WAIT_CHUNK_MS); + const result = yield* forward( + scope, + "t3_thread_wait", + OrchestratorMcpThreadWaitInput, + OrchestratorMcpThreadWaitResult, + environmentId, + { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk }, + ); + remaining -= chunk; + if (!result.timedOut || remaining <= 0) return result; + // Later pieces wait for the run the first one picked, not a newer one. + runId = result.runId ?? undefined; + } + }); + + const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => + tool.name === "t3_thread_wait" + ? (waitForThread( + scope, + environmentId, + params as OrchestratorMcpThreadWaitInput, + ) as Effect.Effect, OrchestratorMcpFailure>) + : forward(scope, tool.name, tool.parametersSchema, tool.successSchema, environmentId, params); + + const listLinks: PeerForwarding["Service"]["links"] = Effect.fn("PeerForwarding.listLinks")( + function* (scope) { + yield* callerLimits(scope); + const listed = yield* links.list.pipe( + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), + ), + ); + return { + environmentId: scope.environmentId, + links: listed.map((link) => ({ + environmentId: link.environmentId, + label: link.label, + status: link.status, + access: link.access, + expiresAt: DateTime.formatIso(link.expiresAt), + lastError: link.lastError, + })), + }; + }, + ); + + return PeerForwarding.of({ call, waitForThread, links: listLinks }); +}); + +export const layer = Layer.effect(PeerForwarding, make); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index ee5ca4960bd85..00452fec1f4ce 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -1,11 +1,5 @@ import { NodeHttpServer } from "@effect/platform-node"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - EnvironmentHttpApi, - EnvironmentId, - type ExecutionEnvironmentDescriptor, - OrchestratorMcpFailure, -} from "@t3tools/contracts"; +import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -19,46 +13,23 @@ import { Tool, Toolkit } from "effect/ai"; import { FetchHttpClient, HttpClient, - HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse, } from "effect/http"; -import * as HttpApi from "effect/http-api/HttpApi"; -import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; -import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; -import * as AuthHttp from "../auth/http.ts"; -import * as McpOAuth from "../auth/McpOAuth.ts"; -import * as McpOAuthHttp from "../auth/mcpOAuthHttp.ts"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerConfig from "../config.ts"; -import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import * as ServerHttp from "../http.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; -import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as McpToolAccess from "../mcp/McpToolAccess.ts"; -import * as Sqlite from "../persistence/Sqlite.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import { + descriptorOf, + layerLinkingEnvironment, + servePeer, + type ServedPeer, +} from "./PeerLinks.testkit.ts"; import * as PeerMcpClient from "./PeerMcpClient.ts"; -class PeerTestApi extends HttpApi.make("environment") - .add(EnvironmentHttpApi.groups.metadata) - .add(EnvironmentHttpApi.groups.mcpOAuth) {} - -const descriptorOf = ( - environmentId: string, - label: string, - capabilities: Partial = {}, -): ExecutionEnvironmentDescriptor => ({ - environmentId: EnvironmentId.make(environmentId), - label, - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities }, -}); - /** What B's probe saw: the calling client and the modes it may start work with. */ const Probe = Schema.Struct({ caller: Schema.String, modes: Schema.String }); @@ -78,127 +49,37 @@ const ProbeToolkit = Toolkit.make( }), ); -/** - * Environment B as it runs: its descriptor, MCP OAuth, and `/mcp` behind the - * real client authenticator, on a real socket. The descriptor can be swapped, - * as when B's address comes to answer as another environment. - */ -const serveB = (initial: ExecutionEnvironmentDescriptor) => - Effect.gen(function* () { - const descriptor = yield* Ref.make(initial); - const bearers = yield* Ref.make>([]); - const probes = yield* Ref.make(0); - const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), - getDescriptor: Ref.get(descriptor), - }); - const authContext = yield* EnvironmentAuth.layer.pipe( - Layer.provide(Sqlite.layerMemory), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge(ServerEnvironment.layerIdentity), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-b-" })), - Layer.provideMerge(NodeServices.layer), - Layer.fresh, - Layer.build, - ); - // Every bearer B receives, on any route, so a test can show one was never sent. - const layerRecordBearers = HttpRouter.middleware( - (httpEffect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.url.startsWith("/.well-known/t3/environment")) { - yield* Ref.update(probes, (count) => count + 1); - } - const authorization = request.headers.authorization; - if (authorization !== undefined) { - yield* Ref.update(bearers, (seen) => [...seen, authorization]); - } - return yield* httpEffect; - }), - { global: true }, - ); - const layerRoutes = Layer.mergeAll( - HttpApiBuilder.layer(PeerTestApi).pipe( - Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), - Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), - Layer.provide(AuthHttp.layerAuthenticatedAuth), - ), - McpHttpServer.toolkitRegistration( - ProbeToolkit, - McpToolAccess.toLayer(ProbeToolkit, { - probe: McpToolAccess.reads(() => - McpInvocationContext.McpInvocationContext.pipe( - Effect.map((scope) => { - const modes = McpInvocationContext.clientModeCeiling(scope.client); - return { - caller: scope.client?.label ?? "none", - modes: `${modes.runtimeMode}/${modes.interactionMode}`, - }; - }), - ), - ), - refuse: McpToolAccess.reads(() => - Effect.fail( - new OrchestratorMcpFailure({ code: "thread_not_found", message: "No such thread." }), - ), - ), - }), - ).pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), - Layer.provide( - Layer.mock(McpSessionRegistry.McpSessionRegistry)({ - // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. - resolve: () => Effect.succeed(undefined), - }), - ), - Layer.provide(McpOAuth.layerMcpClientAuthenticator), - ), - ).pipe( - Layer.provide(layerRecordBearers), - Layer.provide(layerEnvironment), - Layer.provide(Layer.succeedContext(authContext)), - ); - yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( - Layer.build, - ); - const address = (yield* HttpServer.HttpServer).address; - if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); - return { - url: `http://127.0.0.1:${address.port}`, - auth: Context.get(authContext, EnvironmentAuth.EnvironmentAuth), - descriptor, - bearers, - /** How often its descriptor was asked for, as a probe of whether it answers. */ - probes, - }; - }); +const probeHandlers = McpToolAccess.toLayer(ProbeToolkit, { + probe: McpToolAccess.reads(() => + McpInvocationContext.McpInvocationContext.pipe( + Effect.map((scope) => { + const modes = McpInvocationContext.clientModeCeiling(scope.client); + return { + caller: scope.client?.label ?? "none", + modes: `${modes.runtimeMode}/${modes.interactionMode}`, + }; + }), + ), + ), + refuse: McpToolAccess.reads(() => + Effect.fail( + new OrchestratorMcpFailure({ code: "thread_not_found", message: "No such thread." }), + ), + ), +}); -/** Environment A: its own database and secret store, reaching B over plain HTTP. */ +const serveB = (descriptor: ExecutionEnvironmentDescriptor) => + servePeer(descriptor, McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers)); + +/** Environment A, which links to B. */ const makeA = (descriptor: ExecutionEnvironmentDescriptor) => - Effect.gen(function* () { - const context = yield* PeerMcpClient.layer.pipe( - Layer.provideMerge(PeerLinks.layer), - Layer.provide( - Layer.mergeAll( - Sqlite.layerMemory, - ServerSecretStore.layer, - FetchHttpClient.layer, - Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Effect.succeed(descriptor.environmentId), - getDescriptor: Effect.succeed(descriptor), - }), - ), - ), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-a-" })), - Layer.provide(NodeServices.layer), - Layer.fresh, - Layer.build, - ); - return { + layerLinkingEnvironment(descriptor).pipe( + Layer.build, + Effect.map((context) => ({ links: Context.get(context, PeerLinks.PeerLinks), peer: Context.get(context, PeerMcpClient.PeerMcpClient), - }; - }); + })), + ); const laptop = descriptorOf("environment-laptop", "Laptop"); const box = descriptorOf("environment-box", "Box"); @@ -215,14 +96,7 @@ const probe = ( success: Probe, }); -const linkedSessions = (b: Effect.Success>) => - b.auth - .listSessions() - .pipe( - Effect.map((sessions) => - sessions.filter((session) => session.client.label?.startsWith("T3 Code · ")), - ), - ); +const linkedSessions = (b: ServedPeer) => b.linkedSessions; it.effect("links with a pairing code, and the peer holds its agents to the limits they carry", () => Effect.scoped( diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts new file mode 100644 index 0000000000000..97af4ffde0e46 --- /dev/null +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -0,0 +1,177 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentHttpApi, + EnvironmentId, + type AuthMcpClientAccess, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpRouter from "effect/http/HttpRouter"; +import * as HttpServer from "effect/http/HttpServer"; +import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as AuthHttp from "../auth/http.ts"; +import * as McpOAuth from "../auth/McpOAuth.ts"; +import * as McpOAuthHttp from "../auth/mcpOAuthHttp.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerHttp from "../http.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import * as Sqlite from "../persistence/Sqlite.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import * as PeerMcpClient from "./PeerMcpClient.ts"; + +class PeerTestApi extends HttpApi.make("environment") + .add(EnvironmentHttpApi.groups.metadata) + .add(EnvironmentHttpApi.groups.mcpOAuth) {} + +export const descriptorOf = ( + environmentId: string, + label: string, + capabilities: Partial = {}, +): ExecutionEnvironmentDescriptor => ({ + environmentId: EnvironmentId.make(environmentId), + label, + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities }, +}); + +/** + * The environment a link points at, as it runs: its descriptor, MCP OAuth, + * and `/mcp` with `tools` behind the real client authenticator, on a real + * socket. The descriptor can be swapped, as when its address comes to answer + * as another environment, and every bearer it receives is recorded. + */ +export const servePeer = ( + initial: ExecutionEnvironmentDescriptor, + tools: Layer.Layer, +) => + Effect.gen(function* () { + const descriptor = yield* Ref.make(initial); + const bearers = yield* Ref.make>([]); + const probes = yield* Ref.make(0); + const layerEnvironment = Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Ref.get(descriptor).pipe(Effect.map((current) => current.environmentId)), + getDescriptor: Ref.get(descriptor), + }); + const authContext = yield* EnvironmentAuth.layer.pipe( + Layer.provide(Sqlite.layerMemory), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(ServerEnvironment.layerIdentity), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-peer-" })), + Layer.provideMerge(NodeServices.layer), + Layer.fresh, + Layer.build, + ); + const layerRecordBearers = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.url.startsWith("/.well-known/t3/environment")) { + yield* Ref.update(probes, (count) => count + 1); + } + const authorization = request.headers.authorization; + if (authorization !== undefined) { + yield* Ref.update(bearers, (seen) => [...seen, authorization]); + } + return yield* httpEffect; + }), + { global: true }, + ); + const layerRoutes = Layer.mergeAll( + HttpApiBuilder.layer(PeerTestApi).pipe( + Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), + Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), + Layer.provide(AuthHttp.layerAuthenticatedAuth), + ), + tools.pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + resolve: () => Effect.succeed(undefined), + }), + ), + Layer.provide(McpOAuth.layerMcpClientAuthenticator), + ), + ).pipe( + Layer.provide(layerRecordBearers), + Layer.provide(layerEnvironment), + Layer.provide(Layer.succeedContext(authContext)), + ); + // Fresh, so the peer never shares a toolkit or MCP server with the + // environment that links to it in the same test. + yield* HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( + Layer.fresh, + Layer.build, + ); + const address = (yield* HttpServer.HttpServer).address; + if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); + const url = `http://127.0.0.1:${address.port}`; + const auth = Context.get(authContext, EnvironmentAuth.EnvironmentAuth); + return { + url, + auth, + descriptor, + bearers, + /** How often its descriptor was asked for, as a probe of whether it answers. */ + probes, + /** The sessions linking created there, which its Connections lists. */ + linkedSessions: auth + .listSessions() + .pipe( + Effect.map((sessions) => + sessions.filter((session) => session.client.label?.startsWith("T3 Code · ")), + ), + ), + }; + }); + +export type ServedPeer = Effect.Success>; + +/** + * The environment that links: its own database and secret store, reaching + * peers over plain HTTP. Provide it to anything built on its links. + */ +export const layerLinkingEnvironment = (descriptor: ExecutionEnvironmentDescriptor) => + PeerMcpClient.layer.pipe( + Layer.provideMerge(PeerLinks.layer), + Layer.provide( + Layer.mergeAll( + Sqlite.layerMemory, + ServerSecretStore.layer, + FetchHttpClient.layer, + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(descriptor.environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-here-" })), + Layer.provideMerge(NodeServices.layer), + Layer.fresh, + ); + +/** Links `links` to `peer` with a fresh pairing code from it. */ +export const linkTo = ( + links: PeerLinks.PeerLinks["Service"], + peer: ServedPeer, + access: AuthMcpClientAccess = "auto", +) => + peer.auth + .issuePairingCredential() + .pipe( + Effect.flatMap((pairing) => + links.link({ url: peer.url, pairingCode: pairing.credential, access }), + ), + ); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 09d2fb89ba65a..1f7bdb4841723 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -22,6 +22,7 @@ import { ProviderAdapterRegistryV2 } from "../orchestration-v2/ProviderAdapterRe import { ThreadLaunchService } from "../orchestration-v2/ThreadLaunchService.ts"; import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; import { ThreadSearch } from "../orchestration-v2/ThreadSearch.ts"; +import { PeerForwarding } from "../peer/PeerForwarding.ts"; import { PreviewManager } from "../preview/Manager.ts"; import { PreviewBrowser } from "../preview/PreviewBrowser.ts"; import { ManagedProjectFolders } from "../project/ManagedProjectFolders.ts"; @@ -48,6 +49,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(ManagedProjectFolders)({ namedProjectsRoot: "" }), Layer.mock(McpProviderSessions)({}), Layer.mock(OrchestratorV2)({}), + Layer.mock(PeerForwarding)({}), Layer.mock(PreviewAutomationBroker)({}), Layer.mock(PreviewBrowser)({}), Layer.mock(PreviewManager)({}), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 38ac884823a10..946416154dd7d 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 4857, - "codexAdditionalContextBytes": 5409, - "mcpToolListJsonBytes": 42349 + "claudeSystemAppendBytes": 5086, + "codexAdditionalContextBytes": 5638, + "mcpToolListJsonBytes": 44232 } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5cd4a4a78fcb4..5226e1469b252 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -67,6 +67,7 @@ import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceR import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; +import * as PeerForwarding from "./peer/PeerForwarding.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -743,7 +744,12 @@ const layerMakeRoutes = Layer.mergeAll( ), Layer.provide(PreviewBrowser.layer), // Links to other environments, shared by Settings (WebSocket) and agents (/mcp). - Layer.provide(PeerMcpClient.layer.pipe(Layer.provideMerge(PeerLinks.layer))), + Layer.provide( + PeerForwarding.layer.pipe( + Layer.provideMerge(PeerMcpClient.layer), + Layer.provideMerge(PeerLinks.layer), + ), + ), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), Layer.provide(layerCommandReadiness), diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 3bd10a63882c6..1d2510b7d942b 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -11,7 +11,8 @@ not introduce another execution model. See An environment keeps its ID across server restarts and endpoint changes. Saved connections are local to a client profile; the server's identity and state are not. A repository identity can correlate clones across environments, but never -routes work between them. A project and its threads belong to one environment. +routes work between them. A project and its threads belong to one environment; +an agent reaches another environment's threads only through a peer link. The canonical key follows the `upstream` remote when one exists, so pull request features target the repository a fork tracks. A fork also reports its own `origin`, and clients group and label by that, so a fork never collapses into a @@ -66,6 +67,39 @@ miss a DHCP change, and the wildcard keeps the reported addresses current. Pull request routing trust covers the whole route list. Adding or changing a route revokes it; reordering does not, because the same addresses remain trusted. +## Peer links + +A [peer link](../../apps/server/src/peer/PeerLinks.ts) lets one environment's +agents work in another. The linking environment signs in to the other's `/mcp` +as an outside MCP client, with a pairing code from it, and keeps that session's +token in its own secret store. The other environment needs nothing new: it +sees one more OAuth client, lists it in Connections, and revokes it there. +Unlinking on the linking side only forgets the token. + +- **A tool runs in a linked environment when its parameters take + `OrchestratorMcpEnvironmentTarget`** as `environmentId`. + [`McpToolAccess.toLayer`](../../apps/server/src/mcp/McpToolAccess.ts) sends + such a call there once the tool's access declaration has checked the caller, + so a read-only or ended caller is refused before anything is sent. The + thread or project the call names is checked where it lives. A tool that acts + as the calling thread itself cannot take a linked environment. +- **Every forwarded call carries the calling agent's modes** in + `T3-Mode-Limit`, which the receiving `/mcp` only ever narrows the client's + approved access with. A plan-mode agent therefore cannot start full-access + work through a full-access link. +- **The token goes only to an address that proves it is the linked + environment**: its descriptor must report the linked environment id. A LAN + address that now belongs to another machine never receives it. +- **All callers on one side share the link's single session on the other**, + so idempotency keys are hashed with the caller's namespace before they + leave. Without that, two agents reusing a key would collide on the far side. +- **A forwarded wait is split into calls of at most 50 s**, because the + receiving `/mcp` sends nothing while a wait is open and T3 Connect's edge + drops idle requests after about 100 s. + +The link is routing, not isolation: an agent the link starts runs as the +receiving environment's user, inside the limits above. + ## Hosted web is a client The hosted web app stores its connection catalog in the browser and connects diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index d0fe65bbfc150..1aed057817cbe 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -336,6 +336,9 @@ export function summarizeT3ToolCalls( case "environment-update": label = phrase("Updated", "update", `environment preferences ${times}`); break; + case "environment-links": + label = phrase("Listed", "list", `linked environments ${times}`); + break; case "attachment-prepare": label = phrase( "Prepared", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 6ec8593d193b1..db76b53e01f66 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -2,8 +2,10 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AuthMcpClientAccess } from "./auth.ts"; import { ContextTransferId, + EnvironmentId, IsoDateTime, MessageId, NodeId, @@ -296,7 +298,20 @@ const OrchestratorMcpProjectTarget = Schema.optional( }), ); +/** + * A linked environment to act in instead of this one. The tools that take it + * forward the call there, where the link's access and the caller's own modes + * both apply. + */ +export const OrchestratorMcpEnvironmentTarget = Schema.optional( + EnvironmentId.annotate({ + description: + "Linked environment to act in. Omit for this one. Ids are then that environment's.", + }), +); + export const OrchestratorMcpThreadListInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), @@ -369,6 +384,7 @@ const clampedPositiveInt = (maximum: number) => ); export const OrchestratorMcpThreadReadInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, itemId: Schema.optional(TurnItemId), textOffset: Schema.optional(NonNegativeInt), @@ -463,6 +479,7 @@ export const OrchestratorMcpThreadReadResult = Schema.Struct({ export type OrchestratorMcpThreadReadResult = typeof OrchestratorMcpThreadReadResult.Type; export const OrchestratorMcpThreadSendInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, message: OrchestratorMcpPrompt, mode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), @@ -482,6 +499,7 @@ export const OrchestratorMcpThreadSendResult = Schema.Struct({ export type OrchestratorMcpThreadSendResult = typeof OrchestratorMcpThreadSendResult.Type; export const OrchestratorMcpThreadWaitInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, runId: Schema.optional(RunId), timeoutMs: Schema.optional(Schema.Finite), @@ -498,9 +516,16 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type; export const OrchestratorMcpThreadInterruptInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, runId: Schema.optional(RunId), reason: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))), + /** + * Stop the thread as its Stop button does instead: interrupt its running + * turn whichever run that is, hold its queued messages, end its pull request + * watches and stop every task it delegated. `runId` is then not used. + */ + stop: Schema.optional(Schema.Boolean), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpThreadInterruptInput = typeof OrchestratorMcpThreadInterruptInput.Type; @@ -517,6 +542,7 @@ export const OrchestratorMcpThreadInterruptResult = Schema.Struct({ export type OrchestratorMcpThreadInterruptResult = typeof OrchestratorMcpThreadInterruptResult.Type; export const OrchestratorMcpCapabilitiesInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, search: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(200)).annotate({ description: "Match model ids and labels. Replaces the short list.", @@ -553,6 +579,25 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ }); export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; +export const OrchestratorMcpEnvironmentLink = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + /** Whether it answered just now. An expired link must be linked again there. */ + status: Schema.Literals(["reachable", "unreachable", "expired"]), + /** The most the linked environment lets this one's agents do there. */ + access: AuthMcpClientAccess, + expiresAt: IsoDateTime, + lastError: Schema.NullOr(Schema.String), +}); +export type OrchestratorMcpEnvironmentLink = typeof OrchestratorMcpEnvironmentLink.Type; + +export const OrchestratorMcpEnvironmentLinksResult = Schema.Struct({ + environmentId: EnvironmentId, + links: Schema.Array(OrchestratorMcpEnvironmentLink), +}); +export type OrchestratorMcpEnvironmentLinksResult = + typeof OrchestratorMcpEnvironmentLinksResult.Type; + export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ parentThreadId: Schema.NullOr(ThreadId), inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId), @@ -575,6 +620,7 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type; export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, prompt: OrchestratorMcpPrompt, schedule: OrchestratorMcpSchedule, @@ -621,6 +667,7 @@ export const OrchestratorMcpScheduleTaskResult = OrchestratorMcpScheduledTask; export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTaskResult.Type; export const OrchestratorMcpListScheduledTasksInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: Schema.optional( ProjectId.annotate({ description: "Default: your thread's project, or all outside a thread.", @@ -637,6 +684,7 @@ export type OrchestratorMcpListScheduledTasksResult = typeof OrchestratorMcpListScheduledTasksResult.Type; export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, scheduledTaskId: ScheduledTaskId, prompt: Schema.optional(OrchestratorMcpPrompt), title: Schema.optional(OrchestratorMcpTitle), @@ -692,6 +740,7 @@ export const OrchestratorMcpRequestSecretResult = Schema.Union([ export type OrchestratorMcpRequestSecretResult = typeof OrchestratorMcpRequestSecretResult.Type; export const OrchestratorMcpDeleteScheduledTaskInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, scheduledTaskId: ScheduledTaskId, }); export type OrchestratorMcpDeleteScheduledTaskInput = diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index d78776eb78439..7790bed28f9b3 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -13,7 +13,8 @@ Ordinary coding tasks need no T3 tools. - If you are a subagent or child, do the work yourself. Start no subagents unless your task says to.`, `- \`t3_thread_launch\` and \`create_threads\` make new top-level threads, only when the user asks for a new thread. "Subagent", "delegate", and "parallel" never mean one. Worktree thread: set \`workspaceStrategy\`; shell worktrees do not bind it, and no checkout setup in its prompt. No \`workspaceStrategy\`: project root, not your worktree. - Given a thread id, \`t3_thread_read\` it before you answer about it. -- In your reply, a thread's first mention is its \`link\` verbatim. No \`link\`: \`[title](t3-thread://v1/)\`. Never a plain title or bare id. +- Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` to a T3 tool to act there. Ids are then that machine's, so pass \`threadId\`. +- In your reply, a thread's first mention is its \`link\` verbatim. No \`link\`: \`[title](t3-thread://v1/)\`. Never a plain title or bare id. A thread on a linked machine: its title and machine, not its link. - A secret: call \`request_secret\` yourself. Pass its \`secretRef\` to a T3 tool, or use \`delivery: "file"\` for a shell command. Never ask in chat or invent one. When asked only to schedule, \`schedule_task\` without running its prompt. - T3 tools may load late. Try the name once before you call it absent.`, `### Visuals and preview diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index c5ddf6af98ced..4c30698db8cef 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -51,6 +51,7 @@ export type T3McpToolSummaryAction = | "project-delete" | "project-clone" | "environment-read" + | "environment-links" | "environment-update" | "attachment-prepare" | "attachment-discard" @@ -321,6 +322,10 @@ const T3_MCP_TOOLS: Readonly> = { ["Read", "Reading", "Read", "environment preferences"], "environment-read", ), + t3_environment_links: tool( + ["List", "Listing", "Listed", "linked environments"], + "environment-links", + ), t3_environment_preferences_update: tool( ["Update", "Updating", "Updated", "environment preferences"], "environment-update", From 6918c35a367a63739baa74d056f06ba40e5ede25 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:25 -0700 Subject: [PATCH 5/9] feat(server): work a linked environment starts stays within its link (#16695) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/auth/EnvironmentAuth.ts | 5 + apps/server/src/auth/McpOAuth.ts | 29 +- apps/server/src/auth/SessionStore.ts | 7 + apps/server/src/cli/thread.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/mcp/McpHttpServer.test.ts | 1 + apps/server/src/mcp/McpInvocationContext.ts | 5 + apps/server/src/mcp/McpToolAccess.test.ts | 16 +- apps/server/src/mcp/McpToolAccess.ts | 77 +++- apps/server/src/mcp/OrchestratorMcpService.ts | 3 + apps/server/src/mcp/WorktreeMcpService.ts | 4 +- apps/server/src/mcp/linkOrigin.test.ts | 387 ++++++++++++++++++ apps/server/src/mcp/linkOrigin.ts | 60 +++ .../src/mcp/toolkits/attachment/handlers.ts | 54 +-- .../src/mcp/toolkits/orchestrator/handlers.ts | 2 + .../src/mcp/toolkits/project/handlers.ts | 115 +++--- .../src/orchestration-v2/Orchestrator.ts | 1 + .../src/orchestration-v2/ProjectionStore.ts | 6 + .../orchestration-v2/ThreadLaunchService.ts | 4 + .../ThreadManagementService.test.ts | 33 ++ .../ThreadManagementService.ts | 6 +- .../src/orchestration-v2/runtimeLayer.ts | 2 + apps/server/src/peer/PeerForwarding.test.ts | 100 +++-- apps/server/src/peer/PeerForwarding.ts | 9 +- apps/server/src/peer/PeerLinks.test.ts | 37 ++ apps/server/src/peer/PeerLinks.testkit.ts | 9 +- apps/server/src/peer/PeerLinks.ts | 47 ++- apps/server/src/peer/PeerMcpClient.ts | 2 +- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/071_PeerLinkFence.ts | 12 + .../reconcileV2PreviewMigration.test.ts | 2 + .../project/ProjectSetupScriptRunner.test.ts | 4 + .../src/project/ProjectSetupScriptRunner.ts | 29 +- docs/internals/remote.md | 25 +- packages/contracts/src/auth.ts | 8 + packages/contracts/src/environment.ts | 4 + packages/contracts/src/orchestrationV2.ts | 27 ++ 37 files changed, 1002 insertions(+), 134 deletions(-) create mode 100644 apps/server/src/mcp/linkOrigin.test.ts create mode 100644 apps/server/src/mcp/linkOrigin.ts create mode 100644 apps/server/src/persistence/Migrations/071_PeerLinkFence.ts diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 59b0077da048e..476a3b9f33e4c 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -92,6 +92,8 @@ export interface McpClientSession { readonly sessionId: AuthSessionId; readonly label: string; readonly access: AuthMcpClientAccess; + /** Another T3 Code environment's link, whose work here stays within it. */ + readonly peerLink: boolean; } export interface AuthenticatedSession { @@ -555,6 +557,7 @@ export class EnvironmentAuth extends Context.Service< readonly issueMcpClientSession: (input: { readonly label: string; readonly access: AuthMcpClientAccess; + readonly peerLink: boolean; readonly client: AuthClientMetadata; }) => Effect.Effect< { @@ -1305,6 +1308,7 @@ export const make = Effect.gen(function* () { access: session.scopes.includes(AuthOrchestrationOperateScope) ? (session.runtimeModeCeiling ?? "approval-required") : "read-only", + peerLink: session.peerLink === true, } satisfies McpClientSession) : Effect.fail( new ServerAuthInvalidCredentialError({ @@ -1324,6 +1328,7 @@ export const make = Effect.gen(function* () { scopes: mcpClientScopes(input.access), ttl: MCP_CLIENT_SESSION_TTL, ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }), + peerLink: input.peerLink, client: { ...input.client, label: input.label, deviceType: "bot" }, }) .pipe( diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 24cb4f4ddea3c..064304bce7a28 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -13,6 +13,7 @@ import { type AuthEnvironmentScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + MCP_PEER_LINK_SOFTWARE_ID, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Clock from "effect/Clock"; @@ -132,6 +133,8 @@ const ClientIdPayload = Schema.Struct({ v: Schema.Literal(1), n: Schema.String, r: Schema.Array(Schema.String), + /** Registered as another T3 Code environment's peer link. */ + p: Schema.optionalKey(Schema.Literal(1)), }); const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload)); @@ -139,6 +142,7 @@ export interface McpOAuthClient { readonly clientId: string; readonly name: string; readonly redirectUris: ReadonlyArray; + readonly peerLink: boolean; } export class McpOAuthPageError extends Schema.TaggedError()( @@ -172,6 +176,7 @@ export interface AuthorizationRequest { interface PendingCode { readonly clientId: string; readonly clientName: string; + readonly peerLink: boolean; readonly redirectUri: string; readonly codeChallenge: string; readonly resource: string; @@ -240,8 +245,10 @@ const make = Effect.gen(function* () { const sign = (domain: string, payload: string) => signPayload(`${domain}.${payload}`, signingKey); - const signClientId = (name: string, redirectUris: ReadonlyArray) => { - const body = base64UrlEncode(JSON.stringify({ v: 1, n: name, r: redirectUris })); + const signClientId = (name: string, redirectUris: ReadonlyArray, peerLink: boolean) => { + const body = base64UrlEncode( + JSON.stringify({ v: 1, n: name, r: redirectUris, ...(peerLink ? { p: 1 } : {}) }), + ); return `${body}.${sign("mcp-client-id", body)}`; }; @@ -257,7 +264,12 @@ const make = Effect.gen(function* () { } return Option.getOrUndefined( decodeClientIdPayload(json).pipe( - Option.map((payload) => ({ clientId, name: payload.n, redirectUris: payload.r })), + Option.map((payload) => ({ + clientId, + name: payload.n, + redirectUris: payload.r, + peerLink: payload.p === 1, + })), ), ); }; @@ -303,7 +315,13 @@ const make = Effect.gen(function* () { }); } // RFC 7591 allows ignoring unsupported registration options; all clients use public PKCE. - return { clientId: signClientId(name, redirectUris), name, redirectUris }; + const peerLink = metadata.software_id === MCP_PEER_LINK_SOFTWARE_ID; + return { + clientId: signClientId(name, redirectUris, peerLink), + name, + redirectUris, + peerLink, + }; }, ); @@ -385,6 +403,7 @@ const make = Effect.gen(function* () { next.set(code, { clientId: authorization.client.clientId, clientName: authorization.client.name, + peerLink: authorization.client.peerLink, redirectUri: authorization.redirectUri, codeChallenge: authorization.codeChallenge, resource: authorization.resource, @@ -488,6 +507,7 @@ const make = Effect.gen(function* () { .issueMcpClientSession({ label: pending.clientName, access: pending.access, + peerLink: pending.peerLink, client: deriveAuthClientMetadata({ request }), }) .pipe( @@ -540,6 +560,7 @@ export const layerMcpClientAuthenticator = Layer.effect( sessionId: client.sessionId, label: client.label, access: client.access, + linked: client.peerLink, }, capabilities: new Set([ "orchestration", diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index d192698012965..e11e4bc4e112f 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -60,6 +60,8 @@ export interface VerifiedSession { readonly scopes: ReadonlyArray; readonly proofKeyThumbprint?: string; readonly runtimeModeCeiling?: RuntimeMode; + /** An MCP client that is another T3 Code environment's peer link. */ + readonly peerLink?: true; } export interface VerifiedWebSocketTicket extends VerifiedSession { @@ -381,6 +383,7 @@ export class SessionStore extends Context.Service< readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; readonly runtimeModeCeiling?: RuntimeMode; + readonly peerLink?: boolean; readonly replaceActiveForSubjectAndMethod?: boolean; readonly replaceSessionId?: AuthSessionId; }) => Effect.Effect; @@ -441,6 +444,8 @@ const SessionClaims = Schema.Struct({ method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), jkt: Schema.optionalKey(Schema.String), rtc: Schema.optionalKey(RuntimeMode), + /** Signed, so a client cannot shed its peer link's limits. */ + plk: Schema.optionalKey(Schema.Literal(1)), iat: Schema.Number, exp: Schema.Number, }); @@ -679,6 +684,7 @@ const make = Effect.gen(function* () { method: input?.method ?? "browser-session-cookie", ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), ...(input?.runtimeModeCeiling ? { rtc: input.runtimeModeCeiling } : {}), + ...(input?.peerLink === true ? { plk: 1 as const } : {}), iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; @@ -860,6 +866,7 @@ const make = Effect.gen(function* () { scopes: claims.scopes, ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}), + ...(claims.plk === 1 ? { peerLink: true as const } : {}), } satisfies VerifiedSession; }, ); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index c14035b679548..777213bc9ff5f 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -221,6 +221,7 @@ const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 04c857b636010..fe47ea862d03f 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -267,6 +267,7 @@ export const make = Effect.gen(function* () { serverBrowser: true, peerLinks: true, mcpModeLimitHeader: true, + linkFence: true, }, }; diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 23b9acf9f15e6..570dc04ccd78c 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -982,6 +982,7 @@ it.effect( (input) => input, (_, modes) => Effect.succeed({ modes: `${modes.runtimeMode}/${modes.interactionMode}` }), + "stamped", ), }), ).pipe( diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 3553f0c3a776b..8e0f55f36b9e9 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -34,6 +34,11 @@ export interface McpClientCaller { readonly sessionId: string; readonly label: string; readonly access: AuthMcpClientAccess; + /** + * Another T3 Code environment's peer link. What it starts here carries its + * session as `linkOrigin`, and it changes only that work (`linkOrigin.ts`). + */ + readonly linked?: boolean | undefined; /** * Narrower modes the client asked for on this request (`T3-Mode-Limit`): * another environment passing on the limits of the agent it calls for. diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index e3c49f5cbdf5c..14888108502d5 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -144,6 +144,7 @@ const probeHandlers: McpToolAccess.Handlers = { starts_threads: McpToolAccess.startsThreads( (input) => input, (_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }), + "stamped", ), writes_environment: McpToolAccess.writesEnvironment(() => ran), }; @@ -231,6 +232,15 @@ const mismatchedProvider = { const supervisedClient = clientCaller("approval-required"); const fullAccessClient = clientCaller("full-access"); const readOnlyClient = clientCaller("read-only"); +const linkedClient: McpInvocationContext.McpInvocationScope = { + ...supervisedClient, + client: { ...supervisedClient.client!, linked: true }, +}; +// A full-access link's agents can do anything this environment's user can. +const fullAccessLinkedClient: McpInvocationContext.McpInvocationScope = { + ...fullAccessClient, + client: { ...fullAccessClient.client!, linked: true }, +}; const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; it.effect.each([ @@ -265,6 +275,10 @@ it.effect.each([ ["writes_with_capability", previewOnly, {}, "ran"], ["writes_with_capability", readOnlyClient, {}, "capability_denied"], ["writes_with_capability", ended, {}, "parent_not_active"], + ["writes", linkedClient, {}, "capability_denied"], + ["writes_with_capability", linkedClient, {}, "capability_denied"], + ["writes", fullAccessLinkedClient, {}, "ran"], + ["writes_with_capability", fullAccessLinkedClient, {}, "ran"], ["writes_threads", supervised, {}, "ran"], ["writes_threads", supervised, { threadId: planThreadId }, "ran"], @@ -374,7 +388,7 @@ const linkedProbeServer = McpHttpServer.toolkitRegistration( writes: McpToolAccess.writes(ranHere), writes_with_capability: McpToolAccess.writesWithCapability(ranHere), writes_threads: McpToolAccess.writesThreads((input) => [input.threadId], ranHere), - starts_threads: McpToolAccess.startsThreads((input) => input, ranHere), + starts_threads: McpToolAccess.startsThreads((input) => input, ranHere, "stamped"), writes_environment: McpToolAccess.writesEnvironment(ranHere), }), ).pipe( diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index d2e218dea0d5a..7e6821e51399d 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -1,5 +1,6 @@ import { type EnvironmentId, + type OrchestrationV2LinkOrigin, OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, type ProviderInteractionMode, @@ -20,6 +21,7 @@ import { type DispatchModeRefusal, } from "../orchestration-v2/DispatchModeLimit.ts"; import * as PeerForwarding from "../peer/PeerForwarding.ts"; +import { assertNotLinked, assertSameLink, callerLinkOrigin } from "./linkOrigin.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts"; import { @@ -135,16 +137,39 @@ export const actsAsCaller = (handle: (params: P) => Effect.Effect(handle: (params: P) => Effect.Effect) => +/** + * Changes something that belongs to no thread, such as a pending upload or a + * scheduled task. Work a linked environment started may not: none of it + * carries the link's origin, so none of it is that link's. + */ +export const writes = ( + handle: (params: P) => Effect.Effect, + options?: { + /** Whether a linked caller may make it, as for uploads its own sends need. */ + readonly linkedCallers?: "allowed"; + }, +) => declare( - (params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params))), + (params: P) => + orchestratingCaller.pipe( + Effect.tap((caller) => + options?.linkedCallers === "allowed" + ? Effect.void + : assertNotLinked(caller, "change this environment's scheduled tasks"), + ), + Effect.flatMap(() => handle(params)), + ), () => orchestratingCaller, ); -/** Requires handler capability checks, not thread control. */ +/** Requires handler capability checks, not thread control; linked work is refused the same way. */ export const writesWithCapability = (handle: (params: P) => Effect.Effect) => declare( - (params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params))), + (params: P) => + writingCaller.pipe( + Effect.tap((caller) => assertNotLinked(caller, "start work here that is not its link's")), + Effect.flatMap(() => handle(params)), + ), () => writingCaller, ); @@ -163,6 +188,7 @@ export const writesThreads = ( .pipe(Effect.mapError(unavailable)); if (target !== null && target.deletedAt === null) { yield* assertTargetWithinLimits(caller.limits, target); + yield* assertSameLink(caller, target); } } // Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors. @@ -193,29 +219,42 @@ const escalationDenied = (refusal: DispatchModeRefusal) => export interface StartedModes { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; + /** The linked environment the caller acts for. The thread must carry it. */ + readonly linkOrigin: OrchestrationV2LinkOrigin | undefined; } +/** + * Starts threads with the modes `modes` requests, which may not be broader + * than the caller's. `linked` says what a linked caller gets: threads stamped + * with its `linkOrigin`, or a refusal where the tool cannot carry one. + */ export const startsThreads = ( modes: (params: P) => { readonly runtimeMode?: RuntimeMode | undefined; readonly interactionMode?: ProviderInteractionMode | undefined; }, handle: (params: P, modes: StartedModes) => Effect.Effect, + linked: "stamped" | { readonly refused: string }, ) => { - const started = Effect.fn("McpToolAccess.started")(function* (params: P) { - const { limits } = yield* writingCaller; + const started = Effect.fn("McpToolAccess.started")(function* (caller: Caller, params: P) { const requested = modes(params); return { - runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode), + runtimeMode: yield* resolveRuntimeMode(caller.limits.runtimeMode, requested.runtimeMode), interactionMode: yield* resolveInteractionMode( - limits.interactionMode, + caller.limits.interactionMode, requested.interactionMode, ), + linkOrigin: callerLinkOrigin(caller), } satisfies StartedModes; }); return declare( - (params: P) => Effect.flatMap(started(params), (modes) => handle(params, modes)), - started, + (params: P) => + Effect.gen(function* () { + const caller = yield* writingCaller; + if (linked !== "stamped") yield* assertNotLinked(caller, linked.refused); + return yield* handle(params, yield* started(caller, params)); + }), + (params: P) => Effect.flatMap(writingCaller, (caller) => started(caller, params)), ); }; @@ -251,13 +290,29 @@ export const writesEnvironment = ( params: P, check: Effect.Effect, ) => Effect.Effect, + options?: { + /** + * Whether a linked caller may make it, as for adding a project: it leaves + * everything already here as it was. + */ + readonly linkedCallers?: "allowed"; + }, ) => { const check = orchestratingCaller.pipe( + Effect.tap((caller) => + options?.linkedCallers === "allowed" + ? Effect.void + : assertNotLinked(caller, "change projects or environment settings"), + ), Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)), ); return declare( (params: P) => check.pipe(Effect.flatMap(() => handle(params, check))), - () => check, + // Linked work never reaches a link at all; that is checked as the call goes. + () => + orchestratingCaller.pipe( + Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)), + ), ); }; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 481c2c9704525..f2ce8c8bb1c8a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2584,6 +2584,9 @@ const make = Effect.gen(function* () { interactionMode, branch: parent.thread.branch, worktreePath: parent.thread.worktreePath, + ...(parent.thread.linkOrigin === undefined + ? {} + : { linkOrigin: parent.thread.linkOrigin }), }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 8f02ee1546424..267df07f2432f 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -357,7 +357,9 @@ const make = Effect.gen(function* () { scriptName: result.scriptName, terminalId: result.terminalId, } - : { status: "no-script" }, + : result.status === "skipped-for-link" + ? { status: "skipped" } + : { status: "no-script" }, ), reportFailed("worktree handoff setup script failed"), ); diff --git a/apps/server/src/mcp/linkOrigin.test.ts b/apps/server/src/mcp/linkOrigin.test.ts new file mode 100644 index 0000000000000..c990fbf68e891 --- /dev/null +++ b/apps/server/src/mcp/linkOrigin.test.ts @@ -0,0 +1,387 @@ +import * as DocumentText from "../provider/DocumentText.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + ContextTransferId, + EnvironmentId, + NodeId, + type OrchestrationV2Run, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as ServerConfig from "../config.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import { makeSubagentChildThread } from "@t3tools/provider-core/server/subagentProjection"; +import * as ThreadForkService from "../orchestration-v2/ThreadForkService.ts"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as PeerForwarding from "../peer/PeerForwarding.ts"; +import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; + +// A linked environment's session drives this one's real orchestrator through +// its real T3 tools. What the link starts carries its origin, and the link +// changes only that. + +const instanceId = ProviderInstanceId.make("codex"); +const projectId = ProjectId.make("project:link-origin"); +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for these commands"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; +const layerDatabase = SqlitePersistence.layerMemory; +const layerOrchestrator = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "link-origin" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), +); +const layerOrchestration = ThreadManagement.layer.pipe( + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + Layer.provideMerge(Layer.merge(layerOrchestrator, ServerSettings.layerTest())), +); + +/** Launches dispatch the real thread.create, and nothing more: no workspace, no run. */ +const layerLaunches = Layer.effect( + ThreadLaunch.ThreadLaunchService, + Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + return ThreadLaunch.ThreadLaunchService.of({ + launch: (input: ThreadLaunch.ThreadLaunchInput) => + threads + .dispatch({ + type: "thread.create", + commandId: input.commandId, + threadId: input.threadId!, + projectId: input.projectId, + title: input.title, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: null, + worktreePath: null, + ...(input.linkOrigin === undefined ? {} : { linkOrigin: input.linkOrigin }), + createdBy: input.createdBy, + creationSource: input.creationSource, + }) + .pipe( + Effect.flatMap(() => threads.getThreadProjection(input.threadId!)), + Effect.map((projection) => ({ + threadId: input.threadId!, + projection, + resumed: false, + })), + Effect.orDie, + ), + } as unknown as ThreadLaunch.ThreadLaunchService["Service"]); + }), +); + +const layerTools = Layer.mergeAll( + McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerThreadToolkit, + McpHttpServer.layerProjectRegistration, +).pipe( + Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(layerLaunches), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + ), + Layer.provide( + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + ), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedNone, + create: (input) => + Effect.succeed({ + id: input.projectId, + title: input.title, + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-18T00:00:00.000Z", + updatedAt: "2026-10-18T00:00:00.000Z", + deletedAt: null, + }), + update: (input) => + Effect.succeed({ + id: input.projectId, + title: input.title ?? "Untitled", + workspaceRoot: "/p/t3code", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-18T00:00:00.000Z", + updatedAt: "2026-10-18T00:00:00.000Z", + deletedAt: null, + }), + }), + ), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/p" }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ + cloneRepository: (input) => + Effect.succeed({ + cwd: input.destinationPath, + remoteUrl: "https://github.com/pingdotgg/t3code.git", + repository: null, + }), + }), + ), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-link-origin-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Layer.provide(NodeServices.layer), +); + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "link-origin", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "link-origin", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const clientScope = ( + sessionId: string, + linked: boolean, + access: McpInvocationContext.McpClientCaller["access"], +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment:box"), + requestNamespace: `client:${sessionId}`, + thread: undefined, + client: { sessionId, label: `T3 Code · ${sessionId}`, access, linked }, + capabilities: new Set(["orchestration", "worktree", "pull-requests"]), + issuedAt: 0, +}); +const laptop = clientScope("laptop", true, "auto"); +const desk = clientScope("desk", true, "auto"); +const trusted = clientScope("trusted", true, "full-access"); +const claudeCode = clientScope("claude-code", false, "full-access"); + +const call = ( + scope: McpInvocationContext.McpInvocationScope, + name: string, + args: Record, +) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name, arguments: args })), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + +const failureCode = (result: McpSchema.CallToolResult) => { + const text = result.content[0]; + return result.isError === true && text?.type === "text" + ? (JSON.parse(text.text) as { code: string }).code + : undefined; +}; + +const launch = (scope: McpInvocationContext.McpInvocationScope, title: string) => + call(scope, "t3_thread_launch", { + projectId, + title, + modelSelection: { instanceId, model: "gpt-5" }, + }).pipe( + Effect.map((result) => { + assert.equal(result.isError, false, JSON.stringify(result.content)); + return (result.structuredContent as { threadId: ThreadId }).threadId; + }), + ); + +const usersOwnThread = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("thread:users-own"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create:users-own"), + threadId, + projectId, + title: "The user's own", + modelSelection: { instanceId, model: "gpt-5" }, + // Within an `auto` link's modes, so only the fence decides. + runtimeMode: "auto", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + return threadId; +}); + +it.layer(Layer.provideMerge(layerTools, layerOrchestration))("work a link starts", (it) => { + it.effect("is stamped, and so is the work it derives", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const launched = yield* launch(laptop, "Started from the laptop"); + const shell = yield* projections.getThreadShell(launched); + assert.deepEqual(shell?.linkOrigin, { + sessionId: "laptop", + label: "T3 Code · laptop", + access: "auto", + }); + + // A subagent of it and a fork of it are built from its thread, origin included. + const thread = (yield* ThreadManagement.ThreadManagementService.pipe( + Effect.flatMap((threads) => threads.getThreadProjection(launched)), + )).thread; + const child = makeSubagentChildThread({ + parentThread: thread, + childThreadId: ThreadId.make("thread:child"), + parentNodeId: NodeId.make("node:child"), + activeProviderThreadId: null, + providerInstanceId: instanceId, + modelSelection: { instanceId, model: "gpt-5" }, + title: "Child", + now: thread.createdAt, + createdBy: "agent", + creationSource: "mcp", + }); + assert.deepEqual(child.linkOrigin, shell?.linkOrigin); + const fork = yield* ThreadForkService.ThreadForkServiceV2.pipe( + Effect.flatMap((forks) => + forks.plan({ + sourceProjection: { thread }, + sourceRun: { id: RunId.make("run:source"), status: "completed" } as OrchestrationV2Run, + sourceProviderThread: undefined, + canonicalSourcePoint: { threadId: launched }, + transferId: ContextTransferId.make("transfer:fork"), + targetThreadId: ThreadId.make("thread:fork"), + createdBy: "agent", + creationSource: "mcp", + createdAt: thread.createdAt, + }), + ), + Effect.provide(ThreadForkService.layer), + ); + assert.deepEqual(fork.targetThread.linkOrigin, shell?.linkOrigin); + + // An ordinary outside agent's launch is not a link's. + const plain = yield* launch(claudeCode, "Started by Claude Code"); + assert.equal((yield* projections.getThreadShell(plain))?.linkOrigin, undefined); + }), + ); + + it.effect("changes only that link's threads below full access", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const users = yield* usersOwnThread; + const ownLaunch = yield* launch(laptop, "The laptop's"); + const rename = (scope: McpInvocationContext.McpInvocationScope, threadId: ThreadId) => + call(scope, "t3_thread_update", { + threadId, + action: "rename", + title: `Renamed by ${scope.client!.sessionId}`, + }); + + // The link may change what it started. + assert.equal((yield* rename(laptop, ownLaunch)).isError, false); + assert.equal((yield* projections.getThreadShell(ownLaunch))?.title, "Renamed by laptop"); + // Not the user's own thread, nor another link's. + assert.equal(failureCode(yield* rename(laptop, users)), "capability_denied"); + assert.equal(failureCode(yield* rename(desk, ownLaunch)), "capability_denied"); + assert.equal((yield* projections.getThreadShell(users))?.title, "The user's own"); + // An ordinary outside agent is not fenced. + assert.equal((yield* rename(claudeCode, users)).isError, false); + // Naming this environment's own id is still this environment's thread. + for (const [name, args] of [ + ["t3_thread_send", { message: "From the laptop." }], + ["t3_thread_interrupt", {}], + ] as const) { + const result = yield* call(laptop, name, { + ...args, + environmentId: laptop.environmentId, + threadId: users, + }); + assert.equal(failureCode(result), "capability_denied", name); + } + + // Nor scheduled tasks, which belong to no thread. + assert.equal( + failureCode(yield* call(laptop, "delete_scheduled_task", { scheduledTaskId: "any" })), + "capability_denied", + ); + + // Reads are not fenced. + const read = yield* call(laptop, "t3_thread_read", { threadId: users }); + assert.equal(read.isError, false); + }), + ); + + it.effect("is not fenced when the link has full access, but still says where it came from", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const users = yield* usersOwnThread; + const launched = yield* launch(trusted, "Started from the desk"); + assert.deepEqual((yield* projections.getThreadShell(launched))?.linkOrigin, { + sessionId: "trusted", + label: "T3 Code · trusted", + access: "full-access", + }); + + const renamed = yield* call(trusted, "t3_thread_update", { + threadId: users, + action: "rename", + title: "Renamed from the desk", + }); + assert.equal(renamed.isError, false, JSON.stringify(renamed.content)); + for (const [name, args] of [ + ["t3_project_update", { projectId, title: "The desk's now" }], + ["t3_project_create", { title: "t3code", workspaceRoot: "/p/t3code" }], + ] as const) { + const changed = yield* call(trusted, name, args); + assert.equal(changed.isError, false, `${name}: ${JSON.stringify(changed.content)}`); + } + }), + ); +}); diff --git a/apps/server/src/mcp/linkOrigin.ts b/apps/server/src/mcp/linkOrigin.ts new file mode 100644 index 0000000000000..7373fd51d2bd4 --- /dev/null +++ b/apps/server/src/mcp/linkOrigin.ts @@ -0,0 +1,60 @@ +import { + linkOriginIsFenced, + OrchestratorMcpFailure, + type OrchestrationV2LinkOrigin, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { Caller } from "./threadAccess.ts"; + +/** + * Below full access, work a link starts here (all of it carries that session in `linkOrigin`) + * may change only work from the same link: not the user's threads, projects or settings, nor + * this environment's links. Reads are not fenced. It is a routing rule for T3's tools; agents + * still run as this environment's user, so a full-access link is not fenced at all. + */ + +/** The link a caller acts for: its own session, or the link that started its thread. */ +export const callerLinkOrigin = ( + caller: Pick, +): OrchestrationV2LinkOrigin | undefined => + caller.caller?.linkOrigin ?? + (caller.scope.client?.linked === true + ? { + sessionId: caller.scope.client.sessionId, + label: caller.scope.client.label, + access: caller.scope.client.access, + } + : undefined); + +/** The link that fences a caller, if any: one paired below full access. */ +const fencingLinkOrigin = (caller: Pick) => { + const origin = callerLinkOrigin(caller); + return origin !== undefined && linkOriginIsFenced(origin) ? origin : undefined; +}; + +const outsideLink = (origin: OrchestrationV2LinkOrigin, message: string) => + new OrchestratorMcpFailure({ + code: "capability_denied", + message: `This work was started from ${origin.label} through a linked environment, so ${message} A link paired with full access is not limited this way.`, + }); + +/** A thread a fenced caller may change: one its own link started. */ +export const assertSameLink = ( + caller: Pick, + target: Pick, +) => { + const origin = fencingLinkOrigin(caller); + return origin === undefined || target.linkOrigin?.sessionId === origin.sessionId + ? Effect.void + : Effect.fail(outsideLink(origin, `it may change only threads that link started.`)); +}; + +/** Fenced work never changes what the environment already has: projects, settings, scheduled runs. */ +export const assertNotLinked = (caller: Pick, what: string) => { + const origin = fencingLinkOrigin(caller); + return origin === undefined + ? Effect.void + : Effect.fail(outsideLink(origin, `it cannot ${what}.`)); +}; diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 4c7f168f60128..b2248976235df 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -46,31 +46,37 @@ export const layer = McpToolAccess.toLayer( Effect.sync(() => { const uploadOwners = new Map(); return { - t3_attachment_prepare_upload: McpToolAccess.writes((input) => - Effect.gen(function* () { - const result = yield* Upload.issueAttachmentUploadUrl(input.upload).pipe( - Effect.mapError(unavailable), - ); - const now = yield* Clock.currentTimeMillis; - for (const [id, entry] of uploadOwners) { - if (now - entry.issuedAt > UPLOAD_OWNER_TTL_MS) uploadOwners.delete(id); - } - uploadOwners.set(result.attachmentId, { owner: yield* uploadOwner, issuedAt: now }); - return result; - }), + // A pending upload belongs to its caller until a send claims it, so + // linked callers may stage the uploads their own sends need. + t3_attachment_prepare_upload: McpToolAccess.writes( + (input) => + Effect.gen(function* () { + const result = yield* Upload.issueAttachmentUploadUrl(input.upload).pipe( + Effect.mapError(unavailable), + ); + const now = yield* Clock.currentTimeMillis; + for (const [id, entry] of uploadOwners) { + if (now - entry.issuedAt > UPLOAD_OWNER_TTL_MS) uploadOwners.delete(id); + } + uploadOwners.set(result.attachmentId, { owner: yield* uploadOwner, issuedAt: now }); + return result; + }), + { linkedCallers: "allowed" }, ), - t3_attachment_discard: McpToolAccess.writes((input) => - Effect.gen(function* () { - if (uploadOwners.get(input.attachmentId)?.owner !== (yield* uploadOwner)) { - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "Only the caller that prepared a pending upload can discard it.", - }); - } - yield* Upload.deletePendingAttachment(input.attachmentId); - uploadOwners.delete(input.attachmentId); - return {}; - }), + t3_attachment_discard: McpToolAccess.writes( + (input) => + Effect.gen(function* () { + if (uploadOwners.get(input.attachmentId)?.owner !== (yield* uploadOwner)) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Only the caller that prepared a pending upload can discard it.", + }); + } + yield* Upload.deletePendingAttachment(input.attachmentId); + uploadOwners.delete(input.attachmentId); + return {}; + }), + { linkedCallers: "allowed" }, ), t3_thread_send_attachments: McpToolAccess.writesThreads( (input) => [input.threadId], diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 1dfd6a70f0487..5d26408314aae 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -95,6 +95,8 @@ const handlers = { const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.scheduleTask(scope, { ...input, prompt, schedule }); }), + // A scheduled run starts long after its caller, with nothing to carry the link. + { refused: "schedule tasks in this environment" }, ), McpToolAccess.writes( ({ scheduledTaskId, prompt, title, schedule, enabled, bindToCurrentThread }) => diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index c380775e29984..55034630b2ca4 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -76,7 +76,7 @@ const access = Effect.gen(function* () { export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_thread_launch: McpToolAccess.startsThreads( (input) => input, - (input, { runtimeMode, interactionMode }) => + (input, { runtimeMode, interactionMode, linkOrigin }) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const context = yield* readCaller(); @@ -216,6 +216,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }), createdBy: "agent", creationSource: "mcp", + ...(linkOrigin === undefined ? {} : { linkOrigin }), }).pipe( Effect.mapError((error) => { if (error._tag === "AttachmentClaimError") { @@ -244,6 +245,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { status: run?.status ?? null, }; }), + // The launched thread carries the caller's link, if it has one. + "stamped", ), t3_project_list: McpToolAccess.reads((input) => Effect.gen(function* () { @@ -267,48 +270,53 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { return result.value; }), ), - t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) => - Effect.gen(function* () { - const projects = yield* Project.ProjectService; - if (workspaceRoot === undefined) { - // Only updates record model defaults; reject defaults that creation would silently drop. - if ( - input.scripts !== undefined || - input.createWorkspaceRootIfMissing !== undefined || - input.defaultModelSelection !== undefined - ) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: - "A project started from its title takes only a title; set scripts or defaultModelSelection afterwards with t3_project_update.", - }); - const folders = yield* ManagedProjectFolders.ManagedProjectFolders; - const created = yield* folders - .createNamedProject({ name: input.title }) - .pipe( + t3_project_create: McpToolAccess.writesEnvironment( + ({ workspaceRoot, ...input }) => + Effect.gen(function* () { + const projects = yield* Project.ProjectService; + if (workspaceRoot === undefined) { + // Only updates record model defaults; reject defaults that creation would silently drop. + if ( + input.scripts !== undefined || + input.createWorkspaceRootIfMissing !== undefined || + input.defaultModelSelection !== undefined + ) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "A project started from its title takes only a title; set scripts or defaultModelSelection afterwards with t3_project_update.", + }); + const folders = yield* ManagedProjectFolders.ManagedProjectFolders; + const created = yield* folders.createNamedProject({ name: input.title }).pipe( Effect.mapError( (error) => - new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), - ), - ); - const project = yield* projects - .getById(created.projectId) - .pipe( - Effect.mapError(unavailable), - Effect.flatMap( - Option.match({ onNone: () => Effect.fail(unavailable()), onSome: Effect.succeed }), + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: error.message, + }), ), ); - return { - ...project, - ...(created.commitError === undefined ? {} : { commitError: created.commitError }), - }; - } - const commandId = yield* newCommandId(); - return yield* projects - .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) }) - .pipe(Effect.mapError(projectFailure)); - }), + const project = yield* projects + .getById(created.projectId) + .pipe( + Effect.mapError(unavailable), + Effect.flatMap( + Option.match({ onNone: () => Effect.fail(unavailable()), onSome: Effect.succeed }), + ), + ); + return { + ...project, + ...(created.commitError === undefined ? {} : { commitError: created.commitError }), + }; + } + const commandId = yield* newCommandId(); + return yield* projects + .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) }) + .pipe(Effect.mapError(projectFailure)); + }), + // Linked work may bring the repository it works in; an existing + // registered workspace is still refused. + { linkedCallers: "allowed" }, ), t3_project_update: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { @@ -326,18 +334,21 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { .pipe(Effect.mapError(projectFailure)); }), ), - t3_project_clone: McpToolAccess.writesEnvironment((input) => - Effect.gen(function* () { - const repositories = yield* Repositories.SourceControlRepositoryService; - return yield* repositories.cloneRepository(input).pipe( - Effect.mapError( - (error) => - new OrchestratorMcpFailure({ - code: "orchestration_error", - message: error.detail, - }), - ), - ); - }), + t3_project_clone: McpToolAccess.writesEnvironment( + (input) => + Effect.gen(function* () { + const repositories = yield* Repositories.SourceControlRepositoryService; + return yield* repositories.cloneRepository(input).pipe( + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: error.detail, + }), + ), + ); + }), + // An existing destination is never adopted. + { linkedCallers: "allowed" }, ), }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e27f32ef84420..e627b4663602e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2539,6 +2539,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio worktreePath: command.worktreePath, activeProviderThreadId: null, pullRequests: [], + ...(command.linkOrigin === undefined ? {} : { linkOrigin: command.linkOrigin }), lineage: { parentThreadId: null, relationshipToParent: null, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ed064325bfe16..dff0bd31fa9ef 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1657,6 +1657,9 @@ export function threadShellFromProjection( ...(projection.thread.historyOrigin === undefined ? {} : { historyOrigin: projection.thread.historyOrigin }), + ...(projection.thread.linkOrigin === undefined + ? {} + : { linkOrigin: projection.thread.linkOrigin }), latestRunId: latestRun?.id ?? null, latestRunRequestedAt: latestRun?.requestedAt ?? null, latestRunStartedAt: latestRun?.startedAt ?? null, @@ -1933,6 +1936,9 @@ function shellFromState(input: { ...(input.state.thread.historyOrigin === undefined ? {} : { historyOrigin: input.state.thread.historyOrigin }), + ...(input.state.thread.linkOrigin === undefined + ? {} + : { linkOrigin: input.state.thread.linkOrigin }), latestRunId: input.state.latestRunId, latestRunRequestedAt: input.state.latestRunRequestedAt, latestRunStartedAt: input.state.latestRunStartedAt, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 763ef25b43626..faef398df104a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -9,6 +9,7 @@ import { type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2CreationSource, + type OrchestrationV2LinkOrigin, type OrchestrationV2ProviderThreadNativeMetadata, type OrchestrationV2ThreadProjection, type ProviderDriverKind, @@ -97,6 +98,8 @@ export interface ThreadLaunchInput { }; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; + /** The linked environment this launch is for, if one asked for it. */ + readonly linkOrigin?: OrchestrationV2LinkOrigin; } type PreparationInput = Pick< @@ -912,6 +915,7 @@ const make = Effect.gen(function* () { ...(input.importedNativeThread === undefined ? {} : { importedNativeThread: input.importedNativeThread }), + ...(input.linkOrigin === undefined ? {} : { linkOrigin: input.linkOrigin }), createdBy: input.createdBy, creationSource: input.creationSource, }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 0314008c6369f..2b75cd7633b86 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "@effect/vitest"; import { CommandId, MessageId, + type OrchestrationV2Command, type OrchestrationV2Run, type OrchestrationV2ProviderThread, ProviderThreadId, @@ -32,6 +33,38 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +it("stamps authoritative provenance on commands that create threads or messages", () => { + const command: OrchestrationV2Command = { + type: "thread.create", + createdBy: "agent", + creationSource: "mcp", + commandId: CommandId.make("command:thread-management:create"), + threadId: ThreadId.make("thread:thread-management:create"), + projectId: ProjectId.make("project:thread-management"), + title: "Thread management", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + // A client cannot claim a linked environment's origin for its own thread. + linkOrigin: { sessionId: "forged", label: "Somewhere else" }, + }; + + const stamped = ThreadManagementService.withCreationProvenance(command, { + createdBy: "user", + creationSource: "web", + }); + expect(stamped).toMatchObject({ + createdBy: "user", + creationSource: "web", + }); + expect(stamped).not.toHaveProperty("linkOrigin"); +}); + const layerReceiptsTest = CommandReceiptStore.layer.pipe( Layer.provide(SqlitePersistence.layerMemory), ); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 9a80f8d284172..6f5656ce83bab 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -70,7 +70,11 @@ export function withCreationProvenance( provenance: ThreadManagementProvenance, ): OrchestrationV2Command { switch (command.type) { - case "thread.create": + case "thread.create": { + // Only the server stamps a link's origin, so a client cannot set one. + const { linkOrigin: _linkOrigin, ...rest } = command; + return { ...rest, ...provenance }; + } case "message.dispatch": case "thread.fork": case "thread.merge_back": diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index eb6f9d1d3eaa1..e382beb55ed84 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -262,6 +262,8 @@ const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImp ); export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe( Layer.provide(layerProjectService), + // Whether a thread came from a linked environment, which skips setup. + Layer.provide(ProjectionStore.layer), ); const layerManagedProjectFoldersProvided = ManagedProjectFolders.layer.pipe( Layer.provide(layerProjectService), diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index eec5e9940f387..6a4fb2937c0e9 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -5,6 +5,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId, type OrchestrationV2Run, + type OrchestrationV2ThreadShell, type SourceControlCloneRepositoryInput, ProjectId, ProviderInstanceId, @@ -63,20 +64,33 @@ const nothingSeen: Seen = { stops: [], }; +/** The box user's own thread, which no link started. */ +const usersThread = ThreadId.make("thread:box-users-own"); + /** - * B's threads: one `auto` thread in `boxProject`, whose run finishes on the - * third wait. Every send and wait is recorded. + * B's threads: one `auto` thread in `boxProject` that the laptop's link + * started, whose run finishes on the third wait, and the box user's own. + * Every send and wait is recorded. */ -const boxThreads = (seen: Ref.Ref) => { - const shell = { ...liveThreadShell(boxThread, { runtimeMode: "auto" }), projectId: boxProject }; +const boxThreads = (seen: Ref.Ref, linkSession: Ref.Ref) => { + const base = { ...liveThreadShell(boxThread, { runtimeMode: "auto" }), projectId: boxProject }; + const linked = Ref.get(linkSession).pipe( + Effect.map((sessionId) => ({ ...base, linkOrigin: { sessionId, label: "T3 Code · Laptop" } })), + ); + const users = { ...liveThreadShell(usersThread, { runtimeMode: "auto" }), projectId: boxProject }; const run = (status: OrchestrationV2Run["status"]) => ({ id: boxRun, status }) as unknown as OrchestrationV2Run; - const projection = { thread: shell, runs: [run("running")], runtimeRequests: [] } as never; + const shellOf = (threadId: ThreadId): Effect.Effect => + threadId === boxThread ? linked : Effect.succeed(threadId === usersThread ? users : null); + const projectionOf = (threadId: ThreadId) => + shellOf(threadId).pipe( + Effect.map((thread) => ({ thread, runs: [run("running")], runtimeRequests: [] }) as never), + ); return Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: (threadId) => Effect.succeed(threadId === boxThread ? shell : null), - getProjectThreadRecords: () => Effect.succeed(projection), - getThreadRecords: () => Effect.succeed(projection), - listProjectThreads: () => Effect.succeed([shell] as never), + getThreadShell: shellOf, + getProjectThreadRecords: (input) => projectionOf(input.threadId), + getThreadRecords: (threadId) => projectionOf(threadId), + listProjectThreads: () => linked.pipe(Effect.map((shell) => [shell] as never)), sendToThread: (input) => Ref.update(seen, (current) => ({ ...current, sends: [...current.sends, input] })).pipe( Effect.as({ run: run("running"), delivery: "started" } as never), @@ -179,8 +193,12 @@ const boxProjects = (seen: Ref.Ref) => }), ); -/** The orchestrator and project toolkits on B's real `/mcp`, behind its real OAuth. */ -const serveBox = (seen: Ref.Ref) => +/** + * The orchestrator and project toolkits on B's real `/mcp`, behind its real + * OAuth. `linkSession` is filled in once A links, so B's fixture thread reads + * as one that link started. + */ +const serveBox = (seen: Ref.Ref, linkSession: Ref.Ref) => servePeer( box, Layer.mergeAll( @@ -190,7 +208,7 @@ const serveBox = (seen: Ref.Ref) => ).pipe( Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), Layer.provide(NodeCrypto.layer), - Layer.provide(boxThreads(seen)), + Layer.provide(boxThreads(seen, linkSession)), Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(boxLaunches(seen)), Layer.provide( @@ -259,12 +277,18 @@ const makeLaptop = Effect.gen(function* () { Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (threadId) => Effect.succeed( - liveThreadShell( - threadId, - threadId === ThreadId.make("thread:laptop-plan") - ? { runtimeMode: "full-access", interactionMode: "plan" } - : { runtimeMode: "full-access" }, - ), + threadId === ThreadId.make("thread:laptop-from-desk") + ? { + ...liveThreadShell(threadId, { runtimeMode: "full-access" }), + // Work another environment's link started on this laptop. + linkOrigin: { sessionId: "session:desk", label: "T3 Code · Desk" }, + } + : liveThreadShell( + threadId, + threadId === ThreadId.make("thread:laptop-plan") + ? { runtimeMode: "full-access", interactionMode: "plan" } + : { runtimeMode: "full-access" }, + ), ), getProjectThreadRecords: ({ threadId }) => Effect.succeed({ @@ -324,9 +348,11 @@ it.effect("an agent here works on a linked environment's threads within its own Effect.scoped( Effect.gen(function* () { const seen = yield* Ref.make(nothingSeen); - const b = yield* serveBox(seen); + const linkSession = yield* Ref.make(""); + const b = yield* serveBox(seen, linkSession); const a = yield* makeLaptop; yield* linkTo(a.links, b, "auto"); + yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId); const listed = yield* a.call("thread:laptop-full", "t3_environment_links", {}); expect(listed.structuredContent).toMatchObject({ @@ -392,9 +418,11 @@ it.effect("keeps a forwarded wait short and its retries scoped to the caller", ( Effect.scoped( Effect.gen(function* () { const seen = yield* Ref.make(nothingSeen); - const b = yield* serveBox(seen); + const linkSession = yield* Ref.make(""); + const b = yield* serveBox(seen, linkSession); const a = yield* makeLaptop; yield* linkTo(a.links, b, "auto"); + yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId); const waited = yield* a.call("thread:laptop-full", "t3_thread_wait", { environmentId: box.environmentId, @@ -442,9 +470,11 @@ it.effect("launches in a linked environment with the caller's modes, retry-safe" Effect.scoped( Effect.gen(function* () { const seen = yield* Ref.make(nothingSeen); - const b = yield* serveBox(seen); + const linkSession = yield* Ref.make(""); + const b = yield* serveBox(seen, linkSession); const a = yield* makeLaptop; yield* linkTo(a.links, b, "full-access"); + yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId); const codex = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }; const launch = (caller: string, args: Record = {}) => a.call(caller, "t3_thread_launch", { @@ -493,7 +523,7 @@ it.effect("brings a repository to a linked environment, only through full access Effect.scoped( Effect.gen(function* () { const seen = yield* Ref.make(nothingSeen); - const b = yield* serveBox(seen); + const b = yield* serveBox(seen, yield* Ref.make("")); const a = yield* makeLaptop; const clone = { repository: "pingdotgg/t3code", destinationPath: "/home/box/t3code" }; const register = { title: "t3code", workspaceRoot: "/home/box/t3code" }; @@ -543,9 +573,11 @@ it.effect("runs any tool that takes a linked environment there", () => Effect.scoped( Effect.gen(function* () { const seen = yield* Ref.make(nothingSeen); - const b = yield* serveBox(seen); + const linkSession = yield* Ref.make(""); + const b = yield* serveBox(seen, linkSession); const a = yield* makeLaptop; yield* linkTo(a.links, b, "auto"); + yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId); const there = yield* a.call("thread:laptop-full", "t3_thread_configure", { environmentId: box.environmentId, @@ -576,9 +608,11 @@ it.effect("refuses what a linked environment cannot take before anything leaves" Effect.scoped( Effect.gen(function* () { const seen = yield* Ref.make(nothingSeen); - const b = yield* serveBox(seen); + const linkSession = yield* Ref.make(""); + const b = yield* serveBox(seen, linkSession); const a = yield* makeLaptop; yield* linkTo(a.links, b, "auto"); + yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId); yield* Ref.set(b.bearers, []); const unknown = yield* a.call("thread:laptop-full", "t3_thread_read", { @@ -620,6 +654,24 @@ it.effect("refuses what a linked environment cannot take before anything leaves" expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" }); } expect(yield* Ref.get(b.bearers)).toEqual([]); + + // Work a desk's link started here never uses this laptop's links: the + // box would see the laptop, not the desk, so its fence could not hold. + for (const [name, args] of [ + ["t3_thread_read", { environmentId: box.environmentId, threadId: boxThread }], + [ + "t3_thread_send", + { environmentId: box.environmentId, threadId: boxThread, message: "From the desk." }, + ], + [ + "t3_thread_launch", + { environmentId: box.environmentId, projectId: boxProject, title: "x" }, + ], + ] as const) { + const relayed = yield* a.call("thread:laptop-from-desk", name, args); + expect(failureOf(relayed), name).toMatchObject({ code: "capability_denied" }); + } + expect(yield* Ref.get(b.bearers)).toEqual([]); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index 09963c76c6417..05b9db6afa9f1 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -14,6 +14,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import type * as Tool from "effect/ai/Tool"; +import { assertNotLinked } from "../mcp/linkOrigin.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { readCaller, unavailable } from "../mcp/threadAccess.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; @@ -74,12 +75,14 @@ const make = Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; /** - * The caller's own limits here, which travel with every call it forwards. - * The peer sees only the link's credential, so a credential that may not - * control threads here is refused here. + * The caller's own limits, which travel with every forwarded call: the peer sees only the + * link's credential, so a caller that may not control threads here is refused here. Work a + * link started never uses this environment's links, since the peer would see this + * environment's session instead of that link, and its fence could not hold. */ const callerLimits = (scope: Scope) => readCaller().pipe( + Effect.tap((caller) => assertNotLinked(caller, "use this environment's links")), Effect.map((caller) => caller.limits), Effect.provideService(McpInvocationContext.McpInvocationContext, scope), Effect.provideService(ThreadManagement.ThreadManagementService, threads), diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 00452fec1f4ce..0e27c19b5d6a5 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import { Tool, Toolkit } from "effect/ai"; import { @@ -78,6 +79,7 @@ const makeA = (descriptor: ExecutionEnvironmentDescriptor) => Effect.map((context) => ({ links: Context.get(context, PeerLinks.PeerLinks), peer: Context.get(context, PeerMcpClient.PeerMcpClient), + sql: Context.get(context, SqlClient.SqlClient), })), ); @@ -280,6 +282,35 @@ it.effect("tells the agent to link again once the peer revokes the link or it ex expect(revoked.code).toBe("capability_denied"); expect(revoked.message).toContain("revoked there"); + yield* a.links.link({ + url: b.url, + pairingCode: (yield* b.auth.issuePairingCredential()).credential, + access: "auto", + }); + // A link made with a peer that later answers without the fence, as + // one made before the fence or a peer moved back to such a version. + yield* Ref.set(b.descriptor, descriptorOf("environment-box", "Box", { linkFence: false })); + yield* Ref.set(b.bearers, []); + const unfenced = yield* probe(a).pipe(Effect.flip); + expect(unfenced.code).toBe("capability_denied"); + expect(unfenced.message).toContain("cannot keep a linked agent's limits"); + expect(yield* Ref.get(b.bearers)).toEqual([]); + yield* Ref.set(b.descriptor, box); + + // A link stored before links were marked: its session there is not a + // link's, so the peer would not fence its work even though it can. + yield* a.sql`UPDATE peer_environment_links SET fenced = 0`; + const legacy = yield* probe(a).pipe(Effect.flip); + expect(legacy.code).toBe("capability_denied"); + expect(legacy.message).toContain("Link it again"); + expect(yield* Ref.get(b.bearers)).toEqual([]); + yield* a.links.link({ + url: b.url, + pairingCode: (yield* b.auth.issuePairingCredential()).credential, + access: "auto", + }); + yield* probe(a); + yield* TestClock.adjust(Duration.days(31)); expect((yield* a.links.list).map((link) => link.status)).toEqual(["expired"]); yield* Ref.set(b.bearers, []); @@ -305,6 +336,12 @@ it.effect( .link({ url: b.url, pairingCode: pairing.credential, access: "auto" }) .pipe(Effect.flip); expect(older.reason).toBe("incompatible"); + // Nor one whose link's work could reach the user's own threads there. + yield* Ref.set(b.descriptor, descriptorOf("environment-box", "Box", { linkFence: false })); + const unfenced = yield* a.links + .link({ url: b.url, pairingCode: pairing.credential, access: "auto" }) + .pipe(Effect.flip); + expect(unfenced.reason).toBe("incompatible"); // Plain http to a LAN address would carry the code and token in clear. const lan = yield* a.links diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index 97af4ffde0e46..da9dbc2a1b8ff 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -43,7 +43,12 @@ export const descriptorOf = ( label, platform: { os: "linux", arch: "x64" }, serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true, mcpModeLimitHeader: true, ...capabilities }, + capabilities: { + repositoryIdentity: true, + mcpModeLimitHeader: true, + linkFence: true, + ...capabilities, + }, }); /** @@ -146,9 +151,9 @@ export type ServedPeer = Effect.Success>; export const layerLinkingEnvironment = (descriptor: ExecutionEnvironmentDescriptor) => PeerMcpClient.layer.pipe( Layer.provideMerge(PeerLinks.layer), + Layer.provideMerge(Sqlite.layerMemory), Layer.provide( Layer.mergeAll( - Sqlite.layerMemory, ServerSecretStore.layer, FetchHttpClient.layer, Layer.succeed(ServerEnvironment.ServerEnvironment, { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 4d438700d4a5e..958e368805daf 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -4,6 +4,8 @@ import { AuthMcpTokenError, EnvironmentHttpApi, EnvironmentId, + type ExecutionEnvironmentCapabilities, + MCP_PEER_LINK_SOFTWARE_ID, type PeerLink, type PeerLinkCreateInput, PeerLinkError, @@ -85,6 +87,7 @@ interface PeerLinkRow { readonly expires_at: string; readonly last_reached_at: string | null; readonly last_error: string | null; + readonly fenced: number; } const decodeUrls = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.String))); @@ -136,6 +139,10 @@ const peerOrigin = (url: string) => { return Effect.succeed(parsed.origin); }; +/** Whether a peer holds a linked agent to its limits and keeps its work within its link. */ +const linkLimitsHold = (peer: { readonly capabilities: ExecutionEnvironmentCapabilities }) => + peer.capabilities.mcpModeLimitHeader === true && peer.capabilities.linkFence === true; + const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const secrets = yield* ServerSecretStore.ServerSecretStore; @@ -200,7 +207,13 @@ const make = Effect.gen(function* () { // Only the peer's own refusals are rejections. A dropped connection or // an answer this version cannot read is classified below. const registered = yield* client.mcpOAuth - .register({ payload: { client_name: clientName, redirect_uris: [LINK_REDIRECT_URI] } }) + .register({ + payload: { + client_name: clientName, + redirect_uris: [LINK_REDIRECT_URI], + software_id: MCP_PEER_LINK_SOFTWARE_ID, + }, + }) .pipe( // A plain Schema.Error has no tag to catch by. Effect.catchIf(Schema.is(AuthMcpRegistrationError), (error) => @@ -322,7 +335,7 @@ const make = Effect.gen(function* () { message: "That address is this environment.", }); } - if (peer.capabilities.mcpModeLimitHeader !== true) { + if (!linkLimitsHold(peer)) { return yield* new PeerLinkError({ reason: "incompatible", message: `${peer.label} runs a T3 Code version that cannot keep a linked agent's limits. Update it, then link again.`, @@ -351,14 +364,15 @@ const make = Effect.gen(function* () { yield* sql` INSERT INTO peer_environment_links ( environment_id, secret_name, label, urls_json, access, linked_at, expires_at, - last_reached_at, last_error + last_reached_at, last_error, fenced ) VALUES ( ${linked.environmentId}, ${secretName}, ${linked.label}, ${urlsJson}, ${linked.access}, ${DateTime.formatIso(linked.linkedAt)}, ${DateTime.formatIso(linked.expiresAt)}, - ${DateTime.formatIso(now)}, NULL + ${DateTime.formatIso(now)}, NULL, 1 ) ON CONFLICT (environment_id) DO UPDATE SET secret_name = excluded.secret_name, + fenced = excluded.fenced, label = excluded.label, urls_json = excluded.urls_json, access = excluded.access, @@ -439,6 +453,14 @@ const make = Effect.gen(function* () { message: `The link to ${link.label} expired. Link it again with a new pairing code from ${link.label}.`, }); } + // Its token was issued before links were marked as links, so the peer + // never fences the work it carries, whatever version it runs now. + if (row.value.fenced !== 1) { + return yield* new PeerLinkError({ + reason: "incompatible", + message: `The link to ${link.label} was made by an older T3 Code. Link it again with a new pairing code from ${link.label}.`, + }); + } const stored = yield* secrets.get(row.value.secret_name).pipe(Effect.catch(failStorage)); if (Option.isNone(stored)) { return yield* new PeerLinkError({ @@ -448,11 +470,14 @@ const make = Effect.gen(function* () { } // The token goes only to an address that proves it is this peer, so a // reused LAN address or a moved hostname never receives it. + let fenced = true; const url = yield* Effect.findFirst(link.urls, (candidate) => describe(candidate).pipe( - Effect.map( - (found) => Option.isSome(found) && found.value.environmentId === environmentId, - ), + Effect.map((found) => { + if (Option.isNone(found) || found.value.environmentId !== environmentId) return false; + fenced = linkLimitsHold(found.value); + return true; + }), ), ); if (Option.isNone(url)) { @@ -461,6 +486,14 @@ const make = Effect.gen(function* () { message: `${link.label} did not answer at ${link.urls.join(", ")}.`, }); } + // A link made before the peer had to fence linked work, or a peer moved + // back to such a version, would carry work past those limits. + if (!fenced) { + return yield* new PeerLinkError({ + reason: "incompatible", + message: `${link.label} runs a T3 Code version that cannot keep a linked agent's limits. Update it, then link again.`, + }); + } return { link, url: url.value, token: new TextDecoder().decode(stored.value) }; }, ); diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index 710b7c26c6e70..e85e39aebfd68 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -70,7 +70,7 @@ const fromLinkError = (error: PeerLinkError) => code: error.reason === "unknown_link" ? "invalid_request" - : error.reason === "expired" + : error.reason === "expired" || error.reason === "incompatible" ? "capability_denied" : "orchestration_error", message: error.message, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9b352d271b271..86a795612d438 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -73,6 +73,7 @@ import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; import Migration0068 from "./Migrations/068_ShellStopRequestIndex.ts"; import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; import Migration0070 from "./Migrations/070_PeerLinks.ts"; +import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -146,6 +147,7 @@ const migrationEntries = [ [68, "ShellStopRequestIndex", Migration0068], [69, "ThreadMessageSearch", Migration0069], [70, "PeerLinks", Migration0070], + [71, "PeerLinkFence", Migration0071], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/071_PeerLinkFence.ts b/apps/server/src/persistence/Migrations/071_PeerLinkFence.ts new file mode 100644 index 0000000000000..8b2ca3039d133 --- /dev/null +++ b/apps/server/src/persistence/Migrations/071_PeerLinkFence.ts @@ -0,0 +1,12 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Whether the link's session was issued as a link, which the peer fences. + // Links stored before it are not, and must be made again. + yield* sql` + ALTER TABLE peer_environment_links ADD COLUMN fenced INTEGER NOT NULL DEFAULT 0 + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 3e699b30dfe29..802f77beb34da 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -50,6 +50,7 @@ describe("V2 preview upgrade", () => { [68, "ShellStopRequestIndex"], [69, "ThreadMessageSearch"], [70, "PeerLinks"], + [71, "PeerLinkFence"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -143,6 +144,7 @@ describe("V2 preview upgrade", () => { [68, "ShellStopRequestIndex"], [69, "ThreadMessageSearch"], [70, "PeerLinks"], + [71, "PeerLinkFence"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index c295a06ac39ac..29a15a6f0715b 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -11,6 +11,7 @@ import { TestClock } from "effect/testing"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectService from "./ProjectService.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; @@ -58,6 +59,9 @@ const makeHarness = Effect.fn(function* (options: { Layer.mergeAll( terminals, Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), ServerSettings.layerTest(), NodeCrypto.layer, ), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 612bb773d0748..075e5843754b3 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,4 +1,4 @@ -import { ProjectId, type ProjectScript } from "@t3tools/contracts"; +import { linkOriginIsFenced, ProjectId, type ProjectScript, ThreadId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { projectScriptRuntimeEnv, @@ -17,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectService from "./ProjectService.ts"; @@ -46,8 +47,18 @@ export interface ProjectSetupScriptOutputLine { readonly line: string; } +/** + * A thread a link below full access started does not run this project's setup + * script: the script is the user's own code, and such a link only grants T3's + * tools (see `mcp/linkOrigin.ts`). + */ +export interface ProjectSetupScriptRunnerResultSkippedForLink { + readonly status: "skipped-for-link"; +} + export type ProjectSetupScriptRunnerResult = | ProjectSetupScriptRunnerResultNoScript + | ProjectSetupScriptRunnerResultSkippedForLink | ProjectSetupScriptRunnerResultStarted; export interface ProjectSetupScriptRunnerInput { @@ -74,7 +85,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedError + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readThread", + cause, + }), + ), + ); + if (thread?.linkOrigin !== undefined && linkOriginIsFenced(thread.linkOrigin)) + return { status: "skipped-for-link" } as const; const suppliedProject = input.project; const projectById = suppliedProject ?? diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 1d2510b7d942b..5bc642e8d14a5 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -97,8 +97,31 @@ Unlinking on the linking side only forgets the token. receiving `/mcp` sends nothing while a wait is open and T3 Connect's edge drops idle requests after about 100 s. +**Work a link paired below full access starts stays within that link.** The +receiving environment knows a session is a peer link because the linking side +registers its OAuth client with the `t3code-peer-link` software id, and the +marker is signed into the session. Threads that session launches carry it, with +the access it was paired with, as an immutable `linkOrigin`, and so do the +subagents, forks and `create_threads` they derive. The rules live in +[`mcp/linkOrigin.ts`](../../apps/server/src/mcp/linkOrigin.ts) and run in the +shared access declarations, not tool by tool: + +- linked work may change only threads with the same origin, not the user's own + threads and not another link's; +- it cannot change projects, settings or scheduled tasks, nor use the receiving + environment's own links; +- its project setup scripts are skipped + ([`ProjectSetupScriptRunner`](../../apps/server/src/project/ProjectSetupScriptRunner.ts) + checks every path that runs them). + +Reads are not fenced, and a client's own `thread.create` can never carry +`linkOrigin`. + The link is routing, not isolation: an agent the link starts runs as the -receiving environment's user, inside the limits above. +receiving environment's user. **A full-access link is therefore not fenced**: +its agents can already do anything that user can, so the fence would only stop +T3's own tools. Its work still carries `linkOrigin`, so clients show where it +came from. A stamp without the access, from before it was recorded, is fenced. ## Hosted web is a client diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 779740fd08ae2..fbdf2c3a34e6f 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -431,8 +431,16 @@ export const AuthMcpAuthorizationServerMetadata = Schema.Struct({ }); export type AuthMcpAuthorizationServerMetadata = typeof AuthMcpAuthorizationServerMetadata.Type; +/** + * The `software_id` another T3 Code environment registers with when it links + * to this one. Its session is then a peer link: the work it starts here stays + * within that link. Claiming it only ever adds limits. + */ +export const MCP_PEER_LINK_SOFTWARE_ID = "t3code-peer-link"; + export const AuthMcpClientRegistration = Schema.Struct({ client_name: Schema.optionalKey(Schema.String), + software_id: Schema.optionalKey(Schema.String), redirect_uris: Schema.optionalKey(Schema.Array(Schema.String)), token_endpoint_auth_method: Schema.optionalKey(Schema.String), }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index e0986dbb3408c..7f3f5ee18c2df 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -142,6 +142,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ a linked environment's calls keep its agent's limits. Another environment links to this one only when it is set. */ mcpModeLimitHeader: Schema.optionalKey(Schema.Boolean), + /** Work a linked environment starts here stays within its link + (`linkOrigin`). Another environment links to this one only when it is + set, since without it that work could change the user's own threads. */ + linkFence: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f4e8303972267..8ce02f8b650e1 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -40,6 +40,7 @@ import { TrimmedNonEmptyString, TurnItemId, } from "./baseSchemas.ts"; +import { AuthMcpClientAccess } from "./auth.ts"; import { ChatAttachment } from "./chatAttachment.ts"; import { ThreadGuestShare } from "./guestShare.ts"; import { @@ -103,6 +104,28 @@ const OrchestrationV2CreationFields = { creationSource: OrchestrationV2CreationSource, } as const; +/** + * The linked environment whose agent started this work here: the MCP client + * session it signed in with, that session's label, and the access it was + * paired with. Set when the thread is created and never changed; work derived + * from it carries the same origin. + */ +export const OrchestrationV2LinkOrigin = Schema.Struct({ + sessionId: TrimmedNonEmptyString, + label: Schema.String, + access: Schema.optional(AuthMcpClientAccess), +}); +export type OrchestrationV2LinkOrigin = typeof OrchestrationV2LinkOrigin.Type; + +/** + * Work from a link paired below full access may change only work from the same + * link. A full-access link's agents already run as this environment's user, + * so fencing T3's own tools would stop nothing they could not do anyway. An + * origin stamped before access was recorded stays fenced. + */ +export const linkOriginIsFenced = (origin: OrchestrationV2LinkOrigin) => + origin.access !== "full-access"; + export const OrchestrationV2NativeRefStrength = Schema.Literals(["strong", "weak", "none"]); export type OrchestrationV2NativeRefStrength = typeof OrchestrationV2NativeRefStrength.Type; @@ -414,6 +437,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), activeProviderThreadId: Schema.NullOr(ProviderThreadId), historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin), + linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), lineage: OrchestrationV2AppThreadLineage, forkedFrom: Schema.NullOr( Schema.Union([ @@ -1942,6 +1966,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ forkedFrom: Schema.NullOr(OrchestrationV2AppThread.fields.forkedFrom), activeProviderThreadId: Schema.NullOr(ProviderThreadId), historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin), + linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), latestRunId: Schema.NullOr(RunId), latestRunRequestedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -2774,6 +2799,8 @@ export const OrchestrationV2Command = Schema.Union([ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + /** Only the server sets this; a client's thread.create never carries one. */ + linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), importedNativeThread: Schema.optional( Schema.Struct({ ref: Schema.Struct({ From a6676f9a13a6363787cfba80edd7f696e6e9ae7e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:26 -0700 Subject: [PATCH 6/9] feat(web,mobile): linked environments in Connections (#16698) Co-authored-by: Claude Opus 5.5 (1M context) --- .../connection/ConnectionsRouteScreen.tsx | 10 + .../connection/LinkedEnvironmentsSection.tsx | 78 +++++ .../features/threads/ThreadRouteScreen.tsx | 1 + apps/mobile/src/state/peerLinks.ts | 5 + apps/server/src/auth/RpcAuthorization.ts | 3 - apps/web/src/components/ChatView.tsx | 1 + .../components/auth/ConnectAgentSurface.tsx | 4 +- apps/web/src/components/chat/ChatHeader.tsx | 13 + .../settings/ConnectionsSettings.tsx | 11 + .../settings/LinkedEnvironmentsSettings.tsx | 283 ++++++++++++++++++ .../settings/settingsSearch.test.ts | 27 ++ .../src/components/settings/settingsSearch.ts | 13 +- .../useAvailableSettingsSearchItems.ts | 3 + apps/web/src/state/peerLinks.ts | 19 ++ docs/user/remote-access.md | 22 ++ packages/client-runtime/package.json | 4 + .../src/state/commandPermissions.test.ts | 132 ++++++++ packages/client-runtime/src/state/models.ts | 2 + .../client-runtime/src/state/peerLinks.ts | 57 ++++ .../contracts/src/clientRpcPermissions.ts | 2 + 20 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx create mode 100644 apps/mobile/src/state/peerLinks.ts create mode 100644 apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx create mode 100644 apps/web/src/state/peerLinks.ts create mode 100644 packages/client-runtime/src/state/peerLinks.ts diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 6192b53301cb9..1c89305b8fdbd 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -11,6 +11,7 @@ import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { LocalEnvironmentList } from "./LocalEnvironmentList"; import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; +import { LinkedEnvironmentsSection } from "./LinkedEnvironmentsSection"; export function ConnectionsRouteScreen() { const { @@ -69,6 +70,15 @@ export function ConnectionsRouteScreen() { onSetEnabled={onSetEnvironmentEnabled} onUpdate={onUpdateEnvironment} /> + {connectedEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => ( + + ))} diff --git a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx new file mode 100644 index 0000000000000..66dd7dfd8f485 --- /dev/null +++ b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx @@ -0,0 +1,78 @@ +import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks"; +import { useFocusEffect } from "@react-navigation/native"; +import { AuthAccessReadScope, type EnvironmentId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { useCallback, useState } from "react"; +import { AppState, View } from "react-native"; + +import { AppText as Text } from "../../components/ui/AppText"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { peerLinkEnvironment } from "../../state/peerLinks"; +import { useEnvironmentQuery } from "../../state/query"; +import { useEnvironmentScope } from "../../state/session"; +import { SettingsSection } from "../settings/components/SettingsSection"; + +export function LinkedEnvironmentsSection(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const config = useEnvironmentServerConfig(props.environmentId); + const canRead = useEnvironmentScope(props.environmentId, AuthAccessReadScope); + const supported = canRead && config?.environment.capabilities.peerLinks === true; + const links = useEnvironmentQuery( + supported ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) : null, + ); + const [nowMs, setNowMs] = useState(Date.now); + const hasLinks = supported && (links.data?.links.length ?? 0) > 0; + useFocusEffect( + useCallback(() => { + if (!hasLinks) return; + const updateNow = () => setNowMs(Date.now()); + updateNow(); + const timer = setInterval(updateNow, 60_000); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") updateNow(); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [hasLinks]), + ); + if (!supported || links.error || !links.data || links.data.links.length === 0) return null; + const now = DateTime.makeUnsafe(nowMs); + return ( + + + {links.data.links.map((link) => { + const health = peerLinkHealth(link, now); + const status = + health.kind === "expired" + ? "Expired. Link it again with a new pairing code." + : health.kind === "unreachable" + ? (health.detail ?? "Not answering right now.") + : health.kind === "failing" + ? health.detail + : health.expiresInDays !== null + ? `Expires in ${health.expiresInDays} ${health.expiresInDays === 1 ? "day" : "days"}` + : "Reachable"; + return ( + + {link.label} + + {status} + + + ); + })} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index ed1a9819d267f..2233a30441a51 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -489,6 +489,7 @@ function ThreadRouteContent( const headerSubtitle = [ selectedThreadProject?.title ?? null, selectedEnvironmentConnection?.environmentLabel ?? null, + selectedThread?.linkOrigin ? `From ${selectedThread.linkOrigin.label}` : null, ] .filter(Boolean) .join(" · "); diff --git a/apps/mobile/src/state/peerLinks.ts b/apps/mobile/src/state/peerLinks.ts new file mode 100644 index 0000000000000..66a291300533f --- /dev/null +++ b/apps/mobile/src/state/peerLinks.ts @@ -0,0 +1,5 @@ +import { createPeerLinkEnvironmentAtoms } from "@t3tools/client-runtime/state/peerLinks"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const peerLinkEnvironment = createPeerLinkEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a8cdbccf68fec..35e2bd4776fb3 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -5,7 +5,6 @@ import { authScopeRequiredResponse, AssetCreateUrlInput, AuthAccessReadScope, - AuthAccessWriteScope, ServerSettingsPatch, ProviderInstanceMutation, requiredScopesForServerSettingsPatch, @@ -88,8 +87,6 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.peerLinksList]: AuthAccessReadScope, - [WS_METHODS.peerLinksLink]: AuthAccessWriteScope, - [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 19726720b1427..7bb833cb905ea 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3159,6 +3159,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThread.id} isServerThread={isServerThread} activeThreadTitle={activeThread.title} + activeThreadLinkOrigin={activeThreadShell?.linkOrigin?.label ?? null} activeProject={activeProject ?? null} parentThreadLink={parentThreadLink} onOpenThread={onOpenRelatedThreadStable} diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx index f70b4454873cc..3ca5160e2772a 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.tsx +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -24,7 +24,7 @@ import { RadioGroup } from "../ui/radio-group"; import { Spinner } from "../ui/spinner"; import { AuthSurfaceShell } from "./AuthSurfaceShell"; -const accessConfig: Record< +export const accessConfig: Record< AuthMcpClientAccess, { readonly label: string; readonly description: string; readonly icon: LucideIcon } > = { @@ -279,7 +279,7 @@ function ConnectAgentHeading({ ); } -function AccessOption({ +export function AccessOption({ access, selected, }: { diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 6add111820f57..116eed2c066d2 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -19,6 +19,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import { isTrailingDoubleClick } from "../Sidebar.logic"; +import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; @@ -45,6 +46,7 @@ interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; activeThreadId: ThreadId; activeThreadTitle: string; + activeThreadLinkOrigin?: string | null | undefined; isServerThread: boolean; activeProject: EnvironmentProject | null; parentThreadLink: { threadId: ThreadId; title: string } | null; @@ -70,6 +72,7 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeThreadId, activeThreadTitle, + activeThreadLinkOrigin, isServerThread, activeProject, parentThreadLink, @@ -406,6 +409,16 @@ export const ChatHeader = memo(function ChatHeader({ {activeThreadTitle} )} + {renamingTitle === null && activeThreadLinkOrigin ? ( + + }> + From {activeThreadLinkOrigin} + + + Started by an agent on {activeThreadLinkOrigin} through a linked environment. + + + ) : null} {isServerThread ? : null} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index daa240cbf68f6..06fba368d28f4 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -36,6 +36,8 @@ import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { LinkedEnvironmentsSettings } from "./LinkedEnvironmentsSettings"; +import { usePeerLinkHosts } from "~/state/peerLinks"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; import { Input } from "../ui/input"; @@ -156,6 +158,7 @@ export function ConnectionsSettings({ view }: { readonly view: "environments" | const desktopBridge = window.desktopBridge; const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const peerLinkHosts = usePeerLinkHosts(); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { reportFailure: false, @@ -1533,6 +1536,14 @@ export function ConnectionsSettings({ view }: { readonly view: "environments" | }} /> ) : null} + {peerLinkHosts.map((environment, index) => ( + 1 ? environment.label : null} + isSearchTarget={index === 0} + /> + ))} diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx new file mode 100644 index 0000000000000..c4804281f54c1 --- /dev/null +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -0,0 +1,283 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthMcpClientAccess, type EnvironmentId, type PeerLinkSummary } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks"; +import * as DateTime from "effect/DateTime"; +import { PlusIcon } from "lucide-react"; +import { useId, useState } from "react"; + +import { requestConfirmDialog } from "~/confirmDialog"; +import { peerLinkEnvironment } from "~/state/peerLinks"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { AccessOption, accessConfig } from "../auth/ConnectAgentSurface"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { RadioGroup } from "../ui/radio-group"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { searchableSetting } from "./settingsSearch"; + +const failureToast = (title: string, description: string) => + toastManager.add(stackedThreadToast({ type: "error", title, description })); + +export function LinkedEnvironmentsSettings({ + environmentId, + environmentLabel, + isSearchTarget, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string | null; + readonly isSearchTarget: boolean; +}) { + const links = useEnvironmentQuery(peerLinkEnvironment.list({ environmentId, input: {} })); + const canLink = useAtomValue(peerLinkEnvironment.link.permissionAtom(environmentId)); + const search = searchableSetting("linked-environments"); + return ( + : null} + > + {links.error ? ( + + ) : !links.data ? ( + + ) : links.data.links.length === 0 ? ( + + ) : ( + links.data.links.map((link) => ( + + )) + )} + + ); +} + +function LinkedEnvironmentRow({ + environmentId, + link, +}: { + readonly environmentId: EnvironmentId; + readonly link: PeerLinkSummary; +}) { + const nowMinute = useNowMinute(); + const health = peerLinkHealth(link, DateTime.makeUnsafe(nowMinute)); + const unlink = useAtomCommand(peerLinkEnvironment.unlink, { label: "unlink environment" }); + const canUnlink = useAtomValue(peerLinkEnvironment.unlink.permissionAtom(environmentId)); + const [busy, setBusy] = useState(false); + const forget = async () => { + const confirmed = await (requestConfirmDialog( + `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Connections to end the session there too.`, + { variant: "destructive" }, + ) ?? Promise.resolve(false)); + if (!confirmed) return; + setBusy(true); + const result = await unlink({ environmentId, input: { environmentId: link.environmentId } }); + setBusy(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Could not forget the link", String(squashAtomCommandFailure(result))); + } + }; + const status = + health.kind === "expired" + ? "Expired. Link it again with a new pairing code." + : health.kind === "unreachable" + ? (health.detail ?? "Not answering right now.") + : health.kind === "failing" + ? health.detail + : health.expiresInDays !== null + ? `Expires in ${health.expiresInDays} ${health.expiresInDays === 1 ? "day" : "days"}. Link it again to renew.` + : "Reachable"; + return ( +

+ ); +} + +function LinkEnvironmentAction({ environmentId }: { readonly environmentId: EnvironmentId }) { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(""); + const [pairingCode, setPairingCode] = useState(""); + const [access, setAccess] = useState("approval-required"); + const [busy, setBusy] = useState(false); + const formId = useId(); + const accessLabelId = `${formId}-access`; + const link = useAtomCommand(peerLinkEnvironment.link, { label: "link environment" }); + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) { + setUrl(""); + setPairingCode(""); + setAccess("approval-required"); + } + }; + const submit = async () => { + setBusy(true); + const result = await link({ + environmentId, + input: { url: url.trim(), pairingCode: pairingCode.trim(), access }, + }); + setBusy(false); + if (result._tag === "Success") { + toastManager.add({ type: "success", title: `Linked ${result.value.label}` }); + handleOpenChange(false); + } else if (!isAtomCommandInterrupted(result)) { + failureToast("Could not link the environment", String(squashAtomCommandFailure(result))); + } + }; + return ( + { + if (!next && busy) return; + handleOpenChange(next); + }} + > + + + Link + + } + /> + + + Link an environment + + Agents here can then launch, message and wait on threads there. It appears in that + environment's Connections, where it can be revoked. + + + +
{ + event.preventDefault(); + void submit(); + }} + > + + +
+ + What agents here may do there + + setAccess(value as AuthMcpClientAccess)} + > + {AuthMcpClientAccess.literals.map((option) => ( + + ))} + +

+ An agent never gets more access there than its own mode here. Below full access, it + can change only threads this link started, not that environment's own threads, + projects or settings. +

+
+
+
+ + }> + Cancel + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index f10dfbd738c51..9f5fc6ac4adca 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -122,10 +122,37 @@ describe("searchSettings", () => { "auto-settle-inactive-threads", "auto-settle-merged-threads", "days-before-auto-settle", + "linked-environments", ]); expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("finds linked environments on a remote host without local maintenance access", () => { + const availability = { + hasCloudPublicConfig: false, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + hasMacProviderSettingsEnvironment: false, + canManageLocalBackend: false, + localEnvironmentDisabled: true, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, + }; + const results = (hasPeerLinkHost: boolean) => + searchSettings( + "linked environments", + filterAvailableSettingsSearchItems({ ...availability, hasPeerLinkHost }), + ); + expect(results(false).map((item) => item.id)).not.toContain("linked-environments"); + expect(results(true)).toContainEqual( + expect.objectContaining({ + id: "linked-environments", + to: "/settings/connections", + targetId: "linked-environments", + }), + ); + }); + it("finds keybinding commands by label, command id, and default key", () => { expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle"); expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle"); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5840566dc1ca1..67318a1ee7c30 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -65,6 +65,7 @@ export interface SettingsSearchItem { readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; readonly managedTunnelOnly?: boolean; + readonly peerLinksOnly?: boolean; readonly secondary?: boolean; readonly requiresThreadAutoSettlement?: boolean; } @@ -79,6 +80,7 @@ export interface SettingsSearchAvailability { readonly isWslSettingsRowVisible: boolean; readonly hasThreadAutoSettlement: boolean; readonly managedTunnelActive?: boolean; + readonly hasPeerLinkHost?: boolean; } export const SETTINGS_SECTION_LABELS: Readonly> = { @@ -1072,6 +1074,14 @@ export const SETTINGS_SEARCH_ITEMS = [ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint", ], }, + { + id: "linked-environments", + peerLinksOnly: true, + title: "Linked environments", + to: "/settings/connections", + targetId: "linked-environments", + searchTerms: ["link peer another machine vps agents work there handoff pairing code"], + }, { id: "remote-environments", title: "Environments", @@ -1260,7 +1270,8 @@ export function filterAvailableSettingsSearchItems( (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement) && - (!item.managedTunnelOnly || availability.managedTunnelActive === true), + (!item.managedTunnelOnly || availability.managedTunnelActive === true) && + (!item.peerLinksOnly || availability.hasPeerLinkHost === true), ); } diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index 20c5703d342b3..712c4d87d7021 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -6,6 +6,7 @@ import { isElectron } from "~/env"; import { isLocalEnvironmentDisabled } from "~/localEnvironment"; import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { usePeerLinkHosts } from "~/state/peerLinks"; import { useEnvironmentQuery } from "~/state/query"; import { useEnvironmentScope } from "~/state/session"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; @@ -42,6 +43,7 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0; const primaryEnvironmentId = usePrimaryEnvironmentId(); const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); + const hasPeerLinkHost = usePeerLinkHosts().length > 0; const cloudLinkState = usePrimaryCloudLinkState().data; // Same fallback as the Connections row: older servers imply a tunnel from `linked`. const managedTunnelActive = @@ -66,5 +68,6 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch isWslSettingsRowVisible: wslSettingsRowVisible, hasThreadAutoSettlement, managedTunnelActive, + hasPeerLinkHost, }); } diff --git a/apps/web/src/state/peerLinks.ts b/apps/web/src/state/peerLinks.ts new file mode 100644 index 0000000000000..c3b6a16d12424 --- /dev/null +++ b/apps/web/src/state/peerLinks.ts @@ -0,0 +1,19 @@ +import { createPeerLinkEnvironmentAtoms } from "@t3tools/client-runtime/state/peerLinks"; +import { AuthAccessReadScope } from "@t3tools/contracts"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { useEnvironments } from "./environments"; +import { useEnvironmentsWithScope } from "./session"; + +export const peerLinkEnvironment = createPeerLinkEnvironmentAtoms(connectionAtomRuntime); + +export function usePeerLinkHosts() { + const { environments } = useEnvironments(); + const supported = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.peerLinks === true, + ); + const readable = useEnvironmentsWithScope(supported, AuthAccessReadScope); + return supported.filter((environment) => readable.has(environment.environmentId)); +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 4b9acbeecaff3..6d6bc234290d0 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -247,6 +247,28 @@ Claude Code, Codex, ChatGPT and other agents T3 Code did not start can drive threads on an environment through its MCP server. See [outside agents](./outside-agents.md) for setup. +## Link another environment + +Link two machines, such as your laptop and a VPS, and agents on one can work on +the other: launch, message, wait on and stop its threads, answer their +questions, and manage its queues, projects and scheduled tasks. On the machine +you will work from, open **Settings → Connections → Linked environments**, +choose **Link**, and enter the other machine's address and a pairing code from +it. From a terminal, use +`t3 environment link
--pairing-code --access `. + +- The access you pick is the most agents from this machine may do there. An + agent also never gets more there than its own mode here. +- Threads a link starts are marked **From** and this machine's name there. +- A full-access link works there like that machine's own agents. Below full + access, the link's agents can change only threads it started, not that + machine's own threads, projects or settings, and they skip its project setup + scripts. +- The link shows up in that machine's **Settings → Connections** like any + outside agent. Revoke it there to end it. **Forget** on this machine only + removes it here. +- Links last 30 days. Link again with a new pairing code to renew one. + ## Manage or revoke access On the host, **Settings → Sharing & access** lets authorized administrators create diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 9719f59360785..949ef95e0fb46 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -207,6 +207,10 @@ "types": "./src/state/deviceHubAccess.ts", "default": "./src/state/deviceHubAccess.ts" }, + "./state/peerLinks": { + "types": "./src/state/peerLinks.ts", + "default": "./src/state/peerLinks.ts" + }, "./state/preview": { "types": "./src/state/preview.ts", "default": "./src/state/preview.ts" diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 09927e38398a6..86fa956365389 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -7,6 +7,8 @@ import type { RpcSession } from "../rpc/session.ts"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { + AuthAccessReadScope, + AuthAccessWriteScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -17,8 +19,10 @@ import { ScheduledTaskId, WS_METHODS, type AuthSessionState, + type PeerLinkSummary, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -26,6 +30,8 @@ import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { createCommandPermissions } from "./commandPermissions.ts"; import { createEnvironmentRpcCommand } from "./runtime.ts"; +import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; +import { createPeerLinkEnvironmentAtoms } from "./peerLinks.ts"; vi.mock("./session.ts", () => ({ createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }), @@ -291,6 +297,132 @@ it.effect.each(["commit", "push", "commit_push", "create_pr", "commit_push_pr"] ), ); +it.effect( + "refreshes linked environments after permitted writes and blocks a read-only session", + () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const peer: PeerLinkSummary = { + environmentId: EnvironmentId.make("peer"), + label: "Box", + urls: ["https://box.example.test"], + access: "auto", + linkedAt: DateTime.makeUnsafe("2026-10-01T12:00:00Z"), + expiresAt: DateTime.makeUnsafe("2026-10-31T12:00:00Z"), + lastReachedAt: null, + lastError: null, + status: "reachable", + }; + let links: ReadonlyArray = []; + const client = { + [WS_METHODS.peerLinksList]: () => Effect.sync(() => ({ links })), + [WS_METHODS.peerLinksLink]: vi.fn(() => + Effect.sync(() => { + links = [peer]; + return peer; + }), + ), + [WS_METHODS.peerLinksUnlink]: vi.fn(() => + Effect.sync(() => { + links = []; + return { removed: true }; + }), + ), + }; + const supervisor = { + target: { environmentId: env }, + session: yield* SubscriptionRef.make(Option.some({ client } as unknown as RpcSession)), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected" as const, + }), + } as EnvironmentSupervisor["Service"]; + const peerRuntime = Atom.runtime( + Layer.succeed(EnvironmentRegistry, { + run: (id, effect) => { + expect(id).toBe(env); + return Effect.provideService(effect, EnvironmentSupervisor, supervisor); + }, + followStream: (id, stream) => { + expect(id).toBe(env); + return Stream.provideService(stream, EnvironmentSupervisor, supervisor); + }, + } as EnvironmentRegistry["Service"]), + ); + const peerLinks = createPeerLinkEnvironmentAtoms(peerRuntime); + const list = peerLinks.list({ environmentId: env, input: {} }); + registry.mount(list); + const readLinks = AtomRegistry.getResult(registry, list, { suspendOnWaiting: true }); + const setAccess = (writable: boolean) => + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthAccessReadScope, ...(writable ? [AuthAccessWriteScope] : [])], + permissions: [AuthAccessReadScope, ...(writable ? [AuthAccessWriteScope] : [])], + }), + ); + registry.mount(sessions(other)); + registry.set( + sessions(other), + AsyncResult.success({ + ...grant(false), + scopes: [AuthAccessWriteScope], + permissions: [AuthAccessWriteScope], + }), + ); + setAccess(false); + expect(yield* readLinks).toEqual({ links: [] }); + const linkInput = { url: peer.urls[0]!, pairingCode: "test-code", access: peer.access }; + const link = Effect.promise(() => + peerLinks.link.run(registry, { environmentId: env, input: linkInput }), + ); + const unlink = Effect.promise(() => + peerLinks.unlink.run(registry, { + environmentId: env, + input: { environmentId: peer.environmentId }, + }), + ); + expect(registry.get(peerLinks.link.permissionAtom(env))).toBe(false); + const deniedLink = yield* link; + expect(deniedLink._tag).toBe("Failure"); + if (deniedLink._tag === "Failure") { + expect(Cause.squash(deniedLink.cause)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredPermission: AuthAccessWriteScope, + }); + } + expect(client[WS_METHODS.peerLinksLink]).not.toHaveBeenCalled(); + setAccess(true); + expect(registry.get(peerLinks.link.permissionAtom(env))).toBe(true); + expect((yield* link)._tag).toBe("Success"); + expect(client[WS_METHODS.peerLinksLink]).toHaveBeenCalledExactlyOnceWith(linkInput); + expect(yield* readLinks).toEqual({ links: [peer] }); + + setAccess(false); + expect(registry.get(peerLinks.unlink.permissionAtom(env))).toBe(false); + const deniedUnlink = yield* unlink; + expect(deniedUnlink._tag).toBe("Failure"); + if (deniedUnlink._tag === "Failure") { + expect(Cause.squash(deniedUnlink.cause)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredPermission: AuthAccessWriteScope, + }); + } + expect(client[WS_METHODS.peerLinksUnlink]).not.toHaveBeenCalled(); + expect(yield* readLinks).toEqual({ links: [peer] }); + setAccess(true); + expect(registry.get(peerLinks.unlink.permissionAtom(env))).toBe(true); + expect((yield* unlink)._tag).toBe("Success"); + expect(client[WS_METHODS.peerLinksUnlink]).toHaveBeenCalledExactlyOnceWith({ + environmentId: peer.environmentId, + }); + expect(yield* readLinks).toEqual({ links: [] }); + }), + ), +); + it.effect("honors exact empty permissions and preserves legacy parent grants", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index d772989f23edc..9dc72dc493027 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -147,6 +147,7 @@ export interface EnvironmentThreadShell { readonly worktreePath: string | null; readonly lineage: OrchestrationV2ThreadShell["lineage"]; readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"]; + readonly linkOrigin: OrchestrationV2ThreadShell["linkOrigin"] | null; readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"]; readonly latestRun: ThreadRunSummary | null; readonly runtime: ThreadRuntimeSummary | null; @@ -298,6 +299,7 @@ export function presentThreadShell( branchPullRequest: thread.branchPullRequest ?? null, lineage: thread.lineage, forkedFrom: thread.forkedFrom, + linkOrigin: thread.linkOrigin ?? null, activeProviderThreadId: thread.activeProviderThreadId, latestRun, runtime, diff --git a/packages/client-runtime/src/state/peerLinks.ts b/packages/client-runtime/src/state/peerLinks.ts new file mode 100644 index 0000000000000..6e65f62eeb748 --- /dev/null +++ b/packages/client-runtime/src/state/peerLinks.ts @@ -0,0 +1,57 @@ +import { type PeerLinkSummary, WS_METHODS } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +export function createPeerLinkEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const list = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:peer-links:list", + tag: WS_METHODS.peerLinksList, + // Listing probes every linked environment, so a list stays fresh briefly. + staleTimeMs: 10_000, + }); + return { + list, + link: createEnvironmentRpcCommand(runtime, { + label: "environment-data:peer-links:link", + tag: WS_METHODS.peerLinksLink, + onSuccess: ({ environmentId }, registry) => + Effect.sync(() => registry.refresh(list({ environmentId, input: {} }))), + }), + unlink: createEnvironmentRpcCommand(runtime, { + label: "environment-data:peer-links:unlink", + tag: WS_METHODS.peerLinksUnlink, + onSuccess: ({ environmentId }, registry) => + Effect.sync(() => registry.refresh(list({ environmentId, input: {} }))), + }), + }; +} + +const EXPIRY_WARNING_DAYS = 5; + +export type PeerLinkHealth = + | { readonly kind: "reachable"; readonly expiresInDays: number | null } + | { readonly kind: "unreachable"; readonly detail: string | null } + | { readonly kind: "failing"; readonly detail: string } + | { readonly kind: "expired" }; + +/** + * Listing only checks that the peer answers, so a link whose last call failed + * (the peer revoked it, say) is `failing` with the recorded reason; the next + * successful call clears it. + */ +export function peerLinkHealth(link: PeerLinkSummary, now: DateTime.Utc): PeerLinkHealth { + if (link.status === "expired" || DateTime.isLessThanOrEqualTo(link.expiresAt, now)) { + return { kind: "expired" }; + } + if (link.status === "unreachable") return { kind: "unreachable", detail: link.lastError }; + if (link.lastError !== null) return { kind: "failing", detail: link.lastError }; + const days = Math.ceil(Duration.toDays(DateTime.distance(now, link.expiresAt))); + return { kind: "reachable", expiresInDays: days <= EXPIRY_WARNING_DAYS ? days : null }; +} diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 3fda40a04a05d..5ff35ad13341e 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -52,6 +52,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, // A guest link hands thread access to someone outside, like a pairing link. [WS_METHODS.guestShareCreate]: AuthAccessWriteScope, + [WS_METHODS.peerLinksLink]: AuthAccessWriteScope, + [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope, } as const; export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; From 594f1ce2eb3f1a2f41e71476a83b1522c5b65dd9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:26 -0700 Subject: [PATCH 7/9] feat(server): delegate_task to a linked environment (#16719) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Lenori <122464369+Lenoridev@users.noreply.github.com> --- .../home/useThreadListActions.test.ts | 5 +- .../src/features/home/useThreadListActions.ts | 2 +- .../src/features/threads/SubagentRow.tsx | 13 +- .../features/threads/ThreadAgentsSheet.tsx | 30 +- .../features/threads/ThreadRouteScreen.tsx | 16 +- .../threads/thread-subagent-group.tsx | 39 +- .../threads/threadAgentsPresentation.ts | 30 +- apps/mobile/src/state/thread-order.test.ts | 6 +- apps/mobile/src/state/thread-order.ts | 9 +- apps/server/src/mcp/McpToolAccess.ts | 2 +- .../OrchestratorMcpService.activity.test.ts | 4 + .../src/mcp/OrchestratorMcpService.test.ts | 43 +- apps/server/src/mcp/OrchestratorMcpService.ts | 327 ++-- ...OrchestratorMcpToolkit.integration.test.ts | 34 +- ...skCancelNativeSubagent.integration.test.ts | 2 + .../src/mcp/ThreadMetadataMcpService.ts | 2 +- apps/server/src/mcp/delegatedTaskModes.ts | 95 + apps/server/src/mcp/linkOrigin.test.ts | 21 +- apps/server/src/mcp/threadAccess.ts | 57 +- apps/server/src/mcp/toolkits/core.test.ts | 5 + .../src/mcp/toolkits/project/handlers.ts | 13 +- apps/server/src/mcp/toolkits/project/tools.ts | 3 + .../DelegatedCompletionDelivery.test.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 402 +++- .../orchestration-v2/ProjectionStore.test.ts | 51 + .../src/orchestration-v2/ProjectionStore.ts | 52 +- .../ProviderRuntimeRecoveryService.ts | 10 +- .../ProviderTurnControlService.test.ts | 1 + .../RemoteDelegatedTask.test.ts | 411 ++++ .../ThreadLaunchService.test.ts | 3 + .../orchestration-v2/ThreadLaunchService.ts | 6 + .../ThreadManagementService.test.ts | 156 +- .../ThreadManagementService.ts | 134 +- .../src/orchestration-v2/runtimeLayer.test.ts | 1 + apps/server/src/peer/PeerForwarding.test.ts | 19 +- apps/server/src/peer/PeerForwarding.ts | 57 +- apps/server/src/peer/PeerLinks.testkit.ts | 16 +- apps/server/src/peer/RemoteDelegation.test.ts | 1697 +++++++++++++++++ apps/server/src/peer/RemoteDelegation.ts | 801 ++++++++ .../src/project/ProjectEnrichmentService.ts | 10 + apps/server/src/project/ProjectService.ts | 29 + .../src/provider/AgentContextBudget.test.ts | 2 + .../src/provider/agentContextBudget.json | 2 +- .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/server.ts | 7 + apps/web/src/components/ChatView.tsx | 53 +- apps/web/src/components/LegacySidebar.tsx | 18 +- apps/web/src/components/Sidebar.logic.test.ts | 6 +- apps/web/src/components/Sidebar.logic.ts | 26 +- apps/web/src/components/Sidebar.tsx | 20 +- ...ificationCoordinator.badge.browser.test.ts | 15 + .../ThreadNotificationCoordinator.tsx | 35 +- .../src/components/chat/MessagesTimeline.tsx | 16 +- .../chat/ThreadRelationshipsControl.tsx | 93 +- .../src/components/chat/V2LifecycleRow.tsx | 36 +- .../web/src/components/sidebar/routeReveal.ts | 8 +- apps/web/src/hooks/useSidebarProjectGroups.ts | 5 +- apps/web/src/state/entities.ts | 5 + docs/internals/remote.md | 9 + docs/user/remote-access.md | 7 + evals/mcp-tool-use/run.test.ts | 2 +- evals/mcp-tool-use/run.ts | 2 +- packages/client-runtime/src/state/models.ts | 3 + .../src/state/threadShell.test.ts | 146 ++ .../client-runtime/src/state/threadShell.ts | 221 ++- packages/contracts/src/orchestrationV2.ts | 98 + packages/contracts/src/orchestratorMcp.ts | 27 +- 67 files changed, 5156 insertions(+), 322 deletions(-) create mode 100644 apps/server/src/mcp/delegatedTaskModes.ts create mode 100644 apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts create mode 100644 apps/server/src/peer/RemoteDelegation.test.ts create mode 100644 apps/server/src/peer/RemoteDelegation.ts diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9a59367a9543a..ec0f6a14558dd 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -114,7 +114,10 @@ vi.mock("../../state/thread-order", () => ({ }, })); vi.mock("../../state/threads", () => ({ - environmentThreadShells: { threadShellsAtom: "thread-shells" }, + environmentThreadShells: { + threadShellsAtom: "thread-shells", + navigationThreadShellsAtom: "thread-shells", + }, threadEnvironment: Object.fromEntries( [ "archive", diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index ddbbf9ff343c5..3ba684bdff174 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -611,7 +611,7 @@ export function useThreadListActions(): { const moveThread = async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; - const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const shells = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom); const current = shells.find( (row) => row.id === thread.id && row.environmentId === thread.environmentId, ); diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx index bd785f20e8cdd..a888adb8273fb 100644 --- a/apps/mobile/src/features/threads/SubagentRow.tsx +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -26,14 +26,17 @@ type SubagentRowSubagent = Pick< | "status" | "progress" | "result" + | "remoteChild" >; export function SubagentRow(props: { readonly environmentId: EnvironmentId; readonly subagent: SubagentRowSubagent; readonly elapsed: ReactNode; + /** The task runs in a linked environment this app is connected to. */ + readonly canOpenRemote?: boolean; }) { - const presentation = resolveSubagentRowPresentation(props.subagent); + const presentation = resolveSubagentRowPresentation(props.subagent, props.canOpenRemote); const detail = subagentCardDetail(presentation.detail); return ( @@ -64,6 +67,11 @@ export function SubagentRow(props: { > {presentation.statusLabel} + {presentation.runsOn !== null ? ( + + on {presentation.runsOn} + + ) : null} {props.elapsed} {presentation.canOpenThread ? ( @@ -92,7 +100,8 @@ function SubagentMetadata(props: { readonly subagent: SubagentRowSubagent; }) { const { environmentId, subagent } = props; - const config = useEnvironmentServerConfig(environmentId); + // A task in a linked environment runs a provider instance of that environment. + const config = useEnvironmentServerConfig(subagent.remoteChild?.environmentId ?? environmentId); const provider = config?.providers.find( (candidate) => candidate.instanceId === subagent.providerInstanceId, ); diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index 4ebe077a3a66e..5c0a4d53b6a79 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -20,10 +20,12 @@ import { AppText as Text } from "../../components/ui/AppText"; import { environmentThreadDetails } from "../../state/threads"; import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack"; +import { useEnvironments } from "../../state/environments"; import { SubagentRow } from "./SubagentRow"; import { useVisibleSecondClock } from "./use-visible-second-clock"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { subagentThreadTarget } from "./threadAgentsPresentation"; import { useOpenThreadInspector } from "./thread-inspector-navigation"; @@ -53,21 +55,21 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps) { const subagents = turn?.subagents ?? []; const hasLiveAgent = (turn?.liveCount ?? 0) > 0; - const openChildThread = (childThreadId: ThreadId) => { + const openChildThread = (child: { environmentId: EnvironmentId; threadId: ThreadId }) => { void Haptics.selectionAsync(); if ( openInspector(target, { type: "thread", - environmentId: target.environmentId, - threadId: childThreadId, + environmentId: child.environmentId, + threadId: child.threadId, }) ) return; // Replace this leaf sheet so the child enters the workspace stack expected by Home's Back button. navigation.dispatch( StackActions.replace("Thread", { - environmentId: target.environmentId, - threadId: childThreadId, + environmentId: child.environmentId, + threadId: child.threadId, }), ); }; @@ -143,26 +145,34 @@ function AgentRow(props: { readonly environmentId: EnvironmentId; readonly subagent: OrchestrationV2Subagent; readonly tickSeconds: boolean; - readonly onOpen: (childThreadId: ThreadId) => void; + readonly onOpen: (child: { environmentId: EnvironmentId; threadId: ThreadId }) => void; }) { const { subagent } = props; - const childThreadId = subagent.childThreadId; + const { presentationById } = useEnvironments(); + const target = subagentThreadTarget(subagent, props.environmentId, (id) => + presentationById.has(id), + ); const row = ( } /> ); - if (childThreadId === null) { + if (target === null) { return ( {row} @@ -173,7 +183,7 @@ function AgentRow(props: { props.onOpen(childThreadId)} + onPress={() => props.onOpen(target)} className="active:opacity-70" > {row} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 2233a30441a51..1559adb60e1f5 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -43,6 +43,8 @@ import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/ui/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useThreadShell } from "../../state/entities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { connectionTone } from "../connection/connectionTone"; import { @@ -486,10 +488,22 @@ function ThreadRouteContent( : null; const usesNativeHeaderGlass = NATIVE_LIQUID_GLASS_SUPPORTED; + const delegatedFrom = selectedThread?.delegatedFrom ?? null; + const delegatedFromRef = + delegatedFrom === null + ? null + : scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId); + // The title it had at delegation, until this client sees the parent itself. + const delegatedFromThread = useThreadShell(delegatedFromRef); const headerSubtitle = [ selectedThreadProject?.title ?? null, selectedEnvironmentConnection?.environmentLabel ?? null, - selectedThread?.linkOrigin ? `From ${selectedThread.linkOrigin.label}` : null, + // A linked environment's delegated task names its parent; other linked work its origin. + delegatedFrom !== null + ? `Subagent of ${delegatedFromThread?.title ?? delegatedFrom.title}` + : selectedThread?.linkOrigin + ? `From ${selectedThread.linkOrigin.label}` + : null, ] .filter(Boolean) .join(" · "); diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index aab3d3c7345ad..f83b85de0b8e2 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -18,8 +18,11 @@ import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { serverEnvironment } from "../../state/server"; import { environmentThreadDetails } from "../../state/threads"; import { subagentCardElapsed } from "./subagent-card-presentation"; +import { useEnvironments } from "../../state/environments"; +import { useServerConfigs } from "../../state/entities"; import { SubagentRow } from "./SubagentRow"; import { useVisibleSecondClock } from "./use-visible-second-clock"; +import { subagentThreadTarget } from "./threadAgentsPresentation"; import { WorkLogBlock } from "./work-log-layout"; import { useOpenThreadInspector } from "./thread-inspector-navigation"; @@ -68,6 +71,7 @@ export function ThreadSubagentGroup(props: { environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, members[0]!.threadId)), (thread) => thread?.projection.subagents, ); + const { presentationById } = useEnvironments(); const agents = members.map((item) => { const live = liveAgents?.find((agent) => agent.id === item.subagentId); return { @@ -85,8 +89,13 @@ export function ThreadSubagentGroup(props: { const label = `${agents.length} subagents`; const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status)); const expanded = props.expandedRows[props.anchorKey] ?? false; + const serverConfigs = useServerConfigs(); + // A task in a linked environment runs a provider instance of that environment. const iconUrl = (item: SubagentItem) => - config?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl; + (item.remoteChild === undefined + ? config + : serverConfigs.get(item.remoteChild.environmentId) + )?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl; return ( {grouped ? ( @@ -137,29 +146,40 @@ export function ThreadSubagentGroup(props: { {!grouped || expanded ? ( {agents.map((agent) => { - const threadId = agent.childThreadId; + // A task in a linked environment opens there, if this app is connected to it. + const target = subagentThreadTarget(agent, props.environmentId, (id) => + presentationById.has(id), + ); return ( { - if (threadId === null) return; + if (target === null) return; if ( openInspector( { environmentId: props.environmentId, threadId: members[0]!.threadId }, - { type: "thread", environmentId: props.environmentId, threadId }, + { + type: "thread", + environmentId: target.environmentId, + threadId: target.threadId, + }, ) ) return; navigation.dispatch( StackActions.push("Thread", { - environmentId: String(props.environmentId), - threadId: String(threadId), + environmentId: String(target.environmentId), + threadId: String(target.threadId), }), ); }} @@ -168,6 +188,7 @@ export function ThreadSubagentGroup(props: { } /> diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index 96981ab3a38aa..3bf82e35c403a 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -3,7 +3,7 @@ import { subagentDetailPreview, } from "@t3tools/client-runtime/state/subagent-display"; import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime"; -import type { OrchestrationV2Subagent } from "@t3tools/contracts"; +import type { EnvironmentId, OrchestrationV2Subagent, ThreadId } from "@t3tools/contracts"; const PROMPT_TITLE_LIMIT = 80; @@ -16,6 +16,27 @@ export interface SubagentRowPresentation { readonly tone: SubagentRowTone; readonly live: boolean; readonly canOpenThread: boolean; + /** The linked environment the task runs in, when it runs in one. */ + readonly runsOn: string | null; +} + +/** + * Where an agent's thread opens: here, or in the linked environment that runs + * it. Null when there is no thread, or this app is not connected to that + * environment. + */ +export function subagentThreadTarget( + subagent: Pick, + environmentId: EnvironmentId, + isKnownEnvironment: (environmentId: EnvironmentId) => boolean, +): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { + if (subagent.childThreadId !== null) { + return { environmentId, threadId: subagent.childThreadId }; + } + const remote = subagent.remoteChild; + return remote !== undefined && isKnownEnvironment(remote.environmentId) + ? { environmentId: remote.environmentId, threadId: remote.threadId } + : null; } function rowTitle(subagent: Pick): string { @@ -58,8 +79,9 @@ function rowStatusLabel(status: OrchestrationV2Subagent["status"]): string { export function resolveSubagentRowPresentation( subagent: Pick< OrchestrationV2Subagent, - "title" | "prompt" | "status" | "progress" | "result" | "childThreadId" + "title" | "prompt" | "status" | "progress" | "result" | "childThreadId" | "remoteChild" >, + canOpenRemote = false, ): SubagentRowPresentation { const live = isActiveSubagentStatus(subagent.status); return { @@ -68,6 +90,8 @@ export function resolveSubagentRowPresentation( statusLabel: rowStatusLabel(subagent.status), tone: rowTone(subagent.status), live, - canOpenThread: subagent.childThreadId !== null, + canOpenThread: + subagent.childThreadId !== null || (subagent.remoteChild !== undefined && canOpenRemote), + runsOn: subagent.remoteChild?.label ?? null, }; } diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts index 6c8bec1454b9b..032744c75d641 100644 --- a/apps/mobile/src/state/thread-order.test.ts +++ b/apps/mobile/src/state/thread-order.test.ts @@ -20,7 +20,9 @@ vi.mock("./atom-registry", async () => { }); vi.mock("./threads", async () => { const { Atom } = await import("effect/reactivity"); - return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } }; + return { + environmentThreadShells: { navigationThreadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) }, + }; }); vi.mock("./server", async () => { const { Atom } = await import("effect/reactivity"); @@ -34,7 +36,7 @@ vi.mock("./use-thread-outbox", async () => { type ServerConfigs = Atom.Type; type ServerConfig = ServerConfigs extends ReadonlyMap ? Config : never; const configsAtom = environmentServerConfigsAtom as Atom.Writable; -const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< +const shellsAtom = environmentThreadShells.navigationThreadShellsAtom as Atom.Writable< readonly EnvironmentThreadShell[], readonly EnvironmentThreadShell[] >; diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts index 730797dac1b88..a6505b61c8a3b 100644 --- a/apps/mobile/src/state/thread-order.ts +++ b/apps/mobile/src/state/thread-order.ts @@ -64,7 +64,7 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { const current = appAtomRegistry.get(pendingThreadOrderAtom); if (current === null) return; const configs = appAtomRegistry.get(environmentServerConfigsAtom); - const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const threads = appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom); const nowMs = Date.now(); const ordered = getThreadListV2OrderedSection({ threads, @@ -95,13 +95,16 @@ export function beginPendingThreadOrder(pending: PendingThreadOrder) { teardownPendingOrder = teardown; appAtomRegistry.set(pendingThreadOrderAtom, pending); unsubscribers.push( - appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), + appAtomRegistry.subscribe(environmentThreadShells.navigationThreadShellsAtom, refresh), appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), appAtomRegistry.subscribe(queuedThreadKeysAtom, refresh), minuteTick.subscribe(refresh), ); armWake( - nextSnoozeWakeAfter(appAtomRegistry.get(environmentThreadShells.threadShellsAtom), Date.now()), + nextSnoozeWakeAfter( + appAtomRegistry.get(environmentThreadShells.navigationThreadShellsAtom), + Date.now(), + ), ); return { isPending: () => { diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 7e6821e51399d..a36b58a2b2d38 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -23,7 +23,7 @@ import { import * as PeerForwarding from "../peer/PeerForwarding.ts"; import { assertNotLinked, assertSameLink, callerLinkOrigin } from "./linkOrigin.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; -import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts"; +import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts"; import { assertFullAccess, assertLiveCaller, diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index d7f9746f76ea5..45561e7670411 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -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"; +import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -161,6 +162,7 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a } satisfies Partial), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), @@ -276,6 +278,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy } satisfies Partial), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), @@ -403,6 +406,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { } satisfies Partial), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index e3f3063d59592..ead5229f86502 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -57,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"; +import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; 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); @@ -165,6 +167,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -265,6 +268,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -365,6 +369,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -457,6 +462,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -553,6 +559,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -644,6 +651,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -729,6 +737,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -815,6 +824,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -948,6 +958,7 @@ describe("OrchestratorMcpService", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); const scope: McpInvocationScope = { @@ -1189,6 +1200,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1365,6 +1377,7 @@ describe("OrchestratorMcpService provider resolution", () => { adapterRegistryLayer([codexInstanceId, opencodeInstanceId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), ), @@ -1469,6 +1482,7 @@ describe("OrchestratorMcpService provider resolution", () => { adapterRegistryLayer([codexInstanceId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -1581,6 +1595,7 @@ describe("OrchestratorMcpService provider resolution", () => { adapterRegistryLayer([codexInstanceId, altInstanceId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ServerSettings.layerTest(), ); @@ -1689,6 +1704,7 @@ describe("OrchestratorMcpService provider resolution", () => { adapterRegistryLayer([codexInstanceId, altInstanceId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ServerSettings.layerTest(), ); @@ -1784,6 +1800,7 @@ describe("OrchestratorMcpService provider resolution", () => { ]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), ), @@ -2040,6 +2057,7 @@ describe("OrchestratorMcpService provider resolution", () => { ), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); @@ -2204,6 +2222,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks }), upsert: () => @@ -2274,6 +2293,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [task({})] }), }), @@ -2362,6 +2382,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({ list: () => Effect.succeed({ tasks: [bound] }), upsert: () => @@ -2438,6 +2459,7 @@ describe("OrchestratorMcpService provider resolution", () => { adapterRegistryLayer([]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); yield* Effect.gen(function* () { @@ -2547,6 +2569,7 @@ describe("OrchestratorMcpService provider resolution", () => { adapterRegistryLayer([codexInstanceId, peerId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ); yield* Effect.gen(function* () { @@ -2577,11 +2600,9 @@ 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"); }), @@ -2589,14 +2610,8 @@ describe("OrchestratorMcpService provider resolution", () => { 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"); }), ); }); @@ -2631,6 +2646,7 @@ describe("OrchestratorMcpService thread lookup", () => { }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), ), @@ -2793,6 +2809,7 @@ describe("OrchestratorMcpService thread stop", () => { Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), ), diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f2ce8c8bb1c8a..1721bc950cbd4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -22,13 +22,11 @@ import { type OrchestratorMcpCreatedThread, type OrchestratorMcpDelegateTaskInput, type OrchestratorMcpDelegateTaskResult, - type OrchestratorMcpInteractionMode, type OrchestratorMcpDeleteScheduledTaskInput, type OrchestratorMcpRequestSecretInput, type OrchestratorMcpRequestSecretResult, type OrchestratorMcpDeleteScheduledTaskResult, type OrchestratorMcpListScheduledTasksResult, - type OrchestratorMcpRuntimeMode, type OrchestratorMcpScheduledTask, type OrchestratorMcpScheduleTaskInput, type OrchestratorMcpScheduleTaskResult, @@ -54,7 +52,6 @@ import { type OrchestratorMcpThreadWaitInput, type OrchestratorMcpThreadWaitResult, type ProviderInteractionMode, - type ProviderInstanceId, type ProviderOptionDescriptor, type ProviderOptionSelection, ProviderRequestKind, @@ -112,7 +109,17 @@ import { requireThreadScope, } from "./McpInvocationContext.ts"; import * as Metrics from "../observability/Metrics.ts"; +import { + interactionModeRank, + resolveInteractionMode, + resolveRuntimeMode, + runtimeModeRank, + taskPrompt, +} from "./delegatedTaskModes.ts"; +import { isLiveCallerShell, 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"; const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; @@ -214,43 +221,6 @@ function failure(code: OrchestratorMcpFailure["code"], message: string): Orchest return new OrchestratorMcpFailure({ code, message }); } -/** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ -export const isLiveCallerShell = ( - shell: Pick< - OrchestrationV2ThreadShell, - "archivedAt" | "activeRunId" | "providerInstanceId" | "pendingBackgroundTasks" - > | null, - providerInstanceId: ProviderInstanceId | undefined, -) => - shell !== null && - shell.archivedAt === null && - shell.providerInstanceId === providerInstanceId && - (shell.activeRunId !== null || - (shell.pendingBackgroundTasks?.some( - (task) => task.kind === "subagent" && task.providerInstanceId === providerInstanceId, - ) ?? - false)); - -/** - * Threads an agent starts over MCP are `mcp:`, and agents often paste the - * bare uuid. The id is never rewritten, so permission checks keyed on it still - * hold; the miss only names a live thread the caller could read by its full id. - */ -export const threadNotFound = Effect.fn("OrchestratorMcpService.threadNotFound")(function* ( - threads: Pick, - threadId: ThreadId, - message: string, -) { - const prefixed = ThreadId.make(`mcp:${threadId}`); - const shell = yield* threads.getThreadShell(prefixed).pipe(Effect.orElseSucceed(() => null)); - return yield* failure( - "thread_not_found", - shell === null || shell.deletedAt !== null - ? message - : `${message} Did you mean \`${prefixed}\`?`, - ); -}); - function threadManagementFailure(error: unknown): OrchestratorMcpFailure { if (!isThreadManagementError(error)) return failure("orchestration_error", errorMessage(error)); switch (error._tag) { @@ -661,73 +631,6 @@ function canExposeTaskRunResult(run: OrchestrationV2Run | undefined): run is Orc ); } -function runtimeModeRank(mode: RuntimeMode): number { - switch (mode) { - case "approval-required": - return 0; - case "auto-accept-edits": - return 1; - case "auto": - return 2; - case "full-access": - return 3; - } -} - -function interactionModeRank(mode: ProviderInteractionMode): number { - return mode === "plan" ? 0 : 1; -} - -export function resolveRuntimeMode( - parentMode: RuntimeMode, - requested: OrchestratorMcpRuntimeMode | undefined, - target?: Pick, -): Effect.Effect { - const inherited = requested === undefined || requested === "inherit"; - const resolved = inherited ? parentMode : requested; - if (runtimeModeRank(resolved) > runtimeModeRank(parentMode)) { - return Effect.fail( - failure( - "runtime_mode_escalation_denied", - `Child runtime mode ${resolved} is broader than parent mode ${parentMode}.`, - ), - ); - } - const supported = target?.supportedRuntimeModes; - if (supported === undefined || supported.length === 0 || supported.includes(resolved)) { - return Effect.succeed(resolved); - } - if (!inherited) { - return Effect.fail( - failure( - "invalid_request", - `Provider ${target?.modelSelection.instanceId} does not offer runtime mode ${resolved}, so it would run as approval-required. Use one of: ${supported.join(", ")}.`, - ), - ); - } - return Effect.succeed( - supported - .filter((mode) => runtimeModeRank(mode) <= runtimeModeRank(resolved)) - .toSorted((left, right) => runtimeModeRank(right) - runtimeModeRank(left))[0] ?? - "approval-required", - ); -} - -export function resolveInteractionMode( - parentMode: ProviderInteractionMode, - requested: OrchestratorMcpInteractionMode | undefined, -): Effect.Effect { - const resolved = requested === undefined || requested === "inherit" ? parentMode : requested; - return interactionModeRank(resolved) > interactionModeRank(parentMode) - ? Effect.fail( - failure( - "interaction_mode_escalation_denied", - `Child interaction mode ${resolved} is broader than parent mode ${parentMode}.`, - ), - ) - : Effect.succeed(resolved); -} - function stablePart(value: string): string { return encodeURIComponent(value); } @@ -809,12 +712,6 @@ function threadTitle(input: { return detail.length > 80 ? `${detail.slice(0, 77)}...` : detail; } -export function taskPrompt(input: OrchestratorMcpDelegateTaskInput): string { - return input.role === undefined || input.role === "general" - ? input.task - : `Act as the ${input.role} sub-agent for this task.\n\n${input.task}`; -} - function threadSettlement( thread: Pick, ): Pick { @@ -1062,6 +959,7 @@ const make = Effect.gen(function* () { const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const gitDriver = yield* GitVcsDriver.GitVcsDriver; const serverSettings = yield* ServerSettings.ServerSettingsService; + const remoteDelegation = yield* RemoteDelegation.RemoteDelegation; const requireProject = (projectId: ProjectId) => projects.getById(projectId).pipe( @@ -1125,6 +1023,22 @@ const make = Effect.gen(function* () { ), ); + // Task waits read on every wake of a parent that may hold thousands of + // tasks, so they read only the task they watch and what its status needs. + const loadTaskParent = (threadId: ThreadId, taskId: NodeId) => + threadManagement + .getThreadRecords(threadId, ["runs", "subagents", "contextTransfers"], { + subagentIds: [taskId], + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to read thread ${threadId}: ${errorMessage(error)}`, + ), + ), + ); + const loadProjectThread = ( projectId: OrchestrationV2ThreadProjection["thread"]["projectId"], threadId: ThreadId, @@ -1477,19 +1391,22 @@ const make = Effect.gen(function* () { acknowledgementOperation = "task-status-acknowledge", ): Effect.fn.Return { yield* requireCapability(scope); - const parentProjection = yield* loadProjection(scope.thread.threadId); + const parentProjection = yield* loadTaskParent(scope.thread.threadId, taskId); const task = parentProjection.subagents.find( (candidate) => candidate.id === taskId && candidate.origin === "app_owned" && candidate.threadId === scope.thread.threadId, ); - if (task === undefined || task.childThreadId === null) { + if (task === undefined || (task.childThreadId === null && task.remoteChild === undefined)) { return yield* failure( "task_not_found", `Delegated task ${taskId} does not belong to thread ${scope.thread.threadId}.`, ); } + if (task.childThreadId === null) { + return yield* readRemoteTask(scope, task, waitTimedOut, acknowledgeTerminal); + } const childControls = yield* threadManagement .getThreadRecords( task.childThreadId, @@ -1670,6 +1587,113 @@ const make = Effect.gen(function* () { return response; }); + /** `mode=wait` on a task already recorded, as `delegateTask` does for its own. */ + const awaitTask = Effect.fn("OrchestratorMcpService.awaitTask")(function* ( + scope: McpThreadInvocationScope, + taskId: NodeId, + requestedTimeoutMs: number | undefined, + ): Effect.fn.Return { + const timeoutMs = Math.min( + MAX_WAIT_TIMEOUT_MS, + Math.max(1, requestedTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + ); + const waited = yield* waitForTask(scope, taskId, timeoutMs); + if (Option.isSome(waited)) return waited.value; + // As for a local task: once the blocking wait ends, a later result wakes the parent. + yield* threadManagement + .dispatch({ + type: "delegated_task.wake-policy", + commandId: stableCommandId({ + scope, + requestKey: taskId, + operation: "delegate-task-wake-policy", + }), + parentThreadId: scope.thread.threadId, + taskId, + completionWake: "always", + }) + .pipe(Effect.ignoreCause({ log: true })); + return yield* readTask(scope, taskId, true, true); + }); + + /** + * A task that runs in a linked environment, answered from its row here: + * the follower records its result once its thread there ends. + */ + const readRemoteTask = Effect.fn("OrchestratorMcpService.readRemoteTask")(function* ( + scope: McpThreadInvocationScope, + task: OrchestrationV2Subagent, + waitTimedOut: boolean, + acknowledgeTerminal: boolean, + ): Effect.fn.Return { + const status: OrchestratorMcpDelegateTaskResult["status"] = + task.result === null + ? "running" + : task.status === "completed" || + task.status === "failed" || + task.status === "cancelled" || + task.status === "interrupted" + ? task.status + : "completed"; + const terminal = isTerminalTaskStatus(status); + if ( + acknowledgeTerminal && + terminal && + task.completionDelivery?.state !== "acknowledged" && + task.completionDelivery?.state !== "disposed" + ) { + const parentProjection = yield* loadProjection(scope.thread.threadId); + const observingRun = ThreadManagementService.latestActiveRun(parentProjection); + yield* threadManagement + .dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: stableCommandId({ + scope, + requestKey: yield* requestKey(undefined), + operation: "task-status-acknowledge", + }), + parentThreadId: scope.thread.threadId, + taskId: task.id, + observedByRunId: + observingRun?.providerInstanceId === scope.thread.providerInstanceId + ? observingRun.id + : null, + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to acknowledge delegated task ${task.id}: ${errorMessage(error)}`, + ), + ), + ); + } + return { + taskId: task.id, + childThreadId: null, + ...(task.remoteChild === undefined ? {} : { remoteChild: task.remoteChild }), + childRunId: null, + childNodeId: task.id, + status, + workState: terminal ? "result_available" : "working", + hasPendingChildRuns: false, + providerInstanceId: task.providerInstanceId, + model: task.model, + summary: task.result, + resultContextTransferId: null, + latestTerminalRunId: null, + latestTerminalStatus: terminal + ? (status as OrchestratorMcpDelegateTaskResult["latestTerminalStatus"]) + : null, + latestTerminalSummary: terminal ? task.result : null, + latestTerminalResultContextTransferId: null, + // Its questions and approvals are answered in the environment it runs in. + hasPendingQuestion: false, + pendingApproval: null, + waitTimedOut, + } satisfies OrchestratorMcpDelegateTaskResult; + }); + const waitForTask = Effect.fn("OrchestratorMcpService.waitForTask")( function* (scope: McpThreadInvocationScope, taskId: NodeId, _timeoutMs: number) { const streamError = (error: unknown) => @@ -1686,10 +1710,17 @@ const make = Effect.gen(function* () { return Option.some(initial); } // One stream per event type, so transcript events never fill a buffer. + // A remote task has no child here; its completion lands on the parent. + const childThreadId = initial.childThreadId; + const wakeEvents = + childThreadId === null + ? TASK_WAKE_EVENTS.filter(({ thread }) => thread === "parent") + : TASK_WAKE_EVENTS; return yield* Stream.mergeAll( - TASK_WAKE_EVENTS.map(({ thread, eventType }) => + wakeEvents.map(({ thread, eventType }) => threadManagement.streamStoredEventsFrom({ - threadId: thread === "parent" ? scope.thread.threadId : initial.childThreadId, + threadId: + thread === "parent" || childThreadId === null ? scope.thread.threadId : childThreadId, afterSequence, eventType, }), @@ -2131,7 +2162,32 @@ const make = Effect.gen(function* () { }; }), delegateTask: Effect.fn("OrchestratorMcpService.delegateTask")(function* (callerScope, input) { + const environmentId = + input.target === undefined + ? undefined + : PeerForwarding.remoteTarget(callerScope, input.target.environmentId); + if (input.target !== undefined && environmentId !== undefined) { + yield* requireCapability(callerScope); + const scope = yield* requireThreadScope(callerScope, "delegate_task"); + // The task's child runs as an ordinary thread there; this thread keeps the task. + const { taskId } = yield* remoteDelegation.delegate(scope, { + ...input, + target: { ...input.target, environmentId }, + }); + return yield* input.mode === "wait" + ? awaitTask(scope, taskId, input.timeoutMs) + : readTask(scope, taskId, false, true); + } const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task"); + if ( + input.target?.projectId !== undefined && + input.target.projectId !== parent.thread.projectId + ) { + return yield* failure( + "invalid_request", + "A local task runs in this thread's project. Omit target.projectId, or pass target.environmentId to run it in that environment's project.", + ); + } const parentRun = parent.runs .filter(ThreadManagementService.isActiveRun) .toSorted((left, right) => right.ordinal - left.ordinal)[0]; @@ -2311,6 +2367,24 @@ const make = Effect.gen(function* () { cancelTask: Effect.fn("OrchestratorMcpService.cancelTask")(function* (callerScope, input) { const scope = yield* requireThreadScope(callerScope, "task_cancel"); const current = yield* readTask(scope, input.taskId); + const childThreadId = current.childThreadId; + if (childThreadId === null) { + // The task runs in a linked environment: its thread there is stopped through the link. + const { subagents } = yield* loadProjection(scope.thread.threadId); + const remoteTask = subagents.find( + (task) => + task.id === input.taskId && + task.origin === "app_owned" && + task.remoteChild !== undefined, + ); + if (remoteTask === undefined) { + return yield* failure( + "task_not_found", + `Delegated task ${input.taskId} does not belong to thread ${scope.thread.threadId}.`, + ); + } + return yield* remoteDelegation.cancel(scope, remoteTask, input); + } const key = yield* requestKey(input.clientRequestId); const parentProjection = yield* loadProjection(scope.thread.threadId); // Cancelling stops the child and every task under it, each a write to a @@ -2338,7 +2412,7 @@ const make = Effect.gen(function* () { } } }); - yield* assertStoppable(current.childThreadId); + yield* assertStoppable(childThreadId); const parentTask = parentProjection.subagents.find( (task) => task.id === input.taskId && task.origin === "app_owned", ); @@ -2372,7 +2446,7 @@ const make = Effect.gen(function* () { .dispatch({ type: "thread.stop", commandId, - threadId: current.childThreadId, + threadId: childThreadId, stopDelegatedTasks: false, reason, }) @@ -2385,7 +2459,7 @@ const make = Effect.gen(function* () { ), ); yield* threadManagement - .stopDelegatedTasks({ threadId: current.childThreadId, commandId, reason }) + .stopDelegatedTasks({ threadId: childThreadId, commandId, reason }) .pipe( Effect.mapError((error) => failure( @@ -2395,7 +2469,7 @@ const make = Effect.gen(function* () { ), ); const stopped = yield* threadManagement - .getThreadRecords(current.childThreadId, ["runs"]) + .getThreadRecords(childThreadId, ["runs"]) .pipe(Effect.mapError(threadManagementFailure)); for (const run of stopped.runs) { if ( @@ -2413,7 +2487,7 @@ const make = Effect.gen(function* () { requestKey: key, operation: `cancel-task-queued-run:${run.id}`, }), - threadId: current.childThreadId, + threadId: childThreadId, runId: run.id, }) .pipe(Effect.mapError(threadManagementFailure)); @@ -2474,7 +2548,7 @@ const make = Effect.gen(function* () { status: cancelled ? "cancelled" : current.status, } satisfies OrchestratorMcpTaskCancelResult; } - const child = yield* loadProjection(current.childThreadId); + const child = yield* loadProjection(childThreadId); if ( current.workState !== "waiting_for_children" && ThreadManagementService.latestActiveRun(child) === undefined && @@ -2873,6 +2947,7 @@ const make = Effect.gen(function* () { MAX_WAIT_TIMEOUT_MS, Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), ), + ...(input.untilDelegatedWorkSettled === true ? { untilDelegatedWorkSettled: true } : {}), }) .pipe(Effect.mapError(threadManagementFailure)); const status = result.run === null ? "idle" : runOutcome(result.run); @@ -2902,6 +2977,11 @@ const make = Effect.gen(function* () { status, timedOut: result.timedOut, ...(wakesCaller ? { next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } : {}), + ...(result.resultRun === undefined + ? {} + : { + resultRun: { runId: result.resultRun.id, status: runOutcome(result.resultRun) }, + }), } satisfies OrchestratorMcpThreadWaitResult; }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { @@ -3006,4 +3086,5 @@ export const layer: Layer.Layer< | ProjectService.ProjectService | SecretRequests.SecretRequests | ServerSettings.ServerSettingsService + | RemoteDelegation.RemoteDelegation > = Layer.effect(OrchestratorMcpService, make); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 6f89e37d4cddb..c73ee93042510 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -43,6 +43,7 @@ import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMoc import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -53,6 +54,12 @@ const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), ); +/** A task delegated here always has a local child thread. */ +const localChild = (childThreadId: ThreadId | null): ThreadId => { + if (childThreadId === null) throw new Error("expected a local delegated child"); + return childThreadId; +}; + // Effect returns a declared tool failure as `isError` with its encoded payload // as JSON text, never as `structuredContent`. @@ -229,6 +236,7 @@ describe("orchestrator MCP toolkit", () => { Layer.provide(layerOrchestration), ), ), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), Layer.provide(NodeServices.layer), ); @@ -355,14 +363,16 @@ describe("orchestrator MCP toolkit", () => { }); expect(delegated.resultContextTransferId).not.toBeNull(); - const delegatedChild = yield* orchestrator.getThreadProjection(delegated.childThreadId); + const delegatedChild = yield* orchestrator.getThreadProjection( + localChild(delegated.childThreadId), + ); expect(delegatedChild.thread.lineage.relationshipToParent).toBe("subagent"); expect(isProviderNativeSubagentThread(delegatedChild.thread)).toBe(false); const followupStartSequence = yield* orchestrator.getThreadEventSequence( - delegated.childThreadId, + localChild(delegated.childThreadId), ); const runningFollowupCall = yield* invoke("t3_thread_send", { - threadId: delegated.childThreadId, + threadId: localChild(delegated.childThreadId), message: cancellationPrompt, clientRequestId: "delegated-child-replay-running-1", }); @@ -371,7 +381,7 @@ describe("orchestrator MCP toolkit", () => { ).pipe(Effect.orDie); yield* orchestrator .streamStoredEventsFrom({ - threadId: delegated.childThreadId, + threadId: localChild(delegated.childThreadId), afterSequence: followupStartSequence, }) .pipe( @@ -390,7 +400,7 @@ describe("orchestrator MCP toolkit", () => { ); const queuedFollowupCall = yield* invoke("t3_thread_send", { - threadId: delegated.childThreadId, + threadId: localChild(delegated.childThreadId), message: queuedFollowupPrompt, mode: "queue", clientRequestId: "delegated-child-replay-queued-1", @@ -404,7 +414,7 @@ describe("orchestrator MCP toolkit", () => { }); const pendingProjection = yield* orchestrator.getThreadProjection( - delegated.childThreadId, + localChild(delegated.childThreadId), ); const followupTasks = (yield* orchestrator.getThreadProjection( parentThreadId, @@ -451,9 +461,11 @@ describe("orchestrator MCP toolkit", () => { latestTerminalResultContextTransferId: null, }); - const finalSequence = yield* orchestrator.getThreadEventSequence(delegated.childThreadId); + const finalSequence = yield* orchestrator.getThreadEventSequence( + localChild(delegated.childThreadId), + ); const interruptCall = yield* invoke("t3_thread_interrupt", { - threadId: delegated.childThreadId, + threadId: localChild(delegated.childThreadId), runId: runningFollowup.runId, reason: "Allow the queued replay follow-up to run.", clientRequestId: "interrupt-delegated-child-replay-1", @@ -467,7 +479,7 @@ describe("orchestrator MCP toolkit", () => { }); yield* orchestrator .streamStoredEventsFrom({ - threadId: delegated.childThreadId, + threadId: localChild(delegated.childThreadId), afterSequence: finalSequence, }) .pipe( @@ -486,7 +498,9 @@ describe("orchestrator MCP toolkit", () => { ), ); - const finalProjection = yield* orchestrator.getThreadProjection(delegated.childThreadId); + const finalProjection = yield* orchestrator.getThreadProjection( + localChild(delegated.childThreadId), + ); expect(finalProjection.runs.find((run) => run.id === runningFollowup.runId)?.status).toBe( "interrupted", ); diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 504ae749a78ac..5e6cc151fc297 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -40,6 +40,7 @@ import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts import * as SecretRequests from "../secrets/SecretRequests.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; +import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; const layerOrchestratorService = OrchestratorMcpService.layer.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), @@ -155,6 +156,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), ServerSettings.layerTest(), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), NodeServices.layer, diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index d5a77f326f931..cc2ce2b9a8635 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -16,7 +16,7 @@ import * as Layer from "effect/Layer"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; -import { threadNotFound } from "./OrchestratorMcpService.ts"; +import { threadNotFound } from "./threadAccess.ts"; export class ThreadMetadataMcpService extends Context.Service< ThreadMetadataMcpService, diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts new file mode 100644 index 0000000000000..54d25bbb36e02 --- /dev/null +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -0,0 +1,95 @@ +import { + type ModelSelection, + OrchestratorMcpFailure, + type OrchestratorMcpDelegateTaskInput, + type OrchestratorMcpInteractionMode, + type OrchestratorMcpRuntimeMode, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +/** How much a runtime mode allows, narrowest first. */ +export function runtimeModeRank(mode: RuntimeMode): number { + switch (mode) { + case "approval-required": + return 0; + case "auto-accept-edits": + return 1; + case "auto": + return 2; + case "full-access": + return 3; + } +} + +/** How much an interaction mode allows: plan, then default. */ +export function interactionModeRank(mode: ProviderInteractionMode): number { + return mode === "plan" ? 0 : 1; +} + +/** + * The mode a child runs at: the one asked for, never broader than its parent's. + * An inherited mode the target provider does not offer steps down to the + * broadest one it does; an explicitly requested one is refused instead. + */ +export function resolveRuntimeMode( + parentMode: RuntimeMode, + requested: OrchestratorMcpRuntimeMode | undefined, + target?: { + readonly modelSelection: ModelSelection; + readonly supportedRuntimeModes: ReadonlyArray | undefined; + }, +): Effect.Effect { + const inherited = requested === undefined || requested === "inherit"; + const resolved = inherited ? parentMode : requested; + if (runtimeModeRank(resolved) > runtimeModeRank(parentMode)) { + return Effect.fail( + new OrchestratorMcpFailure({ + code: "runtime_mode_escalation_denied", + message: `Child runtime mode ${resolved} is broader than parent mode ${parentMode}.`, + }), + ); + } + const supported = target?.supportedRuntimeModes; + if (supported === undefined || supported.length === 0 || supported.includes(resolved)) { + return Effect.succeed(resolved); + } + if (!inherited) { + return Effect.fail( + new OrchestratorMcpFailure({ + code: "invalid_request", + message: `Provider ${target?.modelSelection.instanceId} does not offer runtime mode ${resolved}, so it would run as approval-required. Use one of: ${supported.join(", ")}.`, + }), + ); + } + return Effect.succeed( + supported + .filter((mode) => runtimeModeRank(mode) <= runtimeModeRank(resolved)) + .toSorted((left, right) => runtimeModeRank(right) - runtimeModeRank(left))[0] ?? + "approval-required", + ); +} + +/** The same, for interaction modes. */ +export function resolveInteractionMode( + parentMode: ProviderInteractionMode, + requested: OrchestratorMcpInteractionMode | undefined, +): Effect.Effect { + const resolved = requested === undefined || requested === "inherit" ? parentMode : requested; + return interactionModeRank(resolved) > interactionModeRank(parentMode) + ? Effect.fail( + new OrchestratorMcpFailure({ + code: "interaction_mode_escalation_denied", + message: `Child interaction mode ${resolved} is broader than parent mode ${parentMode}.`, + }), + ) + : Effect.succeed(resolved); +} + +/** The task as its child receives it, with the role the caller asked for. */ +export function taskPrompt(input: OrchestratorMcpDelegateTaskInput): string { + return input.role === undefined || input.role === "general" + ? input.task + : `Act as the ${input.role} sub-agent for this task.\n\n${input.task}`; +} diff --git a/apps/server/src/mcp/linkOrigin.test.ts b/apps/server/src/mcp/linkOrigin.test.ts index c990fbf68e891..59e7ba5992155 100644 --- a/apps/server/src/mcp/linkOrigin.test.ts +++ b/apps/server/src/mcp/linkOrigin.test.ts @@ -37,6 +37,7 @@ import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts import * as ServerSettings from "../serverSettings.ts"; import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; @@ -92,6 +93,7 @@ const layerLaunches = Layer.effect( branch: null, worktreePath: null, ...(input.linkOrigin === undefined ? {} : { linkOrigin: input.linkOrigin }), + ...(input.delegatedFrom === undefined ? {} : { delegatedFrom: input.delegatedFrom }), createdBy: input.createdBy, creationSource: input.creationSource, }) @@ -156,6 +158,7 @@ const layerTools = Layer.mergeAll( Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), Layer.provide( Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/p" }), ), @@ -226,11 +229,16 @@ const failureCode = (result: McpSchema.CallToolResult) => { : undefined; }; -const launch = (scope: McpInvocationContext.McpInvocationScope, title: string) => +const launch = ( + scope: McpInvocationContext.McpInvocationScope, + title: string, + extra: Record = {}, +) => call(scope, "t3_thread_launch", { projectId, title, modelSelection: { instanceId, model: "gpt-5" }, + ...extra, }).pipe( Effect.map((result) => { assert.equal(result.isError, false, JSON.stringify(result.content)); @@ -309,6 +317,17 @@ it.layer(Layer.provideMerge(layerTools, layerOrchestration))("work a link starts // An ordinary outside agent's launch is not a link's. const plain = yield* launch(claudeCode, "Started by Claude Code"); assert.equal((yield* projections.getThreadShell(plain))?.linkOrigin, undefined); + + // A link's delegated task names its parent there; only a link's launch may. + const delegatedFrom = { + environmentId: "environment-laptop", + threadId: "thread:laptop-parent", + title: "Laptop parent", + }; + const task = yield* launch(laptop, "Delegated from the laptop", { delegatedFrom }); + assert.deepEqual((yield* projections.getThreadShell(task))?.delegatedFrom, delegatedFrom); + const claimed = yield* launch(claudeCode, "Claims a parent", { delegatedFrom }); + assert.equal((yield* projections.getThreadShell(claimed))?.delegatedFrom, undefined); }), ); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 1c0e3e543e5a7..ca8b373ba92ca 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -3,9 +3,10 @@ import { CommandId, OrchestratorMcpFailure, type ProjectId, + type ProviderInstanceId, type ProviderInteractionMode, type RuntimeMode, - type ThreadId, + ThreadId, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; @@ -13,9 +14,30 @@ import * as Effect from "effect/Effect"; import type { OrchestratorV2Error } from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; -import * as OrchestrationMcp from "./OrchestratorMcpService.ts"; +import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +/** + * Threads an agent starts over MCP are `mcp:`, and agents often paste the + * bare uuid. The id is never rewritten, so permission checks keyed on it still + * hold; the miss only names a live thread the caller could read by its full id. + */ +export const threadNotFound = Effect.fn("OrchestratorMcpService.threadNotFound")(function* ( + threads: Pick, + threadId: ThreadId, + message: string, +) { + const prefixed = ThreadId.make(`mcp:${threadId}`); + const shell = yield* threads.getThreadShell(prefixed).pipe(Effect.orElseSucceed(() => null)); + return yield* new OrchestratorMcpFailure({ + code: "thread_not_found", + message: + shell === null || shell.deletedAt !== null + ? message + : `${message} Did you mean \`${prefixed}\`?`, + }); +}); + export const unavailable = () => new OrchestratorMcpFailure({ code: "orchestration_error", @@ -92,17 +114,32 @@ export const assertTargetWithinLimits = ( limits: CallerLimits, target: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, ) => - OrchestrationMcp.resolveRuntimeMode(limits.runtimeMode, target.runtimeMode).pipe( - Effect.andThen( - OrchestrationMcp.resolveInteractionMode(limits.interactionMode, target.interactionMode), - ), + resolveRuntimeMode(limits.runtimeMode, target.runtimeMode).pipe( + Effect.andThen(resolveInteractionMode(limits.interactionMode, target.interactionMode)), Effect.asVoid, ); +/** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ +export const isLiveCallerShell = ( + shell: Pick< + OrchestrationV2ThreadShell, + "archivedAt" | "activeRunId" | "providerInstanceId" | "pendingBackgroundTasks" + > | null, + providerInstanceId: ProviderInstanceId | undefined, +) => + shell !== null && + shell.archivedAt === null && + shell.providerInstanceId === providerInstanceId && + (shell.activeRunId !== null || + (shell.pendingBackgroundTasks?.some( + (task) => task.kind === "subagent" && task.providerInstanceId === providerInstanceId, + ) ?? + false)); + /** Thread callers need a live run or its background subagent in an unarchived thread. OAuth clients rely on their session and ceiling. */ export function assertLiveCaller({ caller, scope }: Caller) { if (caller === undefined) return Effect.void; - return OrchestrationMcp.isLiveCallerShell(caller, scope.thread?.providerInstanceId) + return isLiveCallerShell(caller, scope.thread?.providerInstanceId) ? Effect.void : Effect.fail( new OrchestratorMcpFailure({ @@ -148,11 +185,7 @@ export const readThread = Effect.fn("mcp.readThread")(function* < const targetId = yield* resolveThreadId(context, threadId); const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable)); if (shell === null || shell.deletedAt !== null) { - return yield* OrchestrationMcp.threadNotFound( - context.threads, - targetId, - "The thread was not found.", - ); + return yield* threadNotFound(context.threads, targetId, "The thread was not found."); } const projection = yield* context.threads .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 96a4a3345ce78..e0eb6f6c81195 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -45,6 +45,7 @@ import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; import * as ProjectService from "../../project/ProjectService.ts"; import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; import * as SecretRequests from "../../secrets/SecretRequests.ts"; +import * as RemoteDelegation from "../../peer/RemoteDelegation.ts"; import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; @@ -603,6 +604,7 @@ it.effect("refuses act-as-caller tools to a client caller", () => Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), ), ), ), @@ -659,6 +661,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), ), ), ), @@ -743,6 +746,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), ), ), @@ -837,6 +841,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), ), ), ); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 55034630b2ca4..f5a67ae025a85 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -20,7 +20,7 @@ import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; -import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; +import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; @@ -217,6 +217,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { createdBy: "agent", creationSource: "mcp", ...(linkOrigin === undefined ? {} : { linkOrigin }), + // Kept only alongside linkOrigin: thread.create drops it otherwise. + ...(input.delegatedFrom === undefined ? {} : { delegatedFrom: input.delegatedFrom }), }).pipe( Effect.mapError((error) => { if (error._tag === "AttachmentClaimError") { @@ -251,11 +253,10 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_project_list: McpToolAccess.reads((input) => Effect.gen(function* () { const projects = yield* access; - const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable)); - const rows = snapshot.projects.filter((project) => project.deletedAt === null); - const start = input.cursor ?? 0, - end = start + (input.limit ?? 20); - return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null }; + // Identities are resolved: linked environments find this side's project by repository. + return yield* projects + .listPage({ cursor: input.cursor ?? 0, limit: input.limit ?? 20 }) + .pipe(Effect.mapError(unavailable)); }), ), t3_project_read: McpToolAccess.reads((input) => diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index b4bddfcd0b5ed..38931596868b4 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -5,6 +5,7 @@ import { TrimmedNonEmptyString, ThreadId, RunId, + OrchestrationV2DelegatedFrom, OrchestrationV2RunStatus, OrchestrationV2ThreadLaunchWorkspaceStrategy, RuntimeMode, @@ -146,6 +147,8 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { ), attachments: Schema.optional(Schema.Array(McpAttachmentInput).check(Schema.isMaxLength(8))), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + /** Set by a linked environment launching its agent's delegated task here; ignored otherwise. */ + delegatedFrom: Schema.optional(OrchestrationV2DelegatedFrom), }), success: Schema.Struct({ threadId: ThreadId, diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 78bf5dcce18d7..6293d25025a92 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -121,6 +121,7 @@ const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink repositoryIdentityResolved: false, }), request: () => Effect.void, + readRepositoryIdentity: () => Effect.succeed(null), getAvailable: () => Effect.succeed({ repositoryIdentity: null, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e627b4663602e..c4f3fe5ebd839 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -47,6 +47,7 @@ import { type OrchestrationV2ExecutionNode, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + type OrchestrationV2RemoteTaskChild, type OrchestrationV2Run, type OrchestrationV2RunAttempt, type OrchestrationV2ThreadShell, @@ -516,6 +517,9 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "provider.switch": return command.threadId; case "delegated_task.request": + case "delegated_task.remote.request": + case "delegated_task.remote.complete": + case "delegated_task.remote.stop": case "delegated_task.wake-policy": case "delegated_task.completion-delivery.acknowledge": case "delegated_task.completion-delivery.dispose": @@ -2540,6 +2544,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio activeProviderThreadId: null, pullRequests: [], ...(command.linkOrigin === undefined ? {} : { linkOrigin: command.linkOrigin }), + // Only stamped work names a remote parent, so a client cannot claim one. + ...(command.linkOrigin === undefined || command.delegatedFrom === undefined + ? {} + : { delegatedFrom: command.delegatedFrom }), lineage: { parentThreadId: null, relationshipToParent: null, @@ -7561,6 +7569,386 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + /** + * Records a task that runs in a linked environment on its parent: the + * task, its node and its turn item, as a local delegation does, but no child + * thread here. The parent's own checks are the local request's. + */ + const dispatchRemoteDelegatedTaskRequest = Effect.fn( + "orchestrationV2.dispatch.remoteDelegatedTaskRequest", + )(function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "delegated_task.remote.request" } + >, + events: Ref.Ref>, + ) { + const parentProjection = yield* projectionStore + .getThreadRecords(command.parentThreadId, [ + "runs", + "nodes", + "subagents", + "providerTurns", + "attempts", + ]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.parentThreadId, cause }), + ), + ); + const parentRun = parentProjection.runs.find( + (candidate) => candidate.id === command.parentRunId, + ); + if (parentRun === undefined || !isBlockingRun(parentRun) || parentRun.rootNodeId === null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Parent run ${command.parentRunId} is not active.`, + }); + } + if (yield* stopReachedRun(command, command.parentThreadId, parentRun.id)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Parent run ${command.parentRunId} is stopping.`, + }); + } + const now = yield* DateTime.now; + const taskNodeId = idAllocator.derive.delegatedTaskNode({ commandId: command.commandId }); + const taskTurnItemId = idAllocator.derive.delegatedTaskTurnItem({ + commandId: command.commandId, + }); + const taskTitle = subagentThreadTitle({ + parentTitle: parentProjection.thread.title, + prompt: command.task, + ...(command.title === undefined ? {} : { title: command.title }), + ordinal: parentProjection.subagents.length + 1, + }); + const common = { + threadId: command.parentThreadId, + runId: parentRun.id, + nodeId: taskNodeId, + driver: command.driver, + providerInstanceId: command.modelSelection.instanceId, + occurredAt: now, + }; + const emitEvent = emit(events, command); + yield* emitEvent({ + ...common, + type: "node.updated", + payload: { + id: taskNodeId, + threadId: command.parentThreadId, + runId: parentRun.id, + parentNodeId: command.parentNodeId, + rootNodeId: parentRun.rootNodeId, + kind: "subagent", + status: "running", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* emitEvent({ + ...common, + type: "subagent.updated", + payload: { + id: taskNodeId, + threadId: command.parentThreadId, + runId: parentRun.id, + parentNodeId: command.parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: command.driver, + providerInstanceId: command.modelSelection.instanceId, + providerThreadId: null, + childThreadId: null, + remoteChild: command.remoteChild, + nativeTaskRef: null, + prompt: command.task, + title: command.title ?? null, + model: command.modelSelection.model, + ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }), + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }); + yield* emitEvent({ + ...common, + type: "turn-item.updated", + payload: { + id: taskTurnItemId, + threadId: command.parentThreadId, + runId: parentRun.id, + nodeId: taskNodeId, + providerThreadId: parentRun.providerThreadId, + providerTurnId: providerTurnForRun(parentProjection, parentRun)?.id ?? null, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal(parentProjection), + status: "running", + title: command.title ?? taskTitle, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "subagent", + subagentId: taskNodeId, + origin: "app_owned", + driver: command.driver, + providerInstanceId: command.modelSelection.instanceId, + childThreadId: null, + remoteChild: command.remoteChild, + prompt: command.task, + result: null, + }, + }); + }); + + /** + * Completes a task that ran in a linked environment: the parent half of + * `finalizeAppOwnedSubagent`, with the result its thread there ended with. + * It runs under the parent's lock, so it never interleaves with a wake + * policy change or a delivery on the same task. + */ + const dispatchRemoteDelegatedTaskComplete = Effect.fn( + "orchestrationV2.dispatch.remoteDelegatedTaskComplete", + )(function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "delegated_task.remote.complete" } + >, + events: Ref.Ref>, + ) { + const parentProjection = yield* projectionStore + .getThreadRecords( + command.parentThreadId, + ["runs", "messages", "subagents", "providerTurns", "nodes", "turnItems"], + { turnItemTypes: ["subagent"], messageRoles: ["user"] }, + ) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.parentThreadId, cause }), + ), + ); + const task = parentProjection.subagents.find( + (candidate) => + candidate.id === command.taskId && + candidate.origin === "app_owned" && + candidate.remoteChild !== undefined, + ); + if (task === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Delegated task ${command.taskId} is not a remote task of thread ${command.parentThreadId}.`, + }); + } + if ( + command.stoppedThere === "stopped" && + (command.stopCount ?? 0) !== (task.remoteChild?.stopCount ?? 0) + ) + return; + const remoteChild = + command.stoppedThere === undefined + ? task.remoteChild + : { ...task.remoteChild!, stoppedThere: true }; + // The first result stands; a repeated follower report records nothing, which + // the dispatcher refuses. A stop there after the result is still recorded, but + // a run that ended by itself does not settle a stop the task here still owes. + if (task.result !== null) { + // A Stop or cancel here that lost the race to the result still owes its stop. + if (command.stoppedThere === undefined) { + return yield* emitRemoteStopOwed(events, command, task, task.remoteChild!); + } + const owesStop = + task.status === "cancelled" || + task.status === "interrupted" || + task.remoteChild?.stoppedThere === false; + if ( + task.remoteChild?.stoppedThere === true || + (owesStop && command.stoppedThere !== "stopped") + ) + return; + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "subagent.updated", + threadId: command.parentThreadId, + ...(task.runId === null ? {} : { runId: task.runId }), + nodeId: task.id, + driver: task.driver, + occurredAt: now, + payload: { ...task, remoteChild, updatedAt: now }, + }); + return; + } + const now = yield* DateTime.now; + const updatedTask: OrchestrationV2Subagent = { + ...task, + remoteChild, + status: command.status, + result: command.result, + completedAt: now, + updatedAt: now, + }; + const parentRun = + task.runId === null + ? undefined + : parentProjection.runs.find((candidate) => candidate.id === task.runId); + const plan = yield* planDelegatedCompletionDelivery({ + parentProjection, + parentRun, + task, + updatedTask, + now, + }).pipe(mapDispatchError(command)); + const emitEvent = emit(events, command); + yield* emitEvent({ + type: "subagent.updated", + threadId: command.parentThreadId, + ...(task.runId === null ? {} : { runId: task.runId }), + nodeId: task.id, + driver: task.driver, + occurredAt: now, + payload: plan.task, + }); + if (plan.parentRun !== undefined) { + yield* emitEvent({ + type: "run.updated", + threadId: command.parentThreadId, + runId: plan.parentRun.id, + ...(plan.parentRun.rootNodeId === null ? {} : { nodeId: plan.parentRun.rootNodeId }), + providerInstanceId: plan.parentRun.providerInstanceId, + occurredAt: now, + payload: plan.parentRun, + }); + } + if (plan.message !== undefined) { + yield* emitEvent({ + type: "message.updated", + threadId: command.parentThreadId, + ...(plan.message.runId === null ? {} : { runId: plan.message.runId }), + ...(plan.message.nodeId === null ? {} : { nodeId: plan.message.nodeId }), + providerInstanceId: + plan.parentRun?.providerInstanceId ?? parentProjection.thread.providerInstanceId, + occurredAt: now, + payload: plan.message, + }); + } + const node = parentProjection.nodes.find((candidate) => candidate.id === task.id); + if (node !== undefined) { + yield* emitEvent({ + type: "node.updated", + threadId: command.parentThreadId, + ...(node.runId === null ? {} : { runId: node.runId }), + nodeId: node.id, + driver: task.driver, + occurredAt: now, + payload: { ...node, status: command.status, completedAt: now }, + }); + } + const item = parentProjection.turnItems.find( + (candidate) => candidate.type === "subagent" && candidate.subagentId === task.id, + ); + if (item !== undefined) { + yield* emitEvent({ + type: "turn-item.updated", + threadId: command.parentThreadId, + ...(item.runId === null ? {} : { runId: item.runId }), + ...(item.nodeId === null ? {} : { nodeId: item.nodeId }), + driver: task.driver, + occurredAt: now, + payload: { + ...item, + status: command.status, + result: command.result, + completedAt: now, + updatedAt: now, + } as OrchestrationV2TurnItem, + }); + } + }); + + /** + * Owes a stop to the thread there of a remote task that already has its + * result. Only `remoteChild.stoppedThere` changes, so the result and its + * delivery stand; the follower sends the stop and clears it. + */ + const dispatchRemoteDelegatedTaskStop = Effect.fn( + "orchestrationV2.dispatch.remoteDelegatedTaskStop", + )(function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "delegated_task.remote.stop" } + >, + events: Ref.Ref>, + ) { + const { subagents } = yield* projectionStore + .getThreadRecords(command.parentThreadId, ["subagents"]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.parentThreadId, cause }), + ), + ); + const task = subagents.find( + (candidate) => + candidate.id === command.taskId && + candidate.origin === "app_owned" && + candidate.remoteChild !== undefined && + candidate.result !== null, + ); + if (task?.remoteChild === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Delegated task ${command.taskId} is not a finished remote task of thread ${command.parentThreadId}.`, + }); + } + yield* emitRemoteStopOwed(events, command, task, task.remoteChild); + }); + + const emitRemoteStopOwed = Effect.fn("orchestrationV2.dispatch.remoteStopOwed")(function* ( + events: Ref.Ref>, + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "delegated_task.remote.complete" | "delegated_task.remote.stop" } + >, + task: OrchestrationV2Subagent, + remoteChild: OrchestrationV2RemoteTaskChild, + ) { + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "subagent.updated", + threadId: command.parentThreadId, + ...(task.runId === null ? {} : { runId: task.runId }), + nodeId: task.id, + driver: task.driver, + occurredAt: now, + payload: { + ...task, + remoteChild: { + ...remoteChild, + stoppedThere: false, + stopCount: (remoteChild.stopCount ?? 0) + 1, + }, + updatedAt: now, + }, + }); + }); + // After blocking waits time out, enable later completions to wake the parent. // Hold the parent dispatch lock shared with finalization to serialize full-row writes. const dispatchDelegatedTaskWakePolicy = Effect.fn( @@ -12117,6 +12505,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "delegated_task.request": yield* dispatchDelegatedTaskRequest(command, events, effects); break; + case "delegated_task.remote.request": + yield* dispatchRemoteDelegatedTaskRequest(command, events); + break; + case "delegated_task.remote.complete": + yield* dispatchRemoteDelegatedTaskComplete(command, events); + break; + case "delegated_task.remote.stop": + yield* dispatchRemoteDelegatedTaskStop(command, events); + break; case "delegated_task.wake-policy": yield* dispatchDelegatedTaskWakePolicy(command, events); break; @@ -12350,7 +12747,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (command.type === "notification.delivery.accept") { yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.threadId)); } - if (command.type === "delegated_task.wake-policy") { + if ( + command.type === "delegated_task.wake-policy" || + command.type === "delegated_task.remote.complete" + ) { yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.parentThreadId)); } diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 7f70a2ec25eee..288378d09fec7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -964,6 +964,56 @@ const runlessNativeSubagentNeedsRecovery = Effect.gen(function* () { } }); +const subagentRecordsSelectById = Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("subagent-records-by-id"); + const now = yield* DateTime.now; + const ids = [NodeId.make("node:subagent-by-id:a"), NodeId.make("node:subagent-by-id:b")]; + for (const id of ids) { + yield* store.apply({ + id: EventId.make(`event:${id}`), + type: "subagent.updated", + threadId, + nodeId: id, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id, + threadId, + runId: null, + parentNodeId: NodeId.make("node:subagent-by-id:parent"), + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Do the work", + title: "Delegated task", + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }); + } + const read = (subagentIds?: ReadonlyArray) => + store + .getThreadRecords(threadId, ["subagents"], subagentIds === undefined ? {} : { subagentIds }) + .pipe(Effect.map(({ subagents }) => subagents.map((task) => task.id))); + assert.deepEqual(yield* read(), ids); + assert.deepEqual(yield* read([ids[1]!]), [ids[1]]); + assert.deepEqual(yield* read([]), []); +}); + +it.effect("memory record reads select subagents by id", () => + subagentRecordsSelectById.pipe(Effect.provide(ProjectionStore.layerMemory)), +); + const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(function* ( suffix: string, ) { @@ -1392,6 +1442,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.deepEqual(recovery?.restartCancelledBackgroundWork, work); }), ); + it.effect("record reads select subagents by id", () => subagentRecordsSelectById); it.effect( "command reads skip finished leaf nodes of finished runs", () => commandReadsSkipSettledLeafNodes, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index dff0bd31fa9ef..224e57c822ecf 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -333,6 +333,7 @@ export interface ProjectionRecordFilter { readonly messageRunIds?: ReadonlyArray; readonly turnItemRunIds?: ReadonlyArray; readonly runIds?: ReadonlyArray; + readonly subagentIds?: ReadonlyArray; readonly turnItemTypes?: ReadonlyArray; readonly turnItemStatuses?: ReadonlyArray; readonly omitSettledLeafNodes?: boolean; @@ -518,6 +519,15 @@ export interface ProjectionStoreV2Shape { ReadonlyArray<{ readonly threadId: ThreadId; readonly worktreePath: string }>, ProjectionStoreV2Error >; + /** + * Delegated tasks in a linked environment with work left for this one: no + * result yet, or cancelled or stopped here without that stop reaching there, + * including a Stop after the task had its result. + */ + readonly getOpenRemoteDelegatedTasks: Effect.Effect< + ReadonlyArray<{ readonly parentThreadId: ThreadId; readonly taskId: NodeId }>, + ProjectionStoreV2Error + >; readonly getTurnStartContext: ( threadId: ThreadId, runId: RunId, @@ -1660,6 +1670,9 @@ export function threadShellFromProjection( ...(projection.thread.linkOrigin === undefined ? {} : { linkOrigin: projection.thread.linkOrigin }), + ...(projection.thread.delegatedFrom === undefined + ? {} + : { delegatedFrom: projection.thread.delegatedFrom }), latestRunId: latestRun?.id ?? null, latestRunRequestedAt: latestRun?.requestedAt ?? null, latestRunStartedAt: latestRun?.startedAt ?? null, @@ -1939,6 +1952,9 @@ function shellFromState(input: { ...(input.state.thread.linkOrigin === undefined ? {} : { linkOrigin: input.state.thread.linkOrigin }), + ...(input.state.thread.delegatedFrom === undefined + ? {} + : { delegatedFrom: input.state.thread.delegatedFrom }), latestRunId: input.state.latestRunId, latestRunRequestedAt: input.state.latestRunRequestedAt, latestRunStartedAt: input.state.latestRunStartedAt, @@ -3350,13 +3366,15 @@ export const layer: Layer.Layer = rows.toSorted((left, right) => left.position - right.position), ), ), - fields !== undefined && !fields.includes("subagents") + (fields !== undefined && !fields.includes("subagents")) || + (window === undefined && matchesNothing(filter?.subagentIds)) ? Effect.succeed([]) : window === undefined ? sql` SELECT payload_json FROM orchestration_v2_projection_subagents WHERE thread_id = ${threadId} + ${filter?.subagentIds === undefined ? sql`` : sql`AND subagent_id IN (SELECT value FROM json_each(${encodeIdList(filter.subagentIds)}))`} ORDER BY COALESCE(started_at, ''), subagent_id ASC ` : sql` @@ -6268,6 +6286,32 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); + const getOpenRemoteDelegatedTasks: ProjectionStoreV2Shape["getOpenRemoteDelegatedTasks"] = sql<{ + readonly thread_id: string; + readonly subagent_id: string; + }>` + SELECT thread_id, subagent_id FROM orchestration_v2_projection_subagents + WHERE origin = 'app_owned' + AND child_thread_id IS NULL + AND json_type(payload_json, '$.remoteChild') = 'object' + AND ( + json_type(payload_json, '$.result') = 'null' + OR ( + json_extract(payload_json, '$.status') IN ('cancelled', 'interrupted') + AND json_type(payload_json, '$.remoteChild.stoppedThere') IS NOT 'true' + ) + OR json_type(payload_json, '$.remoteChild.stoppedThere') = 'false' + ) + `.pipe( + Effect.map((rows) => + rows.map((row) => ({ + parentThreadId: ThreadId.make(row.thread_id), + taskId: NodeId.make(row.subagent_id), + })), + ), + Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })), + ); + const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = Effect.fn("ProjectionStore.getThreadsWithPullRequests")( function* (threadId) { @@ -6787,6 +6831,7 @@ export const layer: Layer.Layer = getSettlementCandidates: readOnReader(getSettlementCandidates), getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests), getThreadWorktreePaths: readOnReader(getThreadWorktreePaths), + getOpenRemoteDelegatedTasks: getOpenRemoteDelegatedTasks.pipe(NodeSqliteClient.readOnly), getThreadProjection, getTurnStartContext: readOnReader(getTurnStartContext), getTurnStartHistory: readOnReader(getTurnStartHistory), @@ -7039,6 +7084,7 @@ export const layerMemory: Layer.Layer = Layer.effect( ), ), ), + getOpenRemoteDelegatedTasks: Effect.succeed([]), getThreadsWithPullRequests: (threadId) => Ref.get(replayState).pipe( Effect.map((state) => @@ -7235,6 +7281,10 @@ export const layerMemory: Layer.Layer = Layer.effect( filter?.runIds === undefined ? projection.runs : projection.runs.filter((row) => filter.runIds!.includes(row.id)), + subagents: + filter?.subagentIds === undefined + ? projection.subagents + : projection.subagents.filter((row) => filter.subagentIds!.includes(row.id)), turnItems: projection.turnItems.filter( (row) => (filter?.turnItemRunIds === undefined || diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index f843461925695..82ae02fb54af8 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -100,11 +100,19 @@ function isNonterminalNodeStatus(status: string): boolean { return status === "pending" || status === "running" || status === "waiting"; } +/** + * A delegate_task child. Its own thread is reconciled and continued on its own + * (here, or in the linked environment it runs in) and reports back through + * the app, so it is not provider background work. + */ function isAppOwnedDelegation(task: { readonly origin: OrchestrationV2Subagent["origin"]; readonly childThreadId: ThreadId | null; + readonly remoteChild?: OrchestrationV2Subagent["remoteChild"]; }): boolean { - return task.origin === "app_owned" && task.childThreadId !== null; + return ( + task.origin === "app_owned" && (task.childThreadId !== null || task.remoteChild !== undefined) + ); } function isAppOwnedDelegationItem( diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 4837c4a2ac1c6..a39f6b7f036a6 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -210,6 +210,7 @@ it.effect( ProjectionStore.ProjectionStoreV2.of({ apply: () => Effect.void, getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), + getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), diff --git a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts new file mode 100644 index 0000000000000..4bb8c103f6df7 --- /dev/null +++ b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts @@ -0,0 +1,411 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EnvironmentId, + EventId, + MessageId, + NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EventSink from "./EventSink.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; + +// A delegated task whose child runs in a linked environment is recorded on +// its parent without a child thread here, and completing it wakes the parent +// the way a local child's result does. + +const instanceId = ProviderInstanceId.make("codex"); +const driver = ProviderDriverKind.make("codex"); +const modelSelection = { instanceId, model: "gpt-5.4" }; +const adapter = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for delegated task records"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; + +/** Every parent wake the orchestrator offers, for the tests to read. */ +class Offers extends Context.Service< + Offers, + Ref.Ref> +>()("t3/orchestration-v2/RemoteDelegatedTask.test/Offers") {} +const layerOffers = Layer.effect( + Offers, + Ref.make>([]), +); +const layerDatabase = SqlitePersistence.layerMemory; +const layerTest = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "remote-delegated-task" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), +).pipe( + Layer.provide( + Layer.effect( + ProviderContinuationRequests.ProviderContinuationRequests, + Offers.pipe( + Effect.map((offers) => ({ + offer: (request: ProviderContinuationRequests.ProviderContinuationRequest) => + Ref.update(offers, (seen) => [...seen, request]), + take: Effect.never, + })), + ), + ), + ), + Layer.provideMerge(layerOffers), +); + +/** A parent thread whose run is live, as when its agent calls delegate_task. */ +const seedLiveParent = (name: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`thread:${name}`); + const runId = RunId.make(`run:${name}`); + const rootNodeId = NodeId.make(`node:${name}:root`); + const providerThreadId = ProviderThreadId.make(`provider-thread:${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:create:${name}`), + threadId, + projectId: ProjectId.make("project:remote-delegated-task"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* sink.write({ + commandId: CommandId.make(`command:seed:${name}`), + events: [ + { + id: EventId.make(`event:seed-provider-thread:${name}`), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: rootNodeId, + nativeThreadRef: { driver, nativeId: `native:${name}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make(`event:seed-node:${name}`), + type: "node.updated", + threadId, + runId, + nodeId: rootNodeId, + driver, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: rootNodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make(`event:seed-run:${name}`), + type: "run.updated", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: instanceId, + modelSelection, + providerThreadId, + userMessageId: MessageId.make(`message:seed-user:${name}`), + rootNodeId, + activeAttemptId: null, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + ], + }); + return { threadId, runId, rootNodeId }; + }); + +const remoteChild = { + environmentId: EnvironmentId.make("environment:box"), + threadId: ThreadId.make("thread:on-the-box"), + label: "Box", +}; + +it.layer(layerTest)("a delegated task in a linked environment", (it) => { + it.effect("is recorded on its parent without a child thread here, once", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parent = yield* seedLiveParent("records"); + const request = { + type: "delegated_task.remote.request" as const, + commandId: CommandId.make("command:remote-delegate:records"), + parentThreadId: parent.threadId, + parentRunId: parent.runId, + parentNodeId: parent.rootNodeId, + task: "Run the suite there.", + driver: ProviderDriverKind.make("remote"), + modelSelection, + remoteChild, + completionWake: "always" as const, + }; + yield* orchestrator.dispatch(request); + yield* orchestrator.dispatch(request); + const projection = yield* orchestrator.getThreadProjection(parent.threadId); + assert.equal(projection.subagents.length, 1); + const [task] = projection.subagents; + assert.equal(task?.childThreadId, null); + assert.deepEqual(task?.remoteChild, remoteChild); + assert.equal(task?.status, "running"); + const item = projection.turnItems.find((candidate) => candidate.type === "subagent"); + assert.deepEqual(item?.type === "subagent" ? item.remoteChild : undefined, remoteChild); + // No thread was created here for the child. + assert.equal(yield* orchestrator.getThreadShell(remoteChild.threadId), null); + }), + ); + + it.effect("keeps a newer stop recoverable after an older acknowledgement", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const parent = yield* seedLiveParent("stop-generation"); + yield* orchestrator.dispatch({ + type: "delegated_task.remote.request", + commandId: CommandId.make("command:remote-delegate:stop-generation"), + parentThreadId: parent.threadId, + parentRunId: parent.runId, + parentNodeId: parent.rootNodeId, + task: "Run a check.", + driver: ProviderDriverKind.make("remote"), + modelSelection, + remoteChild, + }); + const task = (yield* orchestrator.getThreadProjection(parent.threadId)).subagents[0]!; + const openFor = projections.getOpenRemoteDelegatedTasks.pipe( + Effect.map((open) => open.filter((entry) => entry.parentThreadId === parent.threadId)), + ); + const complete = { + type: "delegated_task.remote.complete" as const, + parentThreadId: parent.threadId, + taskId: task.id, + status: "completed" as const, + result: "Original result.", + }; + yield* orchestrator.dispatch({ + ...complete, + commandId: CommandId.make("command:result:stop-generation"), + stoppedThere: "ran-out", + }); + for (const count of [1, 2]) { + yield* orchestrator.dispatch({ + type: "delegated_task.remote.stop", + parentThreadId: parent.threadId, + taskId: task.id, + commandId: CommandId.make(`command:stop-generation:${count}`), + }); + } + // Reports from before generation tracking cannot clear a later numbered Stop either. + for (const stopCount of [undefined, 1]) { + const rejected = yield* orchestrator + .dispatch({ + ...complete, + commandId: CommandId.make(`command:stale-stop:${stopCount}`), + stoppedThere: "stopped", + ...(stopCount === undefined ? {} : { stopCount }), + }) + .pipe(Effect.flip); + assert.equal(rejected._tag, "OrchestratorDispatchError"); + assert.deepEqual(yield* openFor, [{ parentThreadId: parent.threadId, taskId: task.id }]); + } + yield* orchestrator.dispatch({ + ...complete, + commandId: CommandId.make("command:current-stop:2"), + stoppedThere: "stopped", + stopCount: 2, + }); + const settled = (yield* orchestrator.getThreadProjection(parent.threadId)).subagents[0]!; + assert.equal(settled.result, "Original result."); + assert.equal(settled.status, "completed"); + assert.equal(settled.remoteChild?.stoppedThere, true); + assert.deepEqual(yield* openFor, []); + }), + ); + + it.effect("owes a stop there for a Stop here that loses the race to its result", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const parent = yield* seedLiveParent("stop-after-result"); + yield* orchestrator.dispatch({ + type: "delegated_task.remote.request", + commandId: CommandId.make("command:remote-delegate:stop-after-result"), + parentThreadId: parent.threadId, + parentRunId: parent.runId, + parentNodeId: parent.rootNodeId, + task: "Run a check.", + driver: ProviderDriverKind.make("remote"), + modelSelection, + remoteChild, + }); + const task = (yield* orchestrator.getThreadProjection(parent.threadId)).subagents[0]!; + const complete = { + type: "delegated_task.remote.complete" as const, + parentThreadId: parent.threadId, + taskId: task.id, + }; + yield* orchestrator.dispatch({ + ...complete, + commandId: CommandId.make("command:result:stop-after-result"), + status: "completed", + result: "Done there.", + stoppedThere: "ran-out", + }); + // The parent's Stop read the task before its result landed. + yield* orchestrator.dispatch({ + ...complete, + commandId: CommandId.make("command:parent-stop:stop-after-result"), + status: "interrupted", + result: "Stopped with its parent.", + }); + + const stopped = (yield* orchestrator.getThreadProjection(parent.threadId)).subagents[0]!; + assert.equal(stopped.status, "completed"); + assert.equal(stopped.result, "Done there."); + assert.equal(stopped.remoteChild?.stoppedThere, false); + assert.equal(stopped.remoteChild?.stopCount, 1); + const open = yield* projections.getOpenRemoteDelegatedTasks; + assert.deepEqual( + open.filter((entry) => entry.parentThreadId === parent.threadId), + [{ parentThreadId: parent.threadId, taskId: task.id }], + ); + }), + ); + + it.effect("wakes its parent with the result when it completes there", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parent = yield* seedLiveParent("completes"); + const recorded = yield* orchestrator.dispatch({ + type: "delegated_task.remote.request", + commandId: CommandId.make("command:remote-delegate:completes"), + parentThreadId: parent.threadId, + parentRunId: parent.runId, + parentNodeId: parent.rootNodeId, + task: "Run the suite there.", + driver: ProviderDriverKind.make("remote"), + modelSelection, + remoteChild, + completionWake: "always", + }); + const taskEvent = recorded.storedEvents.find( + (stored) => stored.event.type === "subagent.updated", + ); + const taskId = + taskEvent?.event.type === "subagent.updated" ? taskEvent.event.payload.id : undefined; + assert.isDefined(taskId); + // Until it completes, it is open, and a restarted follower picks it up. + const projections = yield* ProjectionStore.ProjectionStoreV2; + const openFor = projections.getOpenRemoteDelegatedTasks.pipe( + Effect.map((open) => open.filter((task) => task.parentThreadId === parent.threadId)), + ); + assert.deepEqual(yield* openFor, [{ parentThreadId: parent.threadId, taskId: taskId! }]); + const offers = yield* Offers; + yield* Ref.set(offers, []); + const complete = (result: string) => + orchestrator.dispatch({ + type: "delegated_task.remote.complete", + commandId: CommandId.make(`command:remote-complete:${result}`), + parentThreadId: parent.threadId, + taskId: taskId!, + status: "completed", + result, + stoppedThere: "ran-out", + }); + yield* complete("All 412 tests pass on the box."); + // A second report is refused, so the first result stands. + const late = yield* complete("A late, different answer.").pipe(Effect.flip); + assert.equal(late._tag, "OrchestratorDispatchError"); + + const projection = yield* orchestrator.getThreadProjection(parent.threadId); + const task = projection.subagents.find((candidate) => candidate.id === taskId); + assert.equal(task?.status, "completed"); + assert.equal(task?.result, "All 412 tests pass on the box."); + // The live parent run claimed it, so task_status acknowledges it as for a local child. + assert.equal(task?.completionDelivery?.state, "claimed"); + // Nothing is left for the follower to pick up after a restart. + assert.deepEqual(yield* openFor, []); + const item = projection.turnItems.find( + (candidate) => candidate.type === "subagent" && candidate.subagentId === taskId, + ); + assert.equal(item?.status, "completed"); + // The parent's live run is offered the delivery, as for a local child. + const delivery = projection.runs.find((run) => run.id === parent.runId)?.delegatedCompletion + ?.delivery; + assert.deepEqual(delivery?.taskIds, [taskId]); + const offered = yield* Ref.get(offers); + assert.equal(offered.length, 1); + assert.equal(offered[0]?.threadId, parent.threadId); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index f3a7ede997239..1e3fd1a8b3684 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -63,6 +63,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const projectId = ProjectId.make("project:launch-test"); @@ -179,6 +180,7 @@ function makeHarness(options: HarnessOptions = {}) { bootstrap: () => Effect.die("unused"), update: () => Effect.die("unused"), delete: () => Effect.die("unused"), + listPage: () => Effect.die("unused"), getById: (id) => Effect.succeed( id === projectId @@ -321,6 +323,7 @@ it.effect.each( NodeCrypto.layer, Scheduler.layer, Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), ), ), ); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index faef398df104a..289df535c12ea 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -9,6 +9,7 @@ import { type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2CreationSource, + type OrchestrationV2DelegatedFrom, type OrchestrationV2LinkOrigin, type OrchestrationV2ProviderThreadNativeMetadata, type OrchestrationV2ThreadProjection, @@ -100,6 +101,8 @@ export interface ThreadLaunchInput { readonly creationSource: OrchestrationV2CreationSource; /** The linked environment this launch is for, if one asked for it. */ readonly linkOrigin?: OrchestrationV2LinkOrigin; + /** The parent there, when that environment's agent delegated this as a task. */ + readonly delegatedFrom?: OrchestrationV2DelegatedFrom; } type PreparationInput = Pick< @@ -916,6 +919,9 @@ const make = Effect.gen(function* () { ? {} : { importedNativeThread: input.importedNativeThread }), ...(input.linkOrigin === undefined ? {} : { linkOrigin: input.linkOrigin }), + ...(input.delegatedFrom === undefined + ? {} + : { delegatedFrom: input.delegatedFrom }), createdBy: input.createdBy, creationSource: input.creationSource, }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 2b75cd7633b86..dfd244ebe2f65 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "@effect/vitest"; import { CommandId, + EnvironmentId, MessageId, type OrchestrationV2Command, type OrchestrationV2Run, @@ -17,12 +18,14 @@ import { } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; 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 Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -50,8 +53,13 @@ it("stamps authoritative provenance on commands that create threads or messages" interactionMode: "default", branch: null, worktreePath: null, - // A client cannot claim a linked environment's origin for its own thread. + // A client cannot claim a linked environment's origin, or a parent there. linkOrigin: { sessionId: "forged", label: "Somewhere else" }, + delegatedFrom: { + environmentId: EnvironmentId.make("environment:elsewhere"), + threadId: ThreadId.make("thread:elsewhere"), + title: "Somewhere else", + }, }; const stamped = ThreadManagementService.withCreationProvenance(command, { @@ -63,6 +71,7 @@ it("stamps authoritative provenance on commands that create threads or messages" creationSource: "web", }); expect(stamped).not.toHaveProperty("linkOrigin"); + expect(stamped).not.toHaveProperty("delegatedFrom"); }); const layerReceiptsTest = CommandReceiptStore.layer.pipe( @@ -290,6 +299,151 @@ it.effect("waitForThread follows a restart continuation instead of the cut run", }), ); +it.effect("waitForThread can wait past an ended run for the thread's delegated work", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:thread-management:wait-delegated"); + const threadId = ThreadId.make("thread:thread-management:wait-delegated"); + const runId = RunId.make("run:thread-management:wait-delegated"); + const wakeRunId = RunId.make("run:thread-management:wait-delegated:wake"); + const subscribed = yield* Deferred.make(); + const events = yield* Queue.unbounded(); + const at = (seconds: number) => DateTime.makeUnsafe(seconds * 1_000); + const run = (id: RunId, ordinal: number, status: OrchestrationV2Run["status"], end: number) => + ({ + id, + ordinal, + status, + startedAt: at(end - 1), + completedAt: status === "running" ? null : at(end), + }) as unknown as OrchestrationV2Run; + // The run ended, but a task it delegated still runs and will wake the thread. + let runs = [run(runId, 1, "completed", 1)]; + let subagents = [{ status: "running" }]; + const stored = (sequence: number, event: object) => + ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadEventSequence: () => Effect.succeed(0), + restartContinuationPending: () => Effect.succeed(false), + delegatedTaskResultPending: () => Effect.succeed(false), + streamStoredEventsFrom: (input) => + input?.eventType === "subagent.updated" + ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(events)), + ) + : Stream.never, + getThreadRecords: () => + Effect.sync( + () => + ({ + thread: { id: threadId, projectId, deletedAt: null }, + runs, + messages: [], + subagents, + providerThreads: [], + }) as unknown as OrchestrationV2ThreadProjection, + ), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide(layerTest), + ); + // Without the option, the ended run is the answer, as before. + expect( + yield* service.waitForThread({ projectId, threadId, runId, timeoutMs: 60 * 60 * 1_000 }), + ).toMatchObject({ timedOut: false, run: { id: runId } }); + expect( + yield* service.waitForThread({ projectId, threadId, runId, timeoutMs: 60 * 60 * 1_000 }), + ).not.toHaveProperty("resultRun"); + + const fiber = yield* service + .waitForThread({ + projectId, + threadId, + runId, + timeoutMs: 60 * 60 * 1_000, + untilDelegatedWorkSettled: true, + }) + .pipe(Effect.forkChild); + // A wait that returns early fails below instead of hanging here. + yield* Effect.raceFirst(Deferred.await(subscribed), Fiber.await(fiber)); + // The task finishes; its delivery wakes the thread, whose next run answers. + subagents = [{ status: "completed" }]; + runs = [...runs, run(wakeRunId, 2, "completed", 3)]; + yield* Queue.offer(events, stored(1, { type: "subagent.updated", payload: {} })); + const result = yield* Fiber.join(fiber); + + expect(result).toMatchObject({ + timedOut: false, + run: { id: runId, status: "completed" }, + resultRun: { id: wakeRunId, status: "completed" }, + }); + }), +); + +it.effect("waitForThread settles delegated work on a thread rolled back past its only run", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:thread-management:wait-rolled-back"); + const threadId = ThreadId.make("thread:thread-management:wait-rolled-back"); + const runId = RunId.make("run:thread-management:wait-rolled-back"); + const rolledBack = { + id: runId, + ordinal: 1, + status: "rolled_back", + startedAt: DateTime.makeUnsafe(0), + completedAt: DateTime.makeUnsafe(1_000), + } as unknown as OrchestrationV2Run; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadEventSequence: () => Effect.succeed(0), + restartContinuationPending: () => Effect.succeed(false), + delegatedTaskResultPending: () => Effect.succeed(false), + streamStoredEventsFrom: () => Stream.never, + getThreadRecords: () => + Effect.succeed({ + thread: { id: threadId, projectId, deletedAt: null }, + runs: [rolledBack], + messages: [], + subagents: [], + providerThreads: [], + } as unknown as OrchestrationV2ThreadProjection), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide(layerTest), + ); + const fiber = yield* service + .waitForThread({ + projectId, + threadId, + runId, + timeoutMs: 60_000, + untilDelegatedWorkSettled: true, + }) + .pipe(Effect.forkChild); + // Nothing will ever run there again, so a wait still open runs out here. + yield* TestClock.adjust(Duration.millis(60_000)); + const result = yield* Fiber.join(fiber); + + expect(result).toMatchObject({ + timedOut: false, + run: { id: runId, status: "rolled_back" }, + resultRun: { id: runId, status: "rolled_back" }, + }); + }), +); + it.effect("waitForThread returns the cut run once its continuation is declined", () => Effect.gen(function* () { const projectId = ProjectId.make("project:thread-management:wait-declined"); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 6f5656ce83bab..aeeaf2ed82229 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -36,6 +36,7 @@ import { ThreadId, type TurnItemId, } from "@t3tools/contracts"; +import { delegatedTaskProgress } from "@t3tools/provider-core/server/subagentProjection"; import { threadTranscriptHeader } from "@t3tools/shared/threadTranscript"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -72,7 +73,7 @@ export function withCreationProvenance( switch (command.type) { case "thread.create": { // Only the server stamps a link's origin, so a client cannot set one. - const { linkOrigin: _linkOrigin, ...rest } = command; + const { linkOrigin: _linkOrigin, delegatedFrom: _delegatedFrom, ...rest } = command; return { ...rest, ...provenance }; } case "message.dispatch": @@ -108,6 +109,9 @@ function existingThreadIdsForCommand( case "delegated_task.wake-policy": case "delegated_task.completion-delivery.acknowledge": case "delegated_task.completion-delivery.dispose": + case "delegated_task.remote.request": + case "delegated_task.remote.complete": + case "delegated_task.remote.stop": return [command.parentThreadId]; case "thread.created.record": return command.parentThreadId === command.targetThreadId @@ -155,12 +159,20 @@ export interface ThreadManagementWaitInput { readonly threadId: ThreadId; readonly runId?: RunId; readonly timeoutMs: number; + /** + * Also wait until the thread's own delegated work settles: an ended run + * still owes the thread's answer while a child, a background task or a + * completion delivery will wake it again, as a delegated task's result does. + */ + readonly untilDelegatedWorkSettled?: boolean; } export interface ThreadManagementWaitResult { readonly threadId: ThreadId; readonly run: OrchestrationV2Run | null; readonly timedOut: boolean; + /** With `untilDelegatedWorkSettled`, once settled: the run with the thread's final answer. */ + readonly resultRun?: OrchestrationV2Run; } export interface ThreadManagementInterruptInput { @@ -899,29 +911,73 @@ const make = Effect.gen(function* () { isTerminalRunStatus(run.status) && (yield* orchestrator.restartContinuationPending(run)); return { run, pending, settled: isTerminalRunStatus(run.status) && !pending }; }); - const initial = yield* outcome(target.runs); - if (initial.settled) { - return { threadId: input.threadId, run: initial.run, timedOut: false }; - } + const untilDelegatedWorkSettled = input.untilDelegatedWorkSettled === true; + const delegatedWorkResult = Effect.gen(function* () { + const records = yield* getProjectThreadRecords( + input, + ["runs", "messages", "subagents", "providerThreads"], + { messageRoles: ["user"] }, + ); + const progress = delegatedTaskProgress(records); + // A thread rolled back past all its work, with nothing left running, has no answer to wait for. + if ( + progress.resultRun === undefined && + records.runs.every((run) => isTerminalRunStatus(run.status)) + ) { + return latestRun(records); + } + if (progress.state !== "result_available") return undefined; + const pending = yield* orchestrator + .delegatedTaskResultPending(input.threadId) + .pipe(Effect.mapError(loadError)); + return pending ? undefined : progress.resultRun; + }); + const evaluate = Effect.fn("orchestrationV2.threadManagement.evaluate")(function* ( + runs: ReadonlyArray, + ) { + const current = yield* outcome(runs); + if (!current.settled || !untilDelegatedWorkSettled) { + return { ...current, resultRun: undefined }; + } + const resultRun = yield* delegatedWorkResult; + return { ...current, settled: resultRun !== undefined, resultRun }; + }); + const settledResult = (current: Effect.Success>) => ({ + threadId: input.threadId, + run: current.run, + timedOut: !current.settled, + ...(current.resultRun === undefined ? {} : { resultRun: current.resultRun }), + }); + const initial = yield* evaluate(target.runs); + if (initial.settled) return settledResult(initial); // A declined continuation records no event, so its effect settling wakes // the wait too. const wait = Stream.merge( - Stream.merge( - orchestrator.streamStoredEventsFrom({ - threadId: input.threadId, - afterSequence, - eventType: "run.updated", - }), - orchestrator.streamStoredEventsFrom({ - threadId: input.threadId, - afterSequence, - eventType: "thread.deleted", - }), + Stream.mergeAll( + ( + [ + "run.updated", + "thread.deleted", + // What delegated work reads besides runs: tasks and their + // deliveries, and a provider thread's background tasks. + ...(untilDelegatedWorkSettled + ? (["subagent.updated", "provider-thread.updated"] as const) + : []), + ] as const + ).map((eventType) => + orchestrator.streamStoredEventsFrom({ + threadId: input.threadId, + afterSequence, + eventType, + }), + ), + { concurrency: "unbounded" }, ).pipe( Stream.mapError(loadError), Stream.filter( (stored) => + untilDelegatedWorkSettled || stored.event.type !== "run.updated" || stored.event.payload.id === selectedRun.id || stored.event.payload.restartContinuationOfRunId !== undefined, @@ -933,7 +989,7 @@ const make = Effect.gen(function* () { ).pipe( Stream.mapEffect(() => getProjectThreadRecords(input, ["runs"]).pipe( - Effect.flatMap((current) => outcome(current.runs)), + Effect.flatMap((current) => evaluate(current.runs)), ), ), Stream.filter((current) => current.settled), @@ -941,14 +997,11 @@ const make = Effect.gen(function* () { Effect.timeoutOption(Duration.millis(Math.max(1, input.timeoutMs))), ); const waited = Option.flatten(yield* wait); - if (Option.isSome(waited)) { - return { threadId: input.threadId, run: waited.value.run, timedOut: false }; - } + if (Option.isSome(waited)) return settledResult(waited.value); // The run may have reached a terminal status while the timeout was // winning the race; the final projection read decides what actually // happened, so only a still-active run counts as timed out. - const final = yield* outcome((yield* getProjectThreadRecords(input, ["runs"])).runs); - return { threadId: input.threadId, run: final.run, timedOut: !final.settled }; + return settledResult(yield* evaluate((yield* getProjectThreadRecords(input, ["runs"])).runs)); }); const settleAfterRun: ThreadManagementServiceShape["settleAfterRun"] = Effect.fn( @@ -1175,7 +1228,42 @@ const make = Effect.gen(function* () { > = []; const stoppedChildThreadIds = new Set(); for (const task of subagents) { - if (task.origin !== "app_owned" || task.childThreadId === null) continue; + if (task.origin !== "app_owned") continue; + if (task.childThreadId === null) { + // A task in a linked environment ends here; its follower then stops it there. + // One with its result keeps it, but its thread there may run follow-up work. + if (task.remoteChild === undefined) continue; + const commandId = CommandId.make(`${input.commandId}:stop:${task.id}`); + yield* dispatch( + task.result === null + ? { + type: "delegated_task.remote.complete", + commandId, + parentThreadId: input.threadId, + taskId: task.id, + status: "interrupted", + result: + input.reason === undefined + ? "Stopped with its parent." + : `Stopped with its parent: ${input.reason}`, + } + : { + type: "delegated_task.remote.stop", + commandId, + parentThreadId: input.threadId, + taskId: task.id, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning("Unable to stop a delegated task", { + parentThreadId: input.threadId, + taskId: task.id, + error, + }).pipe(Effect.andThen(Effect.sync(() => failures.push(error)))), + ), + ); + continue; + } const threadId = task.childThreadId; // Follow-up tasks reuse a child thread; walk its subtree only once. if (stoppedChildThreadIds.has(threadId)) continue; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 025191f098823..425c00af4b11e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -614,6 +614,7 @@ const layerSharedApplicationDataPlaneTest = Layer.mergeAll( repositoryIdentityResolved: false, }), request: () => Effect.void, + readRepositoryIdentity: () => Effect.succeed(null), getAvailable: () => Effect.succeed({ repositoryIdentity: null, diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 6a4fb2937c0e9..59a4e810c9ba2 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -32,6 +32,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as RemoteDelegation from "./RemoteDelegation.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { liveThreadShell } from "../mcp/McpToolAccess.testkit.ts"; @@ -228,6 +229,7 @@ const serveBox = (seen: Ref.Ref, linkSession: Ref.Ref) => Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), ), ); @@ -296,12 +298,17 @@ const makeLaptop = Effect.gen(function* () { } as never), }), ), - Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), - Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), - Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), - Layer.provide(Layer.mock(ProjectService.ProjectService)({})), - Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), - Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ServerSettings.layerTest(), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + ), + ), Layer.fresh, ); const here = yield* Layer.build(layerHere); diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index 05b9db6afa9f1..be9db1e489508 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -2,6 +2,10 @@ import { type EnvironmentId, OrchestratorMcpFailure, type OrchestratorMcpEnvironmentLinksResult, + OrchestratorMcpThreadInterruptInput, + OrchestratorMcpThreadInterruptResult, + OrchestratorMcpThreadReadInput, + OrchestratorMcpThreadReadResult, OrchestratorMcpThreadWaitInput, OrchestratorMcpThreadWaitResult, } from "@t3tools/contracts"; @@ -51,6 +55,18 @@ export class PeerForwarding extends Context.Service< environmentId: EnvironmentId, params: Tool.Parameters, ) => Effect.Effect, OrchestratorMcpFailure>; + /** `t3_thread_read` there. */ + readonly readThread: ( + scope: Scope, + environmentId: EnvironmentId, + input: OrchestratorMcpThreadReadInput, + ) => Effect.Effect; + /** `t3_thread_interrupt` there. */ + readonly interruptThread: ( + scope: Scope, + environmentId: EnvironmentId, + input: OrchestratorMcpThreadInterruptInput, + ) => Effect.Effect; /** `t3_thread_wait` there, in pieces short enough to survive any route. */ readonly waitForThread: ( scope: Scope, @@ -132,6 +148,30 @@ const make = Effect.gen(function* () { }); }); + const readThread: PeerForwarding["Service"]["readThread"] = (scope, environmentId, input) => + forward( + scope, + "t3_thread_read", + OrchestratorMcpThreadReadInput, + OrchestratorMcpThreadReadResult, + environmentId, + input, + ); + + const interruptThread: PeerForwarding["Service"]["interruptThread"] = ( + scope, + environmentId, + input, + ) => + forward( + scope, + "t3_thread_interrupt", + OrchestratorMcpThreadInterruptInput, + OrchestratorMcpThreadInterruptResult, + environmentId, + input, + ); + const waitForThread: PeerForwarding["Service"]["waitForThread"] = Effect.fn( "PeerForwarding.waitForThread", )(function* (scope, environmentId, input) { @@ -148,7 +188,14 @@ const make = Effect.gen(function* () { OrchestratorMcpThreadWaitInput, OrchestratorMcpThreadWaitResult, environmentId, - { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk }, + { + threadId: input.threadId, + ...(runId === undefined ? {} : { runId }), + timeoutMs: chunk, + ...(input.untilDelegatedWorkSettled === undefined + ? {} + : { untilDelegatedWorkSettled: input.untilDelegatedWorkSettled }), + }, ); remaining -= chunk; if (!result.timedOut || remaining <= 0) return result; @@ -189,7 +236,13 @@ const make = Effect.gen(function* () { }, ); - return PeerForwarding.of({ call, waitForThread, links: listLinks }); + return PeerForwarding.of({ + call, + readThread, + interruptThread, + waitForThread, + links: listLinks, + }); }); export const layer = Layer.effect(PeerForwarding, make); diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index da9dbc2a1b8ff..59be493d92db0 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -152,15 +152,13 @@ export const layerLinkingEnvironment = (descriptor: ExecutionEnvironmentDescript PeerMcpClient.layer.pipe( Layer.provideMerge(PeerLinks.layer), Layer.provideMerge(Sqlite.layerMemory), - Layer.provide( - Layer.mergeAll( - ServerSecretStore.layer, - FetchHttpClient.layer, - Layer.succeed(ServerEnvironment.ServerEnvironment, { - getEnvironmentId: Effect.succeed(descriptor.environmentId), - getDescriptor: Effect.succeed(descriptor), - }), - ), + Layer.provide(Layer.mergeAll(ServerSecretStore.layer, FetchHttpClient.layer)), + // Services that act for this environment, such as delegation, name it too. + Layer.provideMerge( + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(descriptor.environmentId), + getDescriptor: Effect.succeed(descriptor), + }), ), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-here-" })), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts new file mode 100644 index 0000000000000..249a5a90729a0 --- /dev/null +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -0,0 +1,1697 @@ +import * as DocumentText from "../provider/DocumentText.ts"; +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + EventId, + MessageId, + NodeId, + type OrchestrationV2Run, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + type Project, + type RepositoryIdentity, + RunId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as SqlClient from "effect/sql/SqlClient"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as ServerConfig from "../config.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { idleThreadProjection, liveThreadShell } from "../mcp/McpToolAccess.testkit.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as PeerForwarding from "./PeerForwarding.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import { descriptorOf, layerLinkingEnvironment, linkTo, servePeer } from "./PeerLinks.testkit.ts"; +import * as RemoteDelegation from "./RemoteDelegation.ts"; + +// The laptop's agent delegates a task to the box through a link. The box is +// its real /mcp behind real OAuth, with one thread the test finishes; the +// laptop is its real orchestrator and toolkits. The parent here wakes as it +// would for a local child. + +const laptop = descriptorOf("environment-laptop", "Laptop"); +const box = descriptorOf("environment-box", "Box"); +const boxProject = ProjectId.make("project:box"); +const instanceId = ProviderInstanceId.make("codex"); +const driver = ProviderDriverKind.make("codex"); +const modelSelection = { instanceId, model: "gpt-5.4" }; + +type LaunchedWith = Pick< + ThreadLaunch.ThreadLaunchInput, + "modelSelection" | "runtimeMode" | "interactionMode" +>; + +/** The thread the box launches for the task, which the test finishes. */ +interface BoxThread { + readonly launched: Ref.Ref; + /** The link session that launched it, as the box stamps it. */ + readonly linkOrigin: Ref.Ref<{ readonly sessionId: string; readonly label: string } | null>; + /** The parent the launch names, so the thread there can link back to it. */ + readonly delegatedFrom: Ref.Ref; + /** The model and modes it was launched with. */ + readonly launchedWith: Ref.Ref; + /** The first message the thread there was launched with. */ + readonly message: Ref.Ref; + readonly status: Ref.Ref; + readonly reply: Ref.Ref; + readonly error: Ref.Ref; + readonly laterError: Ref.Ref; + /** A later run's reply, after the task's own, as a follow-up message would leave. */ + readonly laterReply: Ref.Ref; + /** The run each wait asked for, in order. */ + readonly waitedFor: Ref.Ref>; + /** The thread's run ends with a task of its own still running, whose result wakes it again. */ + readonly nested: Ref.Ref; + /** Completed once a wait there blocks on that task. */ + readonly nestedWaiting: Deferred.Deferred; + /** That task settled and the run it woke answered, with `laterReply`. */ + readonly nestedSettled: Deferred.Deferred; + /** Waiting there fails with this, as a thread deleted there does. */ + readonly gone: Ref.Ref; + readonly interrupted: Ref.Ref; + /** What each interrupt or stop there did, once per command id. */ + readonly stops: Ref.Ref>; + /** The launch starts no run, as one recorded before its run was kept. */ + readonly runless: Ref.Ref; + /** Completed on the first interrupt there. */ + readonly interruptedOnce: Deferred.Deferred; + readonly finished: Deferred.Deferred; + /** While set, reading the thread there waits: completed once a read starts, released by `releaseRead`. */ + readonly holdRead: Ref.Ref; + readonly readStarted: Deferred.Deferred; + readonly releaseRead: Deferred.Deferred; + readonly holdFirstStop: Ref.Ref; + readonly stopStarted: Deferred.Deferred; + readonly releaseStop: Deferred.Deferred; + /** Messages sent to the thread there after its launch, each starting a later run. */ + readonly sent: Ref.Ref>; + /** Runs as the launch there is answered, before the laptop hears back. */ + readonly afterLaunch: Ref.Ref>; +} + +const makeBoxThread = Effect.gen(function* () { + return { + launched: yield* Ref.make(null), + linkOrigin: yield* Ref.make<{ readonly sessionId: string; readonly label: string } | null>( + null, + ), + delegatedFrom: yield* Ref.make(null), + launchedWith: yield* Ref.make(null), + message: yield* Ref.make(null), + status: yield* Ref.make("running"), + reply: yield* Ref.make("Working…"), + error: yield* Ref.make(null), + laterError: yield* Ref.make(null), + laterReply: yield* Ref.make(null), + waitedFor: yield* Ref.make>([]), + nested: yield* Ref.make(false), + nestedWaiting: yield* Deferred.make(), + nestedSettled: yield* Deferred.make(), + gone: yield* Ref.make(false), + interrupted: yield* Ref.make(0), + stops: yield* Ref.make>([]), + runless: yield* Ref.make(false), + interruptedOnce: yield* Deferred.make(), + finished: yield* Deferred.make(), + holdRead: yield* Ref.make(false), + readStarted: yield* Deferred.make(), + releaseRead: yield* Deferred.make(), + holdFirstStop: yield* Ref.make(false), + stopStarted: yield* Deferred.make(), + releaseStop: yield* Deferred.make(), + sent: yield* Ref.make>([]), + afterLaunch: yield* Ref.make>(Effect.void), + } satisfies BoxThread; +}); + +/** The box's projects: more than a page, with this repository's last. */ +const boxProjects: ReadonlyArray = [ + ...Array.from({ length: 120 }, (_, index) => ({ + id: ProjectId.make(`project:box-other-${index}`), + title: `other ${index}`, + workspaceRoot: `/srv/other-${index}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + deletedAt: null, + })), + { + id: boxProject, + title: "app", + workspaceRoot: "/srv/app", + repositoryIdentity: { + canonicalKey: "github.com/acme/app", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:acme/app.git", + }, + }, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + deletedAt: null, + }, +]; + +const serveBox = (thread: BoxThread) => { + return servePeer(box, boxToolkitLayer(thread)); +}; + +const boxRunId = RunId.make("run:box"); +const boxLaterRunId = RunId.make("run:box-later"); + +const boxToolkitLayer = (thread: BoxThread) => { + const runId = boxRunId; + const interrupts = new Set(); + const shellOf = (threadId: ThreadId) => + Effect.gen(function* () { + if ((yield* Ref.get(thread.launched)) !== threadId) return null; + const linkOrigin = yield* Ref.get(thread.linkOrigin); + return { + ...liveThreadShell(threadId, { runtimeMode: "full-access" }), + projectId: boxProject, + ...(linkOrigin === null ? {} : { linkOrigin }), + }; + }); + const runOf = (status: OrchestrationV2Run["status"]) => + ({ + id: runId, + threadId: ThreadId.make("thread:box"), + ordinal: 1, + status, + modelSelection, + requestedAt: DateTime.makeUnsafe(0), + startedAt: DateTime.makeUnsafe(0), + completedAt: null, + }) as unknown as OrchestrationV2Run; + const projectionOf = (threadId: ThreadId) => + Effect.gen(function* () { + const shell = yield* shellOf(threadId); + const status = yield* Ref.get(thread.status); + const runless = yield* Ref.get(thread.runless); + return shell === null + ? (null as never) + : { ...idleThreadProjection(shell), runs: runless ? [] : [runOf(status)] }; + }); + // One record per command id, as its receipt replays a repeat. + const recordStop = (commandId: string, kind: "interrupt" | "thread.stop" | "delegated-tasks") => + Effect.gen(function* () { + if (interrupts.has(`${kind}:${commandId}`)) return; + interrupts.add(`${kind}:${commandId}`); + yield* Ref.update(thread.stops, (seen) => [...seen, kind]); + if (kind !== "delegated-tasks") { + yield* Ref.update(thread.interrupted, (count) => count + 1); + yield* Deferred.succeed(thread.interruptedOnce, undefined); + } + }); + const layerBoxThreads = Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: shellOf, + getProjectThreadRecords: (input) => projectionOf(input.threadId), + getThreadRecords: (threadId) => projectionOf(threadId), + waitForThread: (input) => + Effect.gen(function* () { + yield* Ref.update(thread.waitedFor, (seen) => [...seen, input.runId]); + // A thread with no run has nothing to wait for. + if (yield* Ref.get(thread.runless)) { + return { threadId: input.threadId, run: null, timedOut: false }; + } + if (yield* Ref.get(thread.gone)) { + return yield* new ThreadManagement.ThreadManagementThreadNotFoundError({ + projectId: boxProject, + threadId: input.threadId, + }); + } + const done = yield* Deferred.await(thread.finished).pipe( + Effect.timeoutOption(input.timeoutMs), + ); + // Only a waiter that asks waits past the run for the thread's own task. + if ( + Option.isSome(done) && + input.untilDelegatedWorkSettled === true && + (yield* Ref.get(thread.nested)) + ) { + yield* Deferred.succeed(thread.nestedWaiting, undefined); + const settled = yield* Deferred.await(thread.nestedSettled).pipe( + Effect.timeoutOption(input.timeoutMs), + ); + return { + threadId: input.threadId, + run: { id: runId, status: yield* Ref.get(thread.status) } as OrchestrationV2Run, + timedOut: Option.isNone(settled), + ...(Option.isSome(settled) + ? { resultRun: { id: boxLaterRunId, status: "completed" } as OrchestrationV2Run } + : {}), + }; + } + // Unpinned, a wait reports the latest run: a later one once there is one. + const later = input.runId === undefined && (yield* Ref.get(thread.laterReply)) !== null; + return { + threadId: input.threadId, + run: { + id: later ? boxLaterRunId : runId, + status: later ? "completed" : yield* Ref.get(thread.status), + } as OrchestrationV2Run, + timedOut: Option.isNone(done), + }; + }), + getTimelinePage: (threadId, options) => + Effect.gen(function* () { + if (yield* Ref.get(thread.holdRead)) { + yield* Deferred.succeed(thread.readStarted, undefined); + yield* Deferred.await(thread.releaseRead); + } + const reply = yield* Ref.get(thread.reply); + const later = yield* Ref.get(thread.laterReply); + const error = yield* Ref.get(thread.error); + const laterError = yield* Ref.get(thread.laterError); + // One item per page, with errors available only in the activity view. + const replies = [ + ...(reply === null + ? [] + : [ + { + id: TurnItemId.make("item:reply"), + runId, + text: reply, + type: "assistant_message", + }, + ]), + ...(error === null + ? [] + : [{ id: TurnItemId.make("item:error"), runId, text: error, type: "error" }]), + ...(later === null + ? [] + : [ + { + id: TurnItemId.make("item:later-reply"), + runId: boxLaterRunId, + text: later, + type: "assistant_message", + }, + ]), + ...(laterError === null + ? [] + : [ + { + id: TurnItemId.make("item:later-error"), + runId: boxLaterRunId, + text: laterError, + type: "error", + }, + ]), + ].map((entry, position) => ({ ...entry, position })); + const visible = + options.itemId === undefined + ? replies.filter( + (entry) => options.view === "activity" || entry.type === "assistant_message", + ) + : replies.filter((entry) => entry.id === options.itemId); + const matching = visible + .filter((entry) => entry.position > (options.afterPosition ?? -1)) + .slice(0, 1); + return { + items: matching.map(({ id, runId: itemRunId, text, position, type }) => ({ + position, + visibility: "local" as const, + sourceThreadId: threadId, + sourceItemId: id, + item: { + id, + threadId, + runId: itemRunId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed" as const, + title: null, + startedAt: DateTime.makeUnsafe(0), + completedAt: DateTime.makeUnsafe(0), + updatedAt: DateTime.makeUnsafe(0), + ...(type === "error" + ? { type, failure: { message: text } } + : { + type: "assistant_message" as const, + messageId: MessageId.make(`message:${id}`), + text, + streaming: false, + }), + } as never, + })), + totalItems: replies.length, + hasMore: + options.itemId === undefined && + matching.length > 0 && + visible.some((entry) => entry.position > matching.at(-1)!.position), + }; + }), + interruptThread: (input) => + recordStop(input.commandId, "interrupt").pipe( + Effect.as({ type: "interrupt_requested", run: runOf("running") } as never), + ), + dispatch: (command) => + command.type === "thread.stop" + ? recordStop(command.commandId, "thread.stop").pipe( + Effect.tap(() => + Effect.gen(function* () { + if (!(yield* Ref.getAndSet(thread.holdFirstStop, false))) return; + yield* Deferred.succeed(thread.stopStarted, undefined); + yield* Deferred.await(thread.releaseStop); + }), + ), + Effect.as({ sequence: 0, storedEvents: [] } as never), + ) + : Effect.die(`The box does not expect ${command.type}`), + stopDelegatedTasks: (input) => recordStop(input.commandId, "delegated-tasks"), + sendToThread: (input) => + Ref.update(thread.sent, (seen) => [...seen, input.text]).pipe( + Effect.as({ + run: { id: boxLaterRunId, status: "running" }, + delivery: "started", + } as never), + ), + }); + const layerBoxLaunches = Layer.mock(ThreadLaunch.ThreadLaunchService)({ + launch: (input) => + Ref.get(thread.runless).pipe( + Effect.flatMap((runless) => + Ref.set(thread.launched, input.threadId!).pipe( + Effect.andThen(Ref.set(thread.linkOrigin, input.linkOrigin ?? null)), + Effect.andThen(Ref.set(thread.delegatedFrom, input.delegatedFrom ?? null)), + Effect.andThen(Ref.set(thread.message, input.initialMessage?.text ?? null)), + Effect.andThen( + Ref.set(thread.launchedWith, { + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }), + ), + Effect.andThen(Effect.flatten(Ref.get(thread.afterLaunch))), + Effect.as({ + threadId: input.threadId, + projection: { + thread: { + id: input.threadId, + projectId: input.projectId, + title: input.title, + modelSelection: input.modelSelection, + }, + runs: + input.initialMessage?.messageId && !runless + ? [ + { + id: runId, + status: "running", + userMessageId: input.initialMessage.messageId, + }, + ] + : [], + }, + resumed: false, + } as unknown as ThreadLaunch.ThreadLaunchResult), + ), + ), + ), + }); + return Layer.merge( + McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerProjectRegistration, + ).pipe( + Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), + Layer.provide(NodeCrypto.layer), + Layer.provide(layerBoxThreads), + Layer.provide(layerBoxLaunches), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/p" }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-remote-delegation-box-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeedNone, + // More than a page, so finding the project with this repository pages. + listPage: ({ cursor, limit }) => + Effect.succeed({ + projects: boxProjects.slice(cursor, cursor + limit), + nextCursor: cursor + limit < boxProjects.length ? cursor + limit : null, + }), + }), + ), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(NodeServices.layer), + ); +}; + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "remote-delegation", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "remote-delegation", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const adapter = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; + +/** + * The laptop: a real orchestrator (in-memory SQLite) whose parent thread has + * a live run, the real orchestrator toolkit, and RemoteDelegation. Built in + * its own scope so a test can tear it down and build it again on the same + * database, as a restart does. + */ +const makeLaptop = ( + database: Context.Context, + offers: SubscriptionRef.SubscriptionRef< + ReadonlyArray + >, + /** The laptop's links from before a restart, which keep their sessions there. */ + kept?: Context.Context>>, +) => + Effect.gen(function* () { + const linking = kept ?? (yield* layerLinkingEnvironment(laptop).pipe(Layer.build)); + const layerDatabase = Layer.succeedContext(database); + const layerOrchestrator = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "remote-delegation-laptop" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), + ).pipe( + Layer.provide( + Layer.succeed(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: (request) => SubscriptionRef.update(offers, (seen) => [...seen, request]), + take: Effect.never, + }), + ), + ); + const layerThreads = ThreadManagement.layer.pipe( + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + Layer.provideMerge(Layer.merge(layerOrchestrator, ServerSettings.layerTest())), + ); + const layerHere = McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge( + RemoteDelegation.layer.pipe( + Layer.provideMerge(PeerForwarding.layer), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + // A cold enrichment cache: the project itself reports no identity yet. + getById: () => + Effect.succeedSome({ + workspaceRoot: "/home/me/app", + repositoryIdentity: null, + } as never), + }), + ), + Layer.provide( + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: (cwd) => + Effect.succeed( + cwd === "/home/me/app" + ? ({ canonicalKey: "github.com/acme/app" } as RepositoryIdentity) + : null, + ), + }), + ), + ), + ), + Layer.provide(Layer.succeedContext(linking)), + Layer.provide(NodeCrypto.layer), + Layer.provideMerge(layerThreads), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.fresh, + ); + const here = yield* Layer.build(layerHere); + const server = Context.get(here, McpServer.McpServer); + return { + links: Context.get(linking, PeerLinks.PeerLinks), + orchestrator: Context.get(here, Orchestrator.OrchestratorV2), + remote: Context.get(here, RemoteDelegation.RemoteDelegation), + threads: Context.get(here, ThreadManagement.ThreadManagementService), + projections: Context.get(here, ProjectionStore.ProjectionStoreV2), + sink: Context.get(here, EventSink.EventSinkV2), + call: (name: string, args: Record, scope = parentScope) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ), + }; + }); + +const parentThreadId = ThreadId.make("thread:laptop-parent"); +const parentRunId = RunId.make("run:laptop-parent"); +const parentRootNode = NodeId.make("node:laptop-parent:root"); +const parentScope: McpInvocationContext.McpInvocationScope = { + environmentId: laptop.environmentId, + requestNamespace: "provider-session:laptop-parent", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:laptop-parent", + providerInstanceId: instanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, +}; + +/** The laptop's parent thread, mid-turn when its agent delegates. */ +const seedParent = (laptopEnv: Effect.Success>) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const providerThreadId = ProviderThreadId.make("provider-thread:laptop-parent"); + yield* laptopEnv.orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:create:laptop-parent"), + threadId: parentThreadId, + projectId: ProjectId.make("project:laptop"), + title: "Laptop parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* laptopEnv.sink.write({ + commandId: CommandId.make("command:seed:laptop-parent"), + events: [ + { + id: EventId.make("event:seed-provider-thread:laptop-parent"), + type: "provider-thread.updated", + threadId: parentThreadId, + driver, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: parentThreadId, + ownerNodeId: parentRootNode, + nativeThreadRef: { driver, nativeId: "native:laptop-parent", strength: "strong" }, + nativeConversationHeadRef: null, + status: "active", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make("event:seed-node:laptop-parent"), + type: "node.updated", + threadId: parentThreadId, + runId: parentRunId, + nodeId: parentRootNode, + driver, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: parentRootNode, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId: null, + rootNodeId: parentRootNode, + kind: "root_turn", + status: "running", + countsForRun: true, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("event:seed-run:laptop-parent"), + type: "run.updated", + threadId: parentThreadId, + runId: parentRunId, + nodeId: parentRootNode, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: parentRunId, + threadId: parentThreadId, + ordinal: 1, + providerInstanceId: instanceId, + modelSelection, + providerThreadId, + userMessageId: MessageId.make("message:seed-user:laptop-parent"), + rootNodeId: parentRootNode, + activeAttemptId: null, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + ], + }); + }); + +const delegateToBox = (laptopEnv: Effect.Success>) => + laptopEnv + .call("delegate_task", { + task: "Run the full suite on the box.", + target: { + environmentId: box.environmentId, + providerInstanceId: instanceId, + model: "gpt-5.4", + }, + mode: "async", + clientRequestId: "suite-on-box", + }) + .pipe( + Effect.map((result) => { + expect(result.isError, JSON.stringify(result.content)).toBe(false); + return result.structuredContent as { + taskId: NodeId; + status: string; + childThreadId: null; + remoteChild: { environmentId: string; threadId: ThreadId; label: string }; + }; + }), + ); + +/** Waits until the task here has the result its thread there ended with. */ +const taskResult = (laptopEnv: Effect.Success>, taskId: NodeId) => + laptopEnv.orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: 0, + eventType: "subagent.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === taskId && + stored.event.payload.result !== null, + ), + Stream.runHead, + Effect.map((stored) => + Option.isSome(stored) && stored.value.event.type === "subagent.updated" + ? stored.value.event.payload + : undefined, + ), + ); + +it.effect("a task delegated to the box wakes the laptop's parent when it ends there", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + + const delegated = yield* delegateToBox(a); + expect(delegated).toMatchObject({ + status: "running", + childThreadId: null, + remoteChild: { environmentId: box.environmentId, label: "Box" }, + }); + // The box launched it in the project with the same repository. + expect(yield* Ref.get(thread.launched)).toBe(delegated.remoteChild.threadId); + // ...naming the parent here, so the thread there links back to it. + expect(yield* Ref.get(thread.delegatedFrom)).toEqual({ + environmentId: laptop.environmentId, + threadId: parentThreadId, + title: "Laptop parent", + }); + // A retry with the same key launches nothing new and records nothing new. + const retried = yield* delegateToBox(a); + expect(retried.taskId).toBe(delegated.taskId); + expect((yield* a.orchestrator.getThreadProjection(parentThreadId)).subagents).toHaveLength(1); + + yield* Ref.set(thread.reply, "All 412 tests pass on the box."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + const task = yield* taskResult(a, delegated.taskId); + expect(task).toMatchObject({ status: "completed", result: "All 412 tests pass on the box." }); + // The parent's live run claims the result, as for a child here. + const parentRun = (yield* a.orchestrator.getThreadProjection(parentThreadId)).runs.find( + (run) => run.id === parentRunId, + ); + expect(parentRun?.delegatedCompletion?.delivery?.taskIds).toEqual([delegated.taskId]); + // The wake is offered after the result commits, so the result can be seen first. + const offered = yield* SubscriptionRef.changes(offers).pipe( + Stream.filter((seen) => seen.some((offer) => offer.threadId === parentThreadId)), + Stream.runHead, + ); + expect(Option.isSome(offered)).toBe(true); + + const status = yield* a.call("task_status", { taskId: delegated.taskId }); + expect(status.structuredContent).toMatchObject({ + status: "completed", + summary: "All 412 tests pass on the box.", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a task named only by environment inherits the parent's model, within the link", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + // The parent runs with full access; the link allows less. + yield* linkTo(a.links, b, "auto-accept-edits"); + yield* seedParent(a); + + const result = yield* a.call("delegate_task", { + task: "Run the suite on the box.", + target: { environmentId: box.environmentId }, + }); + expect(result.isError, JSON.stringify(result.content)).toBe(false); + // The parent's model, and the link's modes rather than the parent's. + expect(yield* Ref.get(thread.launchedWith)).toEqual({ + modelSelection, + runtimeMode: "auto-accept-edits", + interactionMode: "default", + }); + + // Asking for more than the link allows is still refused there. + const broader = yield* a.call("delegate_task", { + task: "Run the suite on the box.", + target: { environmentId: box.environmentId }, + runtimeMode: "full-access", + clientRequestId: "broader", + }); + expect(broader.isError).toBe(true); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("refuses worktree isolation for a task there before launching anything", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "auto"); + yield* seedParent(a); + + const result = yield* a.call("delegate_task", { + task: "Run the suite on the box.", + target: { environmentId: box.environmentId }, + isolation: "worktree", + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("isolation"); + expect(yield* Ref.get(thread.launched)).toBeNull(); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("follows an open task again after the laptop restarts", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + // The laptop before the restart delegates, then goes away. + const delegated = yield* Effect.scoped( + Effect.gen(function* () { + const before = yield* makeLaptop(database, offers); + yield* linkTo(before.links, b, "full-access"); + yield* seedParent(before); + return yield* delegateToBox(before); + }), + ); + // The task finishes on the box while the laptop is down. + yield* Ref.set(thread.reply, "Finished while you were away."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + + const after = yield* makeLaptop(database, offers); + // The link lives in the laptop's own store, so it needs linking again here. + yield* linkTo(after.links, b, "full-access"); + yield* after.remote.start(); + const task = yield* taskResult(after, delegated.taskId); + expect(task).toMatchObject({ status: "completed", result: "Finished while you were away." }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("fails the task when the box revokes the link, and cancel interrupts it there", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + + const cancelled = yield* delegateToBox(a); + const cancel = yield* a.call("task_cancel", { + taskId: cancelled.taskId, + reason: "Not needed", + }); + expect(cancel.structuredContent).toEqual({ + taskId: cancelled.taskId, + status: "cancel_requested", + }); + // Cancelling stops the thread there as Stop does: its queue and its own tasks too. + expect(yield* Ref.get(thread.stops)).toEqual(["thread.stop", "delegated-tasks"]); + expect(yield* taskResult(a, cancelled.taskId)).toMatchObject({ status: "cancelled" }); + + // Cancelling again, once the result is in, still stops what came after it there. + const again = yield* a.call("task_cancel", { + taskId: cancelled.taskId, + clientRequestId: "cancel-again", + }); + expect(again.structuredContent).toEqual({ taskId: cancelled.taskId, status: "cancelled" }); + expect(yield* Ref.get(thread.interrupted)).toBe(2); + // ...and a retry of that cancel stops nothing new. + yield* a.call("task_cancel", { taskId: cancelled.taskId, clientRequestId: "cancel-again" }); + expect(yield* Ref.get(thread.interrupted)).toBe(2); + + // A second task, then the box revokes the link before it ends. + const revoked = yield* a + .call("delegate_task", { + task: "Another one.", + target: { + environmentId: box.environmentId, + providerInstanceId: instanceId, + model: "gpt-5.4", + }, + clientRequestId: "second", + }) + .pipe(Effect.map((result) => result.structuredContent as { taskId: NodeId })); + const [session] = yield* b.linkedSessions; + yield* b.auth.revokeSession(session!.sessionId); + yield* Deferred.succeed(thread.finished, undefined); + const failed = yield* taskResult(a, revoked.taskId); + expect(failed?.status).toBe("failed"); + expect(failed?.result).toBe( + "The linked environment no longer accepts this link. It may have been revoked there; link it again.", + ); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a task's result is its own run's whole last reply, not a later run's", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + + const result = yield* a.call("delegate_task", { + task: "Review the diff.", + role: "review", + target: { environmentId: box.environmentId }, + clientRequestId: "review-on-box", + }); + expect(result.isError, JSON.stringify(result.content)).toBe(false); + const { taskId } = result.structuredContent as { taskId: NodeId }; + // The role reaches the thread there, as it does a child here. + expect(yield* Ref.get(thread.message)).toBe( + "Act as the review sub-agent for this task.\n\nReview the diff.", + ); + + // A reply longer than one read, then a later run's reply on the same thread. + const long = "x".repeat(45_000); + yield* Ref.set(thread.reply, long); + yield* Ref.set(thread.laterReply, "Someone else asked a follow-up."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + const task = yield* taskResult(a, taskId); + expect(task?.status).toBe("completed"); + expect(task?.result).toBe(long); + // Every wait was for the run the launch started. + const waits = yield* Ref.get(thread.waitedFor); + expect(waits.length).toBeGreaterThan(0); + expect(waits.every((runId) => runId === boxRunId)).toBe(true); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a task whose thread there waits on its own task ends with the answer after it", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + + // The task's run there ends after delegating a review of its own. + yield* Ref.set(thread.nested, true); + yield* Ref.set(thread.reply, "Started a review; I will answer when it reports."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + const first = yield* Effect.raceFirst( + Deferred.await(thread.nestedWaiting).pipe(Effect.as("waiting there" as const)), + taskResult(a, delegated.taskId).pipe(Effect.as("completed here" as const)), + ); + expect(first).toBe("waiting there"); + + // The review reports and wakes the thread there, whose next run answers. + yield* Ref.set(thread.laterReply, "The review found two issues; both are fixed."); + yield* Deferred.succeed(thread.nestedSettled, undefined); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "The review found two issues; both are fixed.", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("the same key from another provider session is another task", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + + const first = yield* delegateToBox(a); + const later = yield* a.call( + "delegate_task", + { + task: "Run the full suite on the box.", + target: { environmentId: box.environmentId }, + clientRequestId: "suite-on-box", + }, + { ...parentScope, requestNamespace: "provider-session:laptop-parent-2" }, + ); + expect(later.isError, JSON.stringify(later.content)).toBe(false); + const second = later.structuredContent as { + taskId: NodeId; + remoteChild: { threadId: ThreadId }; + }; + expect(second.taskId).not.toBe(first.taskId); + expect(second.remoteChild.threadId).not.toBe(first.remoteChild.threadId); + expect((yield* a.orchestrator.getThreadProjection(parentThreadId)).subagents).toHaveLength(2); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect.each([null, "Working before the provider failed."])( + "reports the failed remote run's error instead of its earlier reply (%s)", + (reply) => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + yield* Ref.set(thread.reply, reply); + const error = "Provider failed: authentication expired on the destination."; + yield* Ref.set(thread.error, error); + yield* Ref.set(thread.status, "failed"); + yield* Deferred.succeed(thread.finished, undefined); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "failed", + result: error, + }); + const status = yield* a.call("task_status", { taskId: delegated.taskId }); + expect(status.structuredContent).toMatchObject({ + status: "failed", + summary: error, + latestTerminalSummary: error, + }); + const offered = yield* SubscriptionRef.changes(offers).pipe( + Stream.filter((seen) => seen.some((offer) => offer.threadId === parentThreadId)), + Stream.runHead, + ); + expect(Option.isSome(offered)).toBe(true); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect.each([false, true])( + "ignores later-run errors and reads the failed run's full error (ownError=%s)", + (ownError) => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + yield* Ref.set(thread.reply, null); + const error = `Provider failed: ${"detail ".repeat(10_000)}end of error.`; + if (ownError) yield* Ref.set(thread.error, error); + yield* Ref.set(thread.laterReply, "A later user run answered."); + yield* Ref.set(thread.laterError, "Another run's provider error."); + yield* Ref.set(thread.status, "failed"); + yield* Deferred.succeed(thread.finished, undefined); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "failed", + result: ownError + ? error + : "(The thread in the linked environment ended without a reply.)", + }); + expect(yield* Ref.get(thread.waitedFor)).toEqual([boxRunId]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("keeps a successful remote reply despite an error item in the run's activity", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + yield* Ref.set(thread.reply, "Recovered and completed the task."); + yield* Ref.set(thread.error, "A recoverable tool error."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "Recovered and completed the task.", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("fails the task when its thread there is gone, or the link here is", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + + yield* Ref.set(thread.gone, true); + const gone = yield* delegateToBox(a); + const failed = yield* taskResult(a, gone.taskId); + expect(failed?.status).toBe("failed"); + expect(failed?.result).toContain("was not found"); + + // The link is forgotten here while the task runs there. + yield* Ref.set(thread.gone, false); + const forgotten = yield* a + .call("delegate_task", { + task: "Another one.", + target: { environmentId: box.environmentId }, + clientRequestId: "forgotten", + }) + .pipe(Effect.map((result) => result.structuredContent as { taskId: NodeId })); + yield* a.links.unlink(box.environmentId); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + const unlinked = yield* taskResult(a, forgotten.taskId); + expect(unlinked?.status).toBe("failed"); + expect(unlinked?.result).toContain("not linked to"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("cancel ends the task here even when the link is gone", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + + const delegated = yield* delegateToBox(a); + yield* a.links.unlink(box.environmentId); + const cancel = yield* a.call("task_cancel", { taskId: delegated.taskId }); + expect(cancel.structuredContent).toEqual({ + taskId: delegated.taskId, + status: "cancel_requested", + }); + const task = yield* taskResult(a, delegated.taskId); + expect(task?.status).toBe("cancelled"); + expect(task?.result).toContain("could not be stopped in Box"); + expect(yield* Ref.get(thread.interrupted)).toBe(0); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a parent stopped while the task's result is read still stops its thread there", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + + // The task's run ends there, and while the follower reads its reply, + // the parent here is stopped: more work may follow that run there. + yield* Ref.set(thread.holdRead, true); + yield* Ref.set(thread.reply, "Done there."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + yield* Deferred.await(thread.readStarted); + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent-mid-read"), + }); + yield* Deferred.succeed(thread.releaseRead, undefined); + + // The Stop's result stands, and the thread there is still stopped. + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ status: "interrupted" }); + yield* Deferred.await(thread.interruptedOnce); + expect(yield* Ref.get(thread.stops)).toContain("thread.stop"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a stop the box refuses because the parent was lowered ends instead of retrying", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + + // The parent is lowered below the full-access thread there, then stopped. + yield* a.orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("command:lower:laptop-parent"), + threadId: parentThreadId, + runtimeMode: "approval-required", + }); + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent-lowered"), + }); + + // The box refuses that stop; the follower records it as settled and + // stops, so nothing retries it, now or after a restart. + const settled = yield* a.orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: 0, + eventType: "subagent.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === delegated.taskId && + stored.event.payload.remoteChild?.stoppedThere === true, + ), + Stream.runHead, + ); + expect(Option.isSome(settled)).toBe(true); + expect(yield* Ref.get(thread.stops)).toEqual([]); + expect(yield* a.projections.getOpenRemoteDelegatedTasks).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("stopping the parent stops its task there", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + + const delegated = yield* delegateToBox(a); + // What the delegated-tasks.stop effect runs once the parent's Stop commits. + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent"), + reason: "User stopped", + }); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "interrupted", + result: "Stopped with its parent: User stopped", + }); + // The follower sees it ended here and stops it there. + yield* Deferred.await(thread.interruptedOnce); + expect(yield* Ref.get(thread.interrupted)).toBe(1); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +/** A follower retry schedule the test steps: each failed attempt is offered, then waits for `retry`. */ +const steppedRetries = Effect.gen(function* () { + const failed = yield* Queue.unbounded(); + const retry = yield* Queue.unbounded(); + const schedule = Schedule.forever.pipe( + Schedule.tap(() => Queue.offer(failed, undefined).pipe(Effect.andThen(Queue.take(retry)))), + ); + return { failed, retry, schedule }; +}); + +/** Waits until the task here records that the `stopCount`th Stop after its result reached there. */ +const stoppedThere = ( + laptopEnv: Effect.Success>, + taskId: NodeId, + // `null` for a cancel's stop, which carries no count. + stopCount: number | null = 1, +) => + laptopEnv.orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: 0, + eventType: "subagent.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === taskId && + stored.event.payload.remoteChild?.stoppedThere === true && + stored.event.payload.remoteChild.stopCount === (stopCount ?? undefined), + ), + Stream.runHead, + Effect.map((stored) => + Option.isSome(stored) && stored.value.event.type === "subagent.updated" + ? stored.value.event.payload + : undefined, + ), + ); + +it.effect("stopping the parent stops follow-up work on a task there that has its result", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* a.remote.start(); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + yield* Ref.set(thread.reply, "All green."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "All green.", + }); + + // The parent sends the thread there a follow-up, which starts a run there. + const sent = yield* a.call("t3_thread_send", { + environmentId: box.environmentId, + threadId: delegated.remoteChild.threadId, + message: "Now run the slow suite too.", + }); + expect(sent.isError, JSON.stringify(sent.content)).toBe(false); + expect(yield* Ref.get(thread.sent)).toEqual(["Now run the slow suite too."]); + expect(yield* Ref.get(thread.interrupted)).toBe(0); + + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent-after-result"), + }); + // The thread there is stopped as Stop does, and the task keeps its result. + const task = yield* stoppedThere(a, delegated.taskId); + expect(yield* Ref.get(thread.stops)).toEqual(["thread.stop", "delegated-tasks"]); + expect(task).toMatchObject({ status: "completed", result: "All green." }); + expect(yield* a.projections.getOpenRemoteDelegatedTasks).toEqual([]); + + // A second Stop owes a second stop there, not a replay of the first. + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent-again"), + }); + expect(yield* stoppedThere(a, delegated.taskId, 2)).toMatchObject({ status: "completed" }); + expect(yield* Ref.get(thread.interrupted)).toBe(2); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a delayed Stop acknowledgement leaves a newer Stop owed", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* a.remote.start(); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + yield* Ref.set(thread.reply, "All green."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + yield* taskResult(a, delegated.taskId); + + yield* Ref.set(thread.holdFirstStop, true); + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:delayed-first"), + }); + yield* Deferred.await(thread.stopStarted); + // Work starts after the first Stop landed there, but before its acknowledgement returns. + yield* a.call("t3_thread_send", { + environmentId: box.environmentId, + threadId: delegated.remoteChild.threadId, + message: "Run another check.", + }); + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:delayed-second"), + }); + const pending = yield* a.threads.getThreadRecords(parentThreadId, ["subagents"]); + expect( + pending.subagents.find((task) => task.id === delegated.taskId)?.remoteChild, + ).toMatchObject({ stoppedThere: false, stopCount: 2 }); + yield* Deferred.succeed(thread.releaseStop, undefined); + expect(yield* stoppedThere(a, delegated.taskId, 2)).toMatchObject({ + status: "completed", + result: "All green.", + }); + expect(yield* Ref.get(thread.interrupted)).toBe(2); + expect(yield* a.projections.getOpenRemoteDelegatedTasks).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a Stop on a task that has its result reaches there after a restart", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); + // The laptop records the Stop, then goes away before its follower sends it. + const delegated = yield* Effect.scoped( + Effect.gen(function* () { + const before = yield* makeLaptop(database, offers, linking); + yield* linkTo(before.links, b, "full-access"); + yield* seedParent(before); + const task = yield* delegateToBox(before); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + yield* taskResult(before, task.taskId); + yield* before.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent-before-restart"), + }); + return task; + }), + ); + expect(yield* Ref.get(thread.interrupted)).toBe(0); + + const after = yield* makeLaptop(database, offers, linking); + expect(yield* after.projections.getOpenRemoteDelegatedTasks).toEqual([ + { parentThreadId, taskId: delegated.taskId }, + ]); + yield* after.remote.start(); + expect(yield* stoppedThere(after, delegated.taskId)).toMatchObject({ status: "completed" }); + expect(yield* Ref.get(thread.stops)).toEqual(["thread.stop", "delegated-tasks"]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a parent stopped while the box is unreachable still stops its task there", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const retries = yield* steppedRetries; + const a = yield* makeLaptop(database, offers).pipe( + Effect.provideService(RemoteDelegation.FollowRetrySchedule, retries.schedule), + ); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + // The box sleeps as soon as the task is there, so its follower's first attempt fails. + yield* Ref.set( + thread.afterLaunch, + Ref.set(b.descriptor, descriptorOf("environment-elsewhere", "Elsewhere")), + ); + const delegated = yield* delegateToBox(a); + yield* Queue.take(retries.failed); + // The parent is stopped meanwhile. + yield* a.threads.stopDelegatedTasks({ + threadId: parentThreadId, + commandId: CommandId.make("command:stop:laptop-parent-offline"), + }); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ status: "interrupted" }); + + // Once the box is back, the follower's next attempt stops the task there. + yield* Ref.set(b.descriptor, box); + yield* Queue.offer(retries.retry, undefined); + yield* Deferred.await(thread.interruptedOnce); + expect(yield* Ref.get(thread.interrupted)).toBe(1); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a task cancelled while the box is unreachable is stopped there after a restart", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + // Its links outlive the restart, so the box knows it as the same link session. + const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); + // The laptop cancels while the box sleeps, then restarts before it wakes. + const delegated = yield* Effect.scoped( + Effect.gen(function* () { + const before = yield* makeLaptop(database, offers, linking); + yield* linkTo(before.links, b, "full-access"); + yield* seedParent(before); + const task = yield* delegateToBox(before); + yield* Ref.set(b.descriptor, descriptorOf("environment-elsewhere", "Elsewhere")); + yield* before.call("task_cancel", { taskId: task.taskId }); + expect(yield* taskResult(before, task.taskId)).toMatchObject({ status: "cancelled" }); + return task; + }), + ); + expect(yield* Ref.get(thread.interrupted)).toBe(0); + + yield* Ref.set(b.descriptor, box); + const after = yield* makeLaptop(database, offers, linking); + yield* after.remote.start(); + yield* Deferred.await(thread.interruptedOnce); + // Once the stop is recorded, nothing is owed there any more. + yield* stoppedThere(after, delegated.taskId, null); + expect(yield* after.projections.getOpenRemoteDelegatedTasks).toEqual([]); + expect(yield* Ref.get(thread.stops)).toEqual(["thread.stop", "delegated-tasks"]); + // The result here is still the cancel's. + expect(yield* taskResult(after, delegated.taskId)).toMatchObject({ status: "cancelled" }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a task whose thread there has no run ends with its last reply", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + // As a task recorded before its run was kept, whose thread there is idle. + yield* Ref.set(thread.runless, true); + yield* Ref.set(thread.reply, "Done before it was followed."); + + const delegated = yield* delegateToBox(a); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "Done before it was followed.", + }); + // One look, not a loop. + expect(yield* Ref.get(thread.waitedFor)).toEqual([undefined]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a local task runs in this thread's project", () => + Effect.scoped( + Effect.gen(function* () { + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* seedParent(a); + + const result = yield* a.call("delegate_task", { + task: "Run it here.", + target: { projectId: "project:elsewhere" }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("invalid_request"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts new file mode 100644 index 0000000000000..739edc0a6039b --- /dev/null +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -0,0 +1,801 @@ +import { + CommandId, + type EnvironmentId, + NodeId, + OrchestratorMcpFailure, + type OrchestratorMcpDelegateTaskInput, + type OrchestratorMcpTaskCancelResult, + type OrchestratorMcpThreadTimelineItem, + type OrchestrationV2RemoteTaskChild, + type OrchestrationV2Subagent, + type ProjectId, + type ProviderInstanceId, + ProviderDriverKind, + type RunId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type * as Tool from "effect/ai/Tool"; + +import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; +import { + resolveInteractionMode, + resolveRuntimeMode, + taskPrompt, +} from "../mcp/delegatedTaskModes.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as PeerForwarding from "./PeerForwarding.ts"; +import * as PeerLinks from "./PeerLinks.ts"; + +/** How long one follow of a remote task waits on its thread there before checking again. */ +const FOLLOW_WAIT_MS = 10 * 60 * 1_000; +/** A linked environment that stops answering is retried, backing off to this. */ +const FOLLOW_MAX_BACKOFF = Duration.minutes(5); +/** How a follower retries; tests step it by hand. */ +export const FollowRetrySchedule = Context.Reference>( + "t3/peer/RemoteDelegation/FollowRetrySchedule", + { + defaultValue: () => + Schedule.exponential(Duration.seconds(2)).pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, FOLLOW_MAX_BACKOFF)), + ), + ), + }, +); + +type ThreadScope = McpInvocationContext.McpThreadInvocationScope; + +/** + * `delegate_task` to a linked environment. The task's child is an ordinary + * thread there, launched through the link; the parent here records the task + * like a local one, without a child thread, and a follower completes it when + * the thread there ends, so the parent wakes as it would for a local child. + */ +export class RemoteDelegation extends Context.Service< + RemoteDelegation, + { + readonly delegate: ( + scope: ThreadScope, + input: OrchestratorMcpDelegateTaskInput & { + readonly target: NonNullable & { + readonly environmentId: EnvironmentId; + }; + }, + ) => Effect.Effect<{ readonly taskId: NodeId }, OrchestratorMcpFailure>; + /** + * Stops the task's thread there, then completes it here as cancelled. A + * task that already has its result keeps it; its thread there is still + * stopped, as cancelling a local task stops follow-up work on its child. + */ + readonly cancel: ( + scope: ThreadScope, + task: OrchestrationV2Subagent, + input: { + readonly reason?: string | undefined; + readonly clientRequestId?: string | undefined; + }, + ) => Effect.Effect; + /** + * Follows every open remote task until it ends, including after a + * restart, and a finished one again when a Stop here owes it a stop there. + */ + readonly start: () => Effect.Effect; + } +>()("t3/peer/RemoteDelegation") {} + +/** The other side refused because the thread there runs above this caller's modes. */ +const isModeRefusal = (error: OrchestratorMcpFailure) => + error.code === "runtime_mode_escalation_denied" || + error.code === "interaction_mode_escalation_denied"; + +/** + * Refusals that following again cannot change: the link was revoked, expired + * or forgotten, or the thread or run there is gone. Anything else (the other + * machine asleep, a dropped route) is worth retrying. + */ +const isFinal = (error: OrchestratorMcpFailure) => + error.code === "capability_denied" || + error.code === "thread_not_found" || + error.code === "run_not_found"; + +const make = Effect.gen(function* () { + const followRetry = yield* FollowRetrySchedule; + const forwarding = yield* PeerForwarding.PeerForwarding; + const links = yield* PeerLinks.PeerLinks; + const threads = yield* ThreadManagement.ThreadManagementService; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const projects = yield* ProjectService.ProjectService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const crypto = yield* Crypto.Crypto; + const hereId = yield* ServerEnvironment.ServerEnvironment.pipe( + Effect.flatMap((environment) => environment.getEnvironmentId), + ); + // Followers live as long as this service, so shutting down stops them. + const followers = yield* Scope.Scope; + + // One follower runs per open task and holds what it read across a wait of + // up to FOLLOW_WAIT_MS, so it reads its own task, never the parent's whole + // task list: a parent with thousands of tasks made every restart decode + // that list once per open task at the same time and ran out of heap. + const readTask = (parentThreadId: ThreadId, taskId: NodeId) => + threads + .getThreadRecords(parentThreadId, ["subagents"], { subagentIds: [taskId] }) + .pipe(Effect.map(({ thread, subagents }) => ({ thread, task: subagents[0] }))); + + /** The peer's project with this thread's repository, or the one the caller named. */ + const resolveRemoteProject = Effect.fn("RemoteDelegation.resolveRemoteProject")(function* ( + scope: ThreadScope, + environmentId: EnvironmentId, + parentProjectId: ProjectId, + requested: ProjectId | undefined, + ) { + if (requested !== undefined) return requested; + const here = yield* projects.getById(parentProjectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ); + // The project's own identity is a one-minute cache that reads blank when + // cold, so resolve it here, as the other side's t3_project_list does. + const key = + here === undefined + ? undefined + : (yield* repositoryIdentities.resolve(here.workspaceRoot))?.canonicalKey; + if (key === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "target_required", + message: "Pass target.projectId: this thread's project has no repository to match there.", + }); + } + const matches: Array<{ readonly id: ProjectId; readonly title: string }> = []; + let cursor: number | null = 0; + while (cursor !== null) { + const listed: Tool.Success = + yield* forwarding.call(scope, ProjectToolkit.tools.t3_project_list, environmentId, { + cursor, + limit: 100, + }); + matches.push( + ...listed.projects.filter((project) => project.repositoryIdentity?.canonicalKey === key), + ); + cursor = listed.nextCursor; + } + if (matches.length === 1) return matches[0]!.id; + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + matches.length === 0 + ? `No project there has this repository (${key}). Pass target.projectId; t3_project_list with this environmentId lists them. To bring the repository there, use t3_project_clone and then t3_project_create with this environmentId.` + : `Several projects there have this repository: ${matches.map((project) => `${project.title} (${project.id})`).join(", ")}. Pass target.projectId.`, + }); + }); + + const delegate: RemoteDelegation["Service"]["delegate"] = Effect.fn("RemoteDelegation.delegate")( + function* (scope, input) { + const parent = yield* threads.getThreadRecords(scope.thread.threadId, ["runs"]).pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "thread_not_found", + message: "The calling thread was not found.", + }), + ), + ); + const parentRun = parent.runs + .filter(ThreadManagement.isActiveRun) + .toSorted((left, right) => right.ordinal - left.ordinal)[0]; + if ( + parentRun === undefined || + parentRun.rootNodeId === null || + parentRun.providerInstanceId !== scope.thread.providerInstanceId + ) { + return yield* new OrchestratorMcpFailure({ + code: "parent_not_active", + message: "Delegated tasks require an active run owned by this MCP provider session.", + }); + } + const parentNodeId = parentRun.rootNodeId; + const { target } = input; + // A worktree there would branch from a commit this checkout has and the + // other may not, so a remote task runs in that project's checkout. + if (input.isolation === "worktree") { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + 'A task in a linked environment runs in that project\'s checkout; isolation "worktree" is not supported there. Omit isolation, or use t3_thread_launch with environmentId and a worktree workspaceStrategy.', + }); + } + // The provider and model inherit as they do for a task here. Another + // provider there has no model to inherit, so the caller names one. + const inherited = parent.thread.modelSelection; + const instanceId = target.providerInstanceId ?? inherited.instanceId; + const model = + target.model ?? (instanceId === inherited.instanceId ? inherited.model : undefined); + if (model === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "target_required", + message: "Pass target.model from orchestrator_capabilities with this environmentId.", + }); + } + const options = + target.options ?? + (instanceId === inherited.instanceId && model === inherited.model + ? inherited.options + : undefined); + // Modes asked for are checked against this thread. Omitted ones are left + // to the other side, which inherits this thread's modes capped by the + // link's access, so a narrower link still takes the task. + const runtimeMode = + input.runtimeMode === undefined || input.runtimeMode === "inherit" + ? undefined + : yield* resolveRuntimeMode(parent.thread.runtimeMode, input.runtimeMode); + const interactionMode = + input.interactionMode === undefined || input.interactionMode === "inherit" + ? undefined + : yield* resolveInteractionMode(parent.thread.interactionMode, input.interactionMode); + const projectId = yield* resolveRemoteProject( + scope, + target.environmentId, + parent.thread.projectId, + target.projectId, + ); + // One key for the launch there and the record here, so a retry finds both. + // Both are scoped to the provider session, as every key a caller passes is: + // the forwarder scopes the launch's, and the record's names it. + const key = input.clientRequestId ?? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)); + const task = taskPrompt(input); + const launched = yield* forwarding.call( + scope, + ProjectToolkit.tools.t3_thread_launch, + target.environmentId, + { + projectId, + title: input.title ?? parent.thread.title, + modelSelection: { instanceId, model, ...(options === undefined ? {} : { options }) }, + ...(runtimeMode === undefined ? {} : { runtimeMode }), + ...(interactionMode === undefined ? {} : { interactionMode }), + message: task, + clientRequestId: `delegate:${scope.thread.threadId}:${key}`, + // The thread there shows it is this thread's subagent, with a way back. + delegatedFrom: { + environmentId: hereId, + threadId: parent.thread.id, + title: parent.thread.title, + }, + }, + ); + const link = yield* links.get(target.environmentId).pipe( + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ); + const commandId = CommandId.make( + `command:mcp:${encodeURIComponent(scope.requestNamespace)}:remote-delegate:${encodeURIComponent(key)}`, + ); + const remoteChild: OrchestrationV2RemoteTaskChild = { + environmentId: target.environmentId, + threadId: launched.threadId, + label: link?.label ?? target.environmentId, + ...(launched.runId === null ? {} : { runId: launched.runId }), + }; + const recorded = yield* threads + .dispatch({ + type: "delegated_task.remote.request", + commandId, + parentThreadId: scope.thread.threadId, + parentRunId: parentRun.id, + parentNodeId, + task, + ...(input.title === undefined ? {} : { title: input.title }), + // The driver runs there; this side only labels the task with it. + driver: ProviderDriverKind.make("remote"), + modelSelection: launched.modelSelection, + remoteChild, + completionWake: input.mode === "wait" ? "settled_only" : "always", + }) + .pipe( + // The cause goes to the log; the caller learns only that recording failed. + Effect.tapError((cause) => + Effect.logWarning("remote-delegation.record-failed", { + parentThreadId: scope.thread.threadId, + cause, + }), + ), + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "Unable to record the delegated task.", + }), + ), + // Nothing here follows a thread the task was never recorded for, so stop it there. + // A crash between the launch and this record still leaves it running there + // until a retry with the same clientRequestId records it. + Effect.tapError(() => + stopThere(followScope(scope.thread, remoteChild), remoteChild, "stop").pipe( + Effect.ignoreCause({ log: true }), + ), + ), + ); + const taskEvent = recorded.storedEvents.find( + (stored) => stored.event.type === "subagent.updated", + ); + const taskId = + taskEvent?.event.type === "subagent.updated" + ? taskEvent.event.payload.id + : // A replayed request recorded the task the first time. + yield* threads.getThreadRecords(scope.thread.threadId, ["subagents"]).pipe( + Effect.map( + ({ subagents }) => + subagents.find((task) => task.remoteChild?.threadId === launched.threadId)?.id, + ), + Effect.orElseSucceed(() => undefined), + ); + if (taskId === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "The delegated task was not recorded.", + }); + } + yield* followOne(scope.thread.threadId, taskId).pipe(Effect.forkIn(followers)); + return { taskId }; + }, + ); + + /** + * Completes `task` here with its thread there's final state. Each attempt is its own + * command, so a failed one never blocks the next; a report refused because the task already + * has its result (a cancel, a Stop or an earlier report) is done. `stoppedThere` records that + * nothing is left to stop there, even on a task that already has its result. + */ + const complete = Effect.fn("RemoteDelegation.complete")(function* ( + parentThreadId: ThreadId, + taskId: NodeId, + status: "completed" | "failed" | "cancelled" | "interrupted", + result: string, + stoppedThere: "ran-out" | "stopped" | undefined, + stopCount?: number, + ) { + const attempt = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + yield* threads + .dispatch({ + type: "delegated_task.remote.complete", + commandId: CommandId.make(`command:remote-task-complete:${taskId}:${attempt}`), + parentThreadId, + taskId, + status, + result, + ...(stoppedThere === undefined ? {} : { stoppedThere }), + ...(stopCount === undefined ? {} : { stopCount }), + }) + .pipe( + Effect.catch((error) => + readTask(parentThreadId, taskId).pipe( + Effect.flatMap(({ task }) => (task?.result != null ? Effect.void : Effect.fail(error))), + ), + ), + ); + }); + + /** + * How this environment reaches a task's thread there: as the parent + * thread, under one namespace per parent, so a stop sent again after a + * restart or a dropped route is recorded there once. + */ + const followScope = ( + parent: { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId }, + remote: OrchestrationV2RemoteTaskChild, + ): ThreadScope => ({ + environmentId: hereId, + requestNamespace: `remote-task:${parent.threadId}`, + thread: { + threadId: parent.threadId, + providerSessionId: `remote-task:${remote.threadId}`, + providerInstanceId: parent.providerInstanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 0, + }); + + /** + * Stops the task's own thread there as its Stop button does: its running turn, queued + * messages, pull request watches and delegated tasks. `key` names the stop (the follower + * sends one, each cancel its own), and each is recorded there once however often it is sent. + */ + const stopThere = ( + scope: ThreadScope, + remote: OrchestrationV2RemoteTaskChild, + key: string, + reason?: string, + ) => + forwarding + .interruptThread(scope, remote.environmentId, { + threadId: remote.threadId, + stop: true, + ...(reason === undefined ? {} : { reason }), + clientRequestId: `${key}:${remote.threadId}`, + }) + .pipe(Effect.asVoid); + + /** + * The last reply `runId` gave on the task's thread there, which is its + * result, read whole however long it is. A failed run prefers its error + * from the activity view over an earlier reply, without taking another run's. + */ + const lastReply = Effect.fn("RemoteDelegation.lastReply")(function* ( + scope: ThreadScope, + remote: OrchestrationV2RemoteTaskChild, + runId: RunId | null, + failed = false, + ) { + let reply: OrchestratorMcpThreadTimelineItem | undefined; + let error: OrchestratorMcpThreadTimelineItem | undefined; + let afterPosition: number | undefined; + while (true) { + const read = yield* forwarding.readThread(scope, remote.environmentId, { + threadId: remote.threadId, + view: failed ? "activity" : "messages", + limit: 100, + ...(afterPosition === undefined ? {} : { afterPosition }), + }); + reply = + read.items.findLast( + (item) => item.type === "assistant_message" && (runId === null || item.runId === runId), + ) ?? reply; + if (failed) { + error = + read.items.findLast( + (item) => item.type === "error" && (runId === null || item.runId === runId), + ) ?? error; + } + if (!read.hasMore || read.nextPosition === null) break; + afterPosition = read.nextPosition; + } + reply = error ?? reply; + if (reply === undefined) return undefined; + let text = reply.text ?? ""; + let textOffset = reply.textTruncated ? (reply.nextTextOffset ?? null) : null; + while (textOffset !== null) { + const rest = yield* forwarding.readThread(scope, remote.environmentId, { + threadId: remote.threadId, + itemId: reply.itemId, + textOffset, + maxCharsPerItem: 50_000, + }); + const part = rest.items[0]; + if (part === undefined) break; + text += part.text ?? ""; + textOffset = part.textTruncated ? (part.nextTextOffset ?? null) : null; + } + return text; + }); + + /** + * Why following a task can never succeed, if that is what `error` means. + * The far side's message already says what was refused and what to do; a + * link forgotten here only reads as a bad request, so it is named. + */ + const finalReason = Effect.fn("RemoteDelegation.finalReason")( + function* (parentThreadId: ThreadId, taskId: NodeId, error: OrchestratorMcpFailure) { + if (isFinal(error)) return error.message; + if (error.code !== "invalid_request") return undefined; + const { task } = yield* readTask(parentThreadId, taskId); + const environmentId = task?.remoteChild?.environmentId; + if (environmentId === undefined) return error.message; + const link = yield* links.get(environmentId); + return Option.isSome(link) ? undefined : error.message; + }, + Effect.orElseSucceed(() => undefined), + ); + + /** A task's thread there that ended without a reply. */ + const noReply = "(The thread in the linked environment ended without a reply.)"; + + /** + * Waits for the task's run there to end, completes the task here, and stops it there if it + * ended here first. A revoked, expired or forgotten link, or a thread or run gone there, + * fails the task with nothing left to stop; anything else (the other machine asleep, a + * dropped route) is retried with back-off, and so is completing. + */ + const follow = (parentThreadId: ThreadId, taskId: NodeId): Effect.Effect => + followOnce(parentThreadId, taskId).pipe( + // `false` means it is still running there: follow again straight away. + Effect.repeat({ while: (done) => !done }), + Effect.catchTags({ + OrchestratorMcpFailure: Effect.fn("RemoteDelegation.followFailed")(function* (error) { + const reason = yield* finalReason(parentThreadId, taskId, error); + if (reason === undefined) return yield* error; + // Gone there, or no link to reach it by: nothing left to stop there. + const { task } = yield* readTask(parentThreadId, taskId); + const stopCount = task?.remoteChild?.stopCount; + yield* complete(parentThreadId, taskId, "failed", reason, "stopped", stopCount); + }), + }), + Effect.retry(followRetry), + Effect.ignoreCause({ log: true }), + ); + + /** + * One wait on the task's thread there; true once the task here is done. + * A task completed here first (cancelled, or its parent stopped) is + * stopped there, and done once that stop is recorded here. + */ + const followOnce = Effect.fn("RemoteDelegation.followOnce")(function* ( + parentThreadId: ThreadId, + taskId: NodeId, + ) { + const sequence = yield* threads.getThreadEventSequence(parentThreadId); + const { thread, task } = yield* readTask(parentThreadId, taskId); + if (task?.remoteChild === undefined) return true; + const remote = task.remoteChild; + const scope = followScope( + { threadId: parentThreadId, providerInstanceId: thread.providerInstanceId }, + remote, + ); + // Ended here, maybe while this follower backed off from an unreachable + // machine or before a restart: it still has to be stopped there. So + // does one its parent here was stopped after, for what followed there. + if (task.result !== null) { + if ( + remote.stoppedThere === false || + ((task.status === "cancelled" || task.status === "interrupted") && + remote.stoppedThere !== true) + ) { + const key = remote.stopCount === undefined ? "stop" : `stop-${remote.stopCount}`; + yield* stopThere(scope, remote, key).pipe( + // Its thread there now runs above what this side may touch, say + // because the parent here was lowered since; no retry changes that. + Effect.catchIf(isModeRefusal, (error) => + Effect.logWarning("A linked environment refused to stop a delegated task", { + taskId, + reason: error.message, + }), + ), + ); + yield* complete( + parentThreadId, + taskId, + terminalStatus(task.status), + task.result, + "stopped", + remote.stopCount, + ); + return yield* settled(parentThreadId, taskId); + } + return true; + } + const endedHere = threads + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: sequence, + eventType: "subagent.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === taskId && + stored.event.payload.result !== null, + ), + Stream.runHead, + Effect.flatMap((ended) => + Option.isSome(ended) ? Effect.succeed({ endedHere: true } as const) : Effect.never, + ), + ); + const waited = yield* forwarding + .waitForThread(scope, remote.environmentId, { + threadId: remote.threadId, + ...(remote.runId === undefined ? {} : { runId: remote.runId }), + timeoutMs: FOLLOW_WAIT_MS, + // A thread there whose run ended may still be waiting on its own + // tasks to wake it for the answer, which a task here waits for too. + // An older peer ignores this and reports the run alone. + untilDelegatedWorkSettled: true, + }) + .pipe(Effect.raceFirst(endedHere)); + // The next look reads the result and stops it there. + if ("endedHere" in waited) return false; + if (waited.timedOut) return false; + // A task recorded without its run waits on the thread's latest; a + // thread there with none has nothing left to wait for. + if (waited.runId === null) { + const reply = yield* lastReply(scope, remote, null); + yield* complete( + parentThreadId, + taskId, + reply === undefined ? "failed" : "completed", + reply ?? "The thread in the linked environment has no run to follow.", + "ran-out", + ); + return yield* settled(parentThreadId, taskId); + } + if (!isTerminal(waited.status)) return false; + const resultRun = waited.resultRun ?? { + runId: remote.runId ?? waited.runId, + status: waited.status, + }; + const result = yield* lastReply(scope, remote, resultRun.runId, resultRun.status === "failed"); + yield* complete( + parentThreadId, + taskId, + terminalStatus(resultRun.status), + result ?? noReply, + "ran-out", + ); + return yield* settled(parentThreadId, taskId); + }); + + /** + * Whether the task is done here after the follower reported its run ran + * out there. A cancel or Stop that landed while the reply was read keeps + * its result, and still owes a stop there for what may follow the run; + * the next look sends it. + */ + const settled = (parentThreadId: ThreadId, taskId: NodeId) => + readTask(parentThreadId, taskId).pipe( + Effect.map(({ task }) => task?.remoteChild?.stoppedThere === true), + ); + + const following = new Map(); + + /** + * `follow`, at most once per task. Asked again while it runs, say by a + * Stop here on a task that already has its result, it looks once more + * before it ends, so that stop is not missed. + */ + const followOne = (parentThreadId: ThreadId, taskId: NodeId) => + Effect.suspend(() => { + const running = following.get(taskId); + if (running !== undefined) { + running.again = true; + return Effect.void; + } + const state = { again: false }; + following.set(taskId, state); + return follow(parentThreadId, taskId).pipe( + // Checked and released in one step, so a request between them is never lost. + Effect.repeat({ + while: () => { + if (state.again) { + state.again = false; + return true; + } + following.delete(taskId); + return false; + }, + }), + Effect.ensuring( + Effect.sync(() => { + if (following.get(taskId) === state) following.delete(taskId); + }), + ), + ); + }); + + const cancel: RemoteDelegation["Service"]["cancel"] = Effect.fn("RemoteDelegation.cancel")( + function* (scope, task, input) { + const remote = task.remoteChild; + if (remote === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "task_not_found", + message: `Delegated task ${task.id} is not remote.`, + }); + } + const { reason } = input; + const there = followScope(scope.thread, remote); + // A later cancel stops what started there since; a retry of this one, nothing new. + const key = `cancel:${encodeURIComponent(scope.requestNamespace)}:${ + input.clientRequestId ?? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)) + }`; + if (task.result !== null) { + // Best effort: the result stands whether or not the stop gets there. + yield* stopThere(there, remote, key, reason).pipe( + Effect.andThen( + complete( + task.threadId, + task.id, + terminalStatus(task.status), + task.result, + "stopped", + remote.stopCount, + ), + ), + Effect.ignoreCause({ log: true }), + ); + return { + taskId: task.id, + status: + task.status === "completed" || task.status === "failed" || task.status === "interrupted" + ? task.status + : "cancelled", + } satisfies OrchestratorMcpTaskCancelResult; + } + // A link that is gone or a machine that is down still cancels the task + // here; its follower keeps trying to stop it there, across restarts. + const unstopped = yield* stopThere(there, remote, key, reason).pipe( + Effect.as(undefined), + Effect.catch((error) => Effect.succeed(error.message)), + ); + const cancelled = reason === undefined ? "Cancelled." : `Cancelled: ${reason}`; + yield* complete( + task.threadId, + task.id, + "cancelled", + unstopped === undefined + ? cancelled + : `${cancelled} It could not be stopped in ${remote.label} and may still run there: ${unstopped}`, + unstopped === undefined ? "stopped" : undefined, + remote.stopCount, + ).pipe( + Effect.tapError((cause) => + Effect.logWarning("remote-delegation.cancel-record-failed", { taskId: task.id, cause }), + ), + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "Unable to record the cancellation.", + }), + ), + ); + return { + taskId: task.id, + status: "cancel_requested", + } satisfies OrchestratorMcpTaskCancelResult; + }, + ); + + const start: RemoteDelegation["Service"]["start"] = () => + forkParked( + Effect.gen(function* () { + // A finished task's follower has ended, so a Stop here that owes its + // thread there a stop starts it again. Started before the scan below; + // a Stop that slips between the two stays owed for the next start. + yield* Stream.runForEach(threads.streamDomainEvents, (event) => + event.type === "subagent.updated" && event.payload.remoteChild?.stoppedThere === false + ? followOne(event.threadId, event.payload.id).pipe(Effect.forkIn(followers)) + : Effect.void, + ).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(followers, { startImmediately: true }), + ); + const open = yield* projections.getOpenRemoteDelegatedTasks; + yield* Effect.forEach( + open, + ({ parentThreadId, taskId }) => followOne(parentThreadId, taskId), + { + concurrency: "unbounded", + discard: true, + }, + ); + }).pipe(Effect.ignoreCause({ log: true })), + ); + + return RemoteDelegation.of({ delegate, cancel, start }); +}); + +const isTerminal = (status: string) => + status === "completed" || + status === "failed" || + status === "cancelled" || + status === "interrupted" || + status === "rolled_back"; + +const terminalStatus = (status: string): "completed" | "failed" | "cancelled" | "interrupted" => + status === "completed" || status === "failed" || status === "interrupted" ? status : "cancelled"; + +export const layer = Layer.effect(RemoteDelegation, make); diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index cbee7275b123f..f7fe236fe5ff4 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -50,6 +50,9 @@ export class ProjectEnrichmentService extends Context.Service< readonly peek: (workspaceRoot: string) => Effect.Effect; readonly request: (workspaceRoot: string) => Effect.Effect; readonly getAvailable: (workspaceRoot: string) => Effect.Effect; + readonly readRepositoryIdentity: ( + workspaceRoot: string, + ) => Effect.Effect; readonly invalidate: (workspaceRoots: Iterable) => Effect.Effect; readonly subscribeChanges: Effect.Effect< PubSub.Subscription, @@ -271,6 +274,12 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( return available; }); + const readRepositoryIdentity: ProjectEnrichmentService["Service"]["readRepositoryIdentity"] = + Effect.fn("ProjectEnrichmentService.readRepositoryIdentity")(function* (workspaceRoot) { + const resolved = yield* Cache.get(repositoryIdentityCache, workspaceRoot); + return Exit.isSuccess(resolved) ? resolved.value : null; + }); + const invalidate: ProjectEnrichmentService["Service"]["invalidate"] = Effect.fn( "ProjectEnrichmentService.invalidate", )(function* (workspaceRoots) { @@ -294,6 +303,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( peek, request, getAvailable, + readRepositoryIdentity, invalidate, subscribeChanges: PubSub.subscribe(changes), }); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 5c646e1ea7be1..18004510baa45 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -132,6 +132,17 @@ export class ProjectService extends Context.Service< options?: { readonly includeDeleted?: boolean }, ) => Effect.Effect, ProjectOperationError>; readonly snapshot: Effect.Effect; + /** + * A page of active projects in creation order, each with its repository + * identity resolved, so a caller can match projects by repository. + */ + readonly listPage: (input: { + readonly cursor: number; + readonly limit: number; + }) => Effect.Effect< + { readonly projects: ReadonlyArray; readonly nextCursor: number | null }, + ProjectOperationError + >; readonly getShell: ( projectId: ProjectId, ) => Effect.Effect, ProjectOperationError>; @@ -557,6 +568,23 @@ export const make = Effect.gen(function* () { } satisfies ProjectSnapshot; }); + const listPage: ProjectService["Service"]["listPage"] = Effect.fn("ProjectService.listPage")( + function* ({ cursor, limit }) { + const rows = (yield* snapshot).projects.filter((project) => project.deletedAt === null); + const projects = yield* Effect.forEach( + rows.slice(cursor, cursor + limit), + (project) => + project.repositoryIdentity !== null + ? Effect.succeed(project) + : projectEnrichment + .readRepositoryIdentity(project.workspaceRoot) + .pipe(Effect.map((repositoryIdentity) => ({ ...project, repositoryIdentity }))), + { concurrency: 8 }, + ); + return { projects, nextCursor: cursor + limit < rows.length ? cursor + limit : null }; + }, + ); + return ProjectService.of({ create, bootstrap, @@ -565,6 +593,7 @@ export const make = Effect.gen(function* () { getById, getByWorkspaceRoot, snapshot, + listPage, getShell, listShells, }); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 1f7bdb4841723..99a8493eaafec 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -23,6 +23,7 @@ import { ThreadLaunchService } from "../orchestration-v2/ThreadLaunchService.ts" import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; import { ThreadSearch } from "../orchestration-v2/ThreadSearch.ts"; import { PeerForwarding } from "../peer/PeerForwarding.ts"; +import { RemoteDelegation } from "../peer/RemoteDelegation.ts"; import { PreviewManager } from "../preview/Manager.ts"; import { PreviewBrowser } from "../preview/PreviewBrowser.ts"; import { ManagedProjectFolders } from "../project/ManagedProjectFolders.ts"; @@ -58,6 +59,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(ProviderAdapterRegistryV2)({}), Layer.mock(ProviderRegistry)({}), Layer.mock(PullRequestReviewGuideService)({}), + Layer.mock(RemoteDelegation)({}), Layer.mock(ScheduledTaskService)({}), Layer.mock(SecretRequests)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-agent-context-budget-" }), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 946416154dd7d..41a9242f28a1a 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5086, "codexAdditionalContextBytes": 5638, - "mcpToolListJsonBytes": 44232 + "mcpToolListJsonBytes": 44745 } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index e101b7475cdf8..0cbf8ebbba992 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -267,6 +267,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( delete: unused, getByWorkspaceRoot: unused, snapshot: Effect.succeed({ projects: [] } as never), + listPage: unused, getShell: unused, listShells: unused, getById: () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5226e1469b252..161e3275a745f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -68,6 +68,7 @@ import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrid import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as PeerForwarding from "./peer/PeerForwarding.ts"; +import * as RemoteDelegation from "./peer/RemoteDelegation.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -730,10 +731,16 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry), Layer.provide(McpOAuth.layerMcpClientAuthenticator), ), + // Follows delegated tasks running in linked environments, including after a restart. + Layer.effectDiscard( + RemoteDelegation.RemoteDelegation.pipe(Effect.flatMap((remote) => remote.start())), + ), ).pipe( Layer.provide( PullRequestReviewGuideService.layer.pipe(Layer.provide(PullRequestReviewGuides.layer)), ), + // delegate_task to a linked environment, from /mcp and the follower above. + Layer.provide(RemoteDelegation.layer.pipe(Layer.provide(ProjectionStoreV2.layer))), // Share the service so HTTP/WS clients single-flight reads and invalidate each other's cached patches. Layer.provide(layerPullRequestService), // The same activity instance RunExecutionService reports to, by layer memoization. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7bb833cb905ea..5b44cc0b247a2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -138,6 +138,7 @@ import { import { type LegendListRef } from "@legendapp/list/react"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { DEFAULT_INTERACTION_MODE, type ChatMessage } from "../types"; +import { buildThreadRouteParams } from "../threadRoutes"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; @@ -231,6 +232,7 @@ import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSki import { vcsEnvironment } from "../state/vcs"; import { type EnvironmentPresentation, + useEnvironment, useEnvironmentProviders, useEnvironments, usePrimaryEnvironment, @@ -829,7 +831,47 @@ export default function ChatView(props: ChatViewProps) { messages: serverProjection.messages, parentSubagents: workflowOpeningParentProjection?.projection.subagents ?? null, }); - const parentThreadLink = + // A linked environment's agent may have delegated this thread from there. + const delegatedFrom = activeThread?.delegatedFrom ?? null; + const delegatedFromEnvironment = useEnvironment(delegatedFrom?.environmentId ?? null); + const delegatedFromRef = + delegatedFrom === null + ? null + : scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId); + // The title it had at delegation, until this client sees the parent itself. + const delegatedFromThread = useQuietThreadShell(delegatedFromRef); + const parentThreadLink = (() => { + if (parentSubagentThreadRef !== null) { + return { + title: parentSubagentThread?.title ?? "Parent thread", + open: (): void => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(parentSubagentThreadRef), + }); + }, + }; + } + if (delegatedFrom === null || delegatedFromRef === null) return null; + const parentRef = delegatedFromRef; + return { + title: delegatedFromThread?.title ?? delegatedFrom.title, + environmentLabel: delegatedFromEnvironment?.label ?? activeThread?.linkOrigin?.label ?? "", + // Only an environment this client is connected to can open the parent. + ...(delegatedFromEnvironment === null + ? {} + : { + open: (): void => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(parentRef), + }); + }, + }), + }; + })(); + // The header's breadcrumb opens a parent in this environment only. + const headerParentThreadLink = parentSubagentThreadRef === null ? null : { @@ -2328,7 +2370,6 @@ export default function ChatView(props: ChatViewProps) { handleSwitchCheckoutToThread, handleSwitchCheckoutToThreadStable, isRestoringThreadBranch, - onOpenRelatedThread, onOpenRelatedThreadStable, sendStandaloneCommand, setBranchMismatchDismissTick, @@ -3161,7 +3202,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadTitle={activeThread.title} activeThreadLinkOrigin={activeThreadShell?.linkOrigin?.label ?? null} activeProject={activeProject ?? null} - parentThreadLink={parentThreadLink} + parentThreadLink={headerParentThreadLink} onOpenThread={onOpenRelatedThreadStable} rightPanelOpen={inlineRightPanelOwnsTitleBar} onNewThreadInProject={handleNewThreadInActiveProjectStable} @@ -3381,11 +3422,7 @@ export default function ChatView(props: ChatViewProps) { modelLabel={providerSubagentModelLabel} effortLabel={providerSubagentEffortLabel} status={providerSubagentStatus} - onOpenParent={ - parentThreadLink - ? () => onOpenRelatedThread(parentThreadLink.threadId) - : null - } + onOpenParent={parentThreadLink?.open ?? null} /> ) : null} {!composerMounted ? null : ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 10ff6ef6bef69..05422aa57c52a 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -98,6 +98,7 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { readThreadShell, useProjects, + useDelegatedUnderParent, useThreadShells, useThreadShellsForProjectRefs, waitForProjectRemoval, @@ -1124,6 +1125,7 @@ function useProjectThreads(project: SidebarProjectSnapshot) { (settings) => settings.sidebarThreadSortOrder, ); const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); + const delegatedUnderParent = useDelegatedUnderParent(); const threadLastVisitedAts = useUiStateStore( useShallow((state) => sidebarThreads.map( @@ -1168,7 +1170,7 @@ function useProjectThreads(project: SidebarProjectSnapshot) { ]), ); const visibleProjectThreads = sortThreads( - filterSidebarV2VisibleThreads(sidebarThreads, null), + filterSidebarV2VisibleThreads(sidebarThreads, null, delegatedUnderParent), threadSortOrder, ); const projectStatus = resolveProjectStatusIndicator( @@ -3121,6 +3123,7 @@ function useSortedLegacySidebarProjects(input: { sidebarProjectSortOrder, } = input; const sidebarThreads = useThreadShells(); + const delegatedUnderParent = useDelegatedUnderParent(); const sidebarProjectByKey = new Map( sidebarProjects.map((project) => [project.projectKey, project] as const), ); @@ -3128,7 +3131,11 @@ function useSortedLegacySidebarProjects(input: { ...project, id: project.projectKey, })); - const sortableThreads = filterSidebarV2VisibleThreads(sidebarThreads, null).map((thread) => { + const sortableThreads = filterSidebarV2VisibleThreads( + sidebarThreads, + null, + delegatedUnderParent, + ).map((thread) => { const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), @@ -3171,6 +3178,7 @@ function useLegacySidebarThreads(input: { expandedThreadListsByProject, } = input; const sidebarThreads = useThreadShells(); + const delegatedUnderParent = useDelegatedUnderParent(); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const sidebarThreadByKey = new Map( sidebarThreads.map( @@ -3201,7 +3209,11 @@ function useLegacySidebarThreads(input: { } const visibleSidebarThreadKeys = sortedProjects.flatMap((project) => { const projectThreads = sortThreads( - filterSidebarV2VisibleThreads(threadsByProjectKey.get(project.projectKey) ?? [], null), + filterSidebarV2VisibleThreads( + threadsByProjectKey.get(project.projectKey) ?? [], + null, + delegatedUnderParent, + ), sidebarThreadSortOrder, ); const projectExpanded = resolveProjectExpanded( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a66540ff4fdf3..ae17a94fb4fbe 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -854,7 +854,7 @@ describe("sortSidebarV2ProjectGroups", () => { ]; expect( - sortSidebarV2ProjectGroups(projects, threads, "updated_at").map( + sortSidebarV2ProjectGroups(projects, threads, "updated_at", new Set()).map( (project) => project.projectKey, ), ).toEqual(["logical-newer", "logical-older"]); @@ -1306,6 +1306,7 @@ describe("resolveSidebarRouteReveal", () => { routeKey: hiddenKey, isSearching: false, scopedProjectKeys, + delegatedUnderParent: new Set(), findThread, }), ).toEqual({ pendingKey: hiddenKey, widensScope: true }); @@ -1319,6 +1320,7 @@ describe("resolveSidebarRouteReveal", () => { routeKey: hiddenKey, isSearching: false, scopedProjectKeys, + delegatedUnderParent: new Set(), findThread, }), ).toEqual({ pendingKey: null, widensScope: false }); @@ -1334,6 +1336,7 @@ describe("resolveSidebarRouteReveal", () => { routeKey, isSearching: false, scopedProjectKeys, + delegatedUnderParent: new Set(), findThread, }); resolve("environment-test:unlisted"); @@ -1347,6 +1350,7 @@ describe("resolveSidebarRouteReveal", () => { routeKey: hiddenKey, isSearching: true, scopedProjectKeys, + delegatedUnderParent: new Set(), findThread, }), ).toEqual({ pendingKey: hiddenKey, widensScope: false }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 90264caa28798..f2ab971e19a0f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,5 @@ import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; +import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; @@ -580,16 +581,27 @@ export function resolveSidebarRowThreadId( return thread?.id ?? threadId; } +/** + * `delegatedUnderParent` holds the tasks linked environments run for a thread + * the sidebar lists, by thread key; they show under that thread, as subagents do. + */ export function filterSidebarV2VisibleThreads< - T extends Pick & { - environmentId: string; + T extends Pick & { projectId: string; }, ->(threads: readonly T[], scopedProjectKeys: ReadonlySet | null): T[] { +>( + threads: readonly T[], + scopedProjectKeys: ReadonlySet | null, + delegatedUnderParent: ReadonlySet, +): T[] { return threads.filter( (thread) => thread.archivedAt === null && - !isSidebarSubagentThread(thread) && + !isShownUnderParent( + { environmentId: thread.environmentId, threadId: thread.id }, + thread.lineage, + delegatedUnderParent, + ) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); @@ -1299,15 +1311,17 @@ export function sortLogicalProjectsForSidebar< export function sortSidebarV2ProjectGroups< TProject extends LogicalSidebarProject, - TThread extends ScopedSidebarThread & Pick, + TThread extends ScopedSidebarThread & + Pick, >( projects: readonly TProject[], threads: readonly TThread[], sortOrder: SidebarProjectSortOrder, + delegatedUnderParent: ReadonlySet, ): TProject[] { return sortLogicalProjectsForSidebar( projects, - filterSidebarV2VisibleThreads(threads, null), + filterSidebarV2VisibleThreads(threads, null, delegatedUnderParent), sortOrder, ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 99f42874066b2..748b006b3809e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -127,6 +127,7 @@ import { import { readThreadShell, useAllEnvironmentProjectSnapshotsReady, + useDelegatedUnderParent, useProjects, useServerConfigs, useQuietThreadShells, @@ -310,6 +311,8 @@ type SidebarOptimisticDrop = { interface SidebarThreadPartitionInput { readonly threads: readonly EnvironmentThreadShell[]; readonly scopedProjectKeys: ReadonlySet | null; + /** Tasks linked environments run for a listed thread; they show under it. */ + readonly delegatedUnderParent: ReadonlySet; readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly workingShelfEnabled: boolean; @@ -371,6 +374,7 @@ function useSidebarThreads(input: { readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly threads: readonly EnvironmentThreadShell[]; + readonly delegatedUnderParent: ReadonlySet; readonly routeRowThreadKey: string | null; }) { const { @@ -379,6 +383,7 @@ function useSidebarThreads(input: { serverConfigs, optimisticDrop, threads, + delegatedUnderParent, routeRowThreadKey, } = input; const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); @@ -417,6 +422,7 @@ function useSidebarThreads(input: { const partition = partitionSidebarThreads({ threads, scopedProjectKeys, + delegatedUnderParent, serverConfigs, optimisticDrop, workingShelfEnabled, @@ -520,9 +526,16 @@ function useSidebarThreads(input: { } function partitionSidebarThreads(input: SidebarThreadPartitionInput) { - const { threads, scopedProjectKeys, serverConfigs, optimisticDrop, workingShelfEnabled } = input; + const { + threads, + scopedProjectKeys, + delegatedUnderParent, + serverConfigs, + optimisticDrop, + workingShelfEnabled, + } = input; const preciseNow = new Date().toISOString(); - const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); + const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -791,6 +804,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectGroupByScopeKey, projectScopeItems, } = useSidebarLookups(); + const delegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -996,6 +1010,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve routeKey: routeRowThreadKey, isSearching: isSearchingThreads, scopedProjectKeys: chosenScopeProjectKeys, + delegatedUnderParent, findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key), }); const projectScopeKey = revealWidensScope ? null : chosenProjectScopeKey; @@ -1137,6 +1152,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve serverConfigs, optimisticDrop, threads, + delegatedUnderParent, routeRowThreadKey, }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index f253dd6be22c7..89128ff69ac55 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -45,6 +45,19 @@ vi.mock(import("../state/presentation"), async (original) => { }, }; }); +vi.mock(import("../state/threads"), async (original) => { + const actual = await original(); + return { + ...actual, + environmentThreadShells: { + ...actual.environmentThreadShells, + pendingSubagentRequestsAtom: (() => noRequestsAtom) as never, + get delegatedUnderParentAtom() { + return noDelegatedAtom as never; + }, + }, + }; +}); vi.mock(import("../hooks/useSettings"), async (original) => ({ ...(await original()), subscribeClientSettings: ((listener: () => void) => { @@ -68,6 +81,8 @@ import { startThreadNotifications } from "./ThreadNotificationCoordinator"; let registry: AtomRegistry.AtomRegistry; const shellAtom = Atom.family((_id: string) => Atom.make(null)); const environmentIdsAtom = Atom.make>([]); +const noRequestsAtom = Atom.make(new Map()); +const noDelegatedAtom = Atom.make>(new Set()); state.shells = { set: (id, value) => registry.set(shellAtom(id), value) }; class TestNotification extends EventTarget { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index a782e24da6522..9513ec7e1826a 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -3,7 +3,7 @@ import { type PendingSubagentRequest, presentThreadShell, } from "@t3tools/client-runtime/state/models"; -import { pendingSubagentRequestsByThread } from "@t3tools/client-runtime/state/threads"; +import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import type { AnyRouter } from "@tanstack/react-router"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; @@ -20,6 +20,7 @@ import { getClientSettings, subscribeClientSettings } from "../hooks/useSettings import { isWindowsPlatform } from "../lib/utils"; import { environmentSummaries } from "../state/presentation"; import { environmentShell } from "../state/shell"; +import { environmentThreadShells } from "../state/threads"; import { hasDesktopNotifications, hasNotificationSound, @@ -173,10 +174,22 @@ export function startThreadNotifications( ) => { const previous = entry.previous; const next = new Map(); - const subagentRequests = pendingSubagentRequestsByThread(threads); + // A parent here also hears of what its tasks on linked environments ask. + const subagentRequests = registry.get( + environmentThreadShells.pendingSubagentRequestsAtom(environmentId), + ); + const delegatedUnderParent = registry.get(environmentThreadShells.delegatedUnderParentAtom); const changed: Array<[NotificationState | undefined, NotificationState]> = []; for (const rawThread of threads) { - if (rawThread.lineage.relationshipToParent === "subagent") continue; + if ( + isShownUnderParent( + { environmentId, threadId: rawThread.id }, + rawThread.lineage, + delegatedUnderParent, + ) + ) { + continue; + } const prior = previous.get(rawThread.id); const subagentRequest = subagentRequests.get(rawThread.id) ?? null; if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) { @@ -227,10 +240,11 @@ export function startThreadNotifications( if (state) present(environmentId, state, kind); }), }; + let threads: ReadonlyArray | null = null; const unsubscribeShell = registry.subscribe( environmentShell.stateValueAtom(environmentId), (shell) => { - const threads = + threads = shell.status === "live" && Option.isSome(shell.snapshot) ? shell.snapshot.value.threads : null; @@ -243,8 +257,21 @@ export function startThreadNotifications( }, { immediate: true }, ); + const reobserve = () => { + if (threads !== null) observeThreads(environmentId, entry, threads); + }; + const unsubscribeRequests = registry.subscribe( + environmentThreadShells.pendingSubagentRequestsAtom(environmentId), + reobserve, + ); + const unsubscribeDelegated = registry.subscribe( + environmentThreadShells.delegatedUnderParentAtom, + reobserve, + ); entry.unsubscribe = () => { unsubscribeShell(); + unsubscribeRequests(); + unsubscribeDelegated(); entry.gate.clear(); }; return entry; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index fbfa67e61bf8e..dfcdce4c557b3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -237,9 +237,12 @@ interface MessagesTimelineProps { onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; turnItemSource?: TimelineRowSharedState["turnItemSource"]; + /** The thread this one is a subagent of; `open` is absent when it cannot be opened from here. */ parentThreadLink?: { - readonly threadId: ThreadId; readonly title: string; + /** Where it runs, when that is another environment. */ + readonly environmentLabel?: string; + readonly open?: () => void; } | null; workflowOpening?: WorkflowOpening | null; onForkFromRun?: (input: { @@ -1093,10 +1096,15 @@ const ConversationTimeline = memo(function ConversationTimeline({
onOpenThread(parentThreadLink.threadId)} + {...(parentThreadLink.open === undefined + ? {} + : { actionLabel: "Open parent thread", onAction: parentThreadLink.open })} />
diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 66fc6b6f21ec2..b37f9d1d1078d 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -32,8 +32,10 @@ import { isProviderNativeSubagentThread, NodeId, type EnvironmentId, + type OrchestrationV2RemoteTaskChild, type OrchestrationV2Subagent, type OrchestrationV2ThreadShell, + type ServerProvider, type ThreadId, } from "@t3tools/contracts"; import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; @@ -61,7 +63,7 @@ import { useThreadProjection, useThreadRelationshipShells, } from "../../state/entities"; -import { useEnvironmentProviders } from "../../state/environments"; +import { useEnvironment, useEnvironmentProviders } from "../../state/environments"; import { threadEnvironment } from "../../state/threads"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -217,6 +219,74 @@ function currentSubagent( }; } +/** + * A task delegated to a linked environment. It has no thread here, so it opens + * the thread there, when this client is connected to that environment. + */ +function RemoteSubagentRow(props: { + readonly agent: OrchestrationV2Subagent & { + readonly remoteChild: OrchestrationV2RemoteTaskChild; + }; + readonly provider: ServerProvider | undefined; +}) { + const navigate = useNavigate(); + const environment = useEnvironment(props.agent.remoteChild.environmentId); + const { remoteChild } = props.agent; + const status = props.agent.status; + const failed = status === "failed"; + const title = relationshipThreadTitle({ + title: props.agent.title ?? "Subagent", + isSubagent: true, + }); + return ( +
  • + + + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(remoteChild.environmentId, remoteChild.threadId), + ), + }) + } + /> + } + > + + + + {title} + + + + on {remoteChild.label} · {threadRelationshipStatusLabel(status)} + + + + {environment === null + ? `Runs on ${remoteChild.label}. Connect to it to open the thread there.` + : `Open on ${remoteChild.label}`} + + +
  • + ); +} + export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -332,12 +402,17 @@ export function ThreadRelationshipsPanel(props: { { id: "active", label: null, rows: active, expanded: true }, { id: "previous", label: "Previous agents", rows: previous, expanded: false }, ]; + // Tasks running in a linked environment have no thread here; they get their own rows. + const remoteSubagents = (projection?.subagents ?? []).filter( + (agent): agent is OrchestrationV2Subagent & { remoteChild: OrchestrationV2RemoteTaskChild } => + agent.remoteChild !== undefined, + ); const runningCount = (projection?.subagents.filter( (agent) => agent.childThreadId === null && agent.status === "running", ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; - if (relationshipRows.length === 0 && runningCount === 0) { + if (relationshipRows.length === 0 && runningCount === 0 && remoteSubagents.length === 0) { return null; } @@ -700,6 +775,20 @@ export function ThreadRelationshipsPanel(props: { } ))} + {remoteSubagents.length > 0 ? ( + {}}> + {remoteSubagents.map((agent) => ( + entry.instanceId === agent.providerInstanceId)} + /> + ))} + + ) : null} ); } diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 2562601140870..96387b59df201 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -3,6 +3,9 @@ import { AgentElapsed } from "./AgentElapsed"; import { projectedSubagentsToRuntime } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ReactNode } from "react"; import { useThreadShell, useProject } from "../../state/entities"; +import { useEnvironment } from "../../state/environments"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { useNavigate } from "@tanstack/react-router"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef, scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -21,6 +24,7 @@ import { ProviderDriverKind, type OrchestrationV2Notification, type OrchestrationV2TurnItem, + type OrchestrationV2RemoteTaskChild, type OrchestrationV2Subagent, type ProviderInstanceId, type ServerProvider, @@ -199,6 +203,7 @@ export function V2LifecycleRow(props: { startedAt={item.startedAt} completedAt={item.completedAt} threadId={item.childThreadId} + remote={item.remoteChild} onOpenThread={props.onOpenThread} /> ); @@ -398,6 +403,8 @@ function SubagentTimelineLink(props: { readonly startedAt: DateTime.Utc | null; readonly completedAt: DateTime.Utc | null; readonly threadId: ThreadId | null; + /** The thread in a linked environment that runs this task, when it runs there. */ + readonly remote?: OrchestrationV2RemoteTaskChild | undefined; readonly onOpenThread: (threadId: ThreadId) => void; readonly event?: { readonly status: OrchestrationV2TurnItem["status"] | null; @@ -409,7 +416,21 @@ function SubagentTimelineLink(props: { environmentThreadDetails.threadAtom(props.parentRef), (thread) => thread?.projection.subagents.find((agent) => agent.id === props.subagentId) ?? null, ); + const remote = props.remote ?? agent?.remoteChild; + const remoteEnvironment = useEnvironment(remote?.environmentId ?? null); + const navigate = useNavigate(); const threadId = props.threadId; + // A task in a linked environment opens there, if this client is connected to it. + const open = + threadId !== null + ? () => props.onOpenThread(threadId) + : remote !== undefined && remoteEnvironment !== null + ? () => + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(remote.environmentId, remote.threadId)), + }) + : null; const liveStatus = agent?.status ?? props.status; const status = props.event ? props.event.status : liveStatus; const statusLabel = props.event?.label ?? subagentStatusVisual(liveStatus).label; @@ -434,7 +455,14 @@ function SubagentTimelineLink(props: { status={status ?? undefined} /> - {props.title} + + + {props.title} + + {remote !== undefined ? ( + on {remote.label} + ) : null} + {props.event ? props.event.timestamp : } - {threadId !== null ? ( + {open !== null ? ( {content} @@ -485,7 +513,7 @@ function SubagentTimelineLink(props: { data-v2-item-type="subagent" aria-label={`Open ${props.title}`} aria-description={statusLabel} - onClick={() => props.onOpenThread(threadId)} + onClick={open} className={cn( className, "cursor-pointer transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index de2a64ca858c6..df9d45dc9d33e 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -52,16 +52,18 @@ export function resolveSidebarRouteReveal(input: { readonly routeKey: string | null; readonly isSearching: boolean; readonly scopedProjectKeys: ReadonlySet | null; + readonly delegatedUnderParent: ReadonlySet; readonly findThread: (key: string) => T | undefined; }): { readonly pendingKey: string | null; readonly widensScope: boolean } { - const { reveal, routeKey, isSearching, scopedProjectKeys, findThread } = input; + const { reveal, routeKey, isSearching, scopedProjectKeys, delegatedUnderParent, findThread } = + input; const pendingKey = pendingSidebarRouteReveal(reveal, routeKey); // Search results replace the thread list; the reveal waits for it to return. const thread = pendingKey === null || isSearching ? undefined : findThread(pendingKey); const widensScope = thread !== undefined && scopedProjectKeys !== null && - filterSidebarV2VisibleThreads([thread], scopedProjectKeys).length === 0 && - filterSidebarV2VisibleThreads([thread], null).length === 1; + filterSidebarV2VisibleThreads([thread], scopedProjectKeys, delegatedUnderParent).length === 0 && + filterSidebarV2VisibleThreads([thread], null, delegatedUnderParent).length === 1; return { pendingKey, widensScope }; } diff --git a/apps/web/src/hooks/useSidebarProjectGroups.ts b/apps/web/src/hooks/useSidebarProjectGroups.ts index cb1b3b1013265..5f4008aea482d 100644 --- a/apps/web/src/hooks/useSidebarProjectGroups.ts +++ b/apps/web/src/hooks/useSidebarProjectGroups.ts @@ -2,7 +2,7 @@ import { orderItemsByPreferredIds, sortSidebarV2ProjectGroups } from "../compone import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { useEnvironmentIdentities, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useQuietThreadShells } from "../state/entities"; +import { useDelegatedUnderParent, useProjects, useQuietThreadShells } from "../state/entities"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -11,6 +11,7 @@ export function useSidebarProjectGroups() { // Activity order lags a running thread's streaming ticks until its next // real change; reading every tick re-rendered the whole sidebar with it. const threads = useQuietThreadShells(); + const delegatedUnderParent = useDelegatedUnderParent(); const projectOrder = useUiStateStore((store) => store.projectOrder); const sortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const groupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -35,5 +36,5 @@ export function useSidebarProjectGroups() { primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => labels.get(environmentId) ?? null, }); - return sortSidebarV2ProjectGroups(unsortedGroups, threads, sortOrder); + return sortSidebarV2ProjectGroups(unsortedGroups, threads, sortOrder, delegatedUnderParent); } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index af71b314c4c00..da3c500ca6bfb 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -112,6 +112,11 @@ export function useAllEnvironmentProjectSnapshotsReady(): boolean { return useAtomValue(allEnvironmentProjectSnapshotsReadyAtom); } +/** Tasks linked environments run for a listed thread, by thread key; lists show them under it. */ +export function useDelegatedUnderParent(): ReadonlySet { + return useAtomValue(environmentThreadShells.delegatedUnderParentAtom); +} + export function useThreadShellsForProjectRefs( refs: ReadonlyArray, ): ReadonlyArray { diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 5bc642e8d14a5..bb12f32621a6a 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -117,6 +117,15 @@ shared access declarations, not tool by tool: Reads are not fenced, and a client's own `thread.create` can never carry `linkOrigin`. +**A delegated task can run in a linked environment.** Its child is then an +ordinary thread there, so the parent here records the task without a child +thread of its own (`remoteChild` instead of `childThreadId`). A follower in +[`RemoteDelegation`](../../apps/server/src/peer/RemoteDelegation.ts) waits on +that thread and completes the task with `delegated_task.remote.complete`. That +internal command reuses the parent half of a local finalize, so the parent +wakes the same way. Open remote tasks are followed again at startup, which is +also what keeps restart recovery from treating them as abandoned provider work. + The link is routing, not isolation: an agent the link starts runs as the receiving environment's user. **A full-access link is therefore not fenced**: its agents can already do anything that user can, so the fence would only stop diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 6d6bc234290d0..a14285d97446f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -269,6 +269,13 @@ it. From a terminal, use removes it here. - Links last 30 days. Link again with a new pairing code to renew one. +An agent can also hand a task to a linked machine with `delegate_task`: it runs +there as an ordinary thread, in the project with the same repository (or the +one `target.projectId` names), and this thread wakes with its result when it +ends, as for any delegated task. It keeps following the task across restarts. +If the other machine revokes the link, or the link is removed here, the task +fails with that reason. + ## Manage or revoke access On the host, **Settings → Sharing & access** lets authorized administrators create diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index b0241eeea8dca..2e8f02f27e788 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -17,7 +17,7 @@ import { shellRequest, t3SystemAppend, } from "./run.ts"; -import { taskPrompt } from "../../apps/server/src/mcp/OrchestratorMcpService.ts"; +import { taskPrompt } from "../../apps/server/src/mcp/delegatedTaskModes.ts"; it("grades Codex shell and subagent calls with the shared rules", () => { const calls = [ diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index f65ef239ed7d6..a83ab675cf843 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -26,7 +26,7 @@ import { Tool } from "effect/ai"; import * as McpSchema from "effect/ai/McpSchema"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; -import { taskPrompt } from "../../apps/server/src/mcp/OrchestratorMcpService.ts"; +import { taskPrompt } from "../../apps/server/src/mcp/delegatedTaskModes.ts"; import * as McpInvocationContext from "../../apps/server/src/mcp/McpInvocationContext.ts"; import { advertisedJsonSchema } from "../../apps/server/src/mcp/advertisedJsonSchema.ts"; import { ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } from "@t3tools/contracts"; diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 9dc72dc493027..ac40471a853ba 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -148,6 +148,8 @@ export interface EnvironmentThreadShell { readonly lineage: OrchestrationV2ThreadShell["lineage"]; readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"]; readonly linkOrigin: OrchestrationV2ThreadShell["linkOrigin"] | null; + /** The thread in a linked environment that delegated this one, if one did. */ + readonly delegatedFrom: OrchestrationV2ThreadShell["delegatedFrom"] | null; readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"]; readonly latestRun: ThreadRunSummary | null; readonly runtime: ThreadRuntimeSummary | null; @@ -300,6 +302,7 @@ export function presentThreadShell( lineage: thread.lineage, forkedFrom: thread.forkedFrom, linkOrigin: thread.linkOrigin ?? null, + delegatedFrom: thread.delegatedFrom ?? null, activeProviderThreadId: thread.activeProviderThreadId, latestRun, runtime, diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 2dcc3233db3a9..ec40d5f679ec9 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -215,6 +215,152 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("lists a task a linked environment runs for a listed thread under that thread", () => { + const { registry, threads, snapshotAtom, catalogValueAtom } = makeHarness([ + environmentId, + remoteEnvironmentId, + ]); + const parent = v2ThreadShell; + const task = { + ...v2ThreadShell, + id: ThreadId.make("remote-task"), + creationSource: "mcp" as const, + status: "running" as const, + activityRunStatus: "running" as const, + delegatedFrom: { environmentId, threadId: parent.id, title: parent.title }, + }; + const helper = { + ...task, + id: ThreadId.make("remote-helper"), + delegatedFrom: undefined, + lineage: { + ...task.lineage, + parentThreadId: task.id, + relationshipToParent: "subagent" as const, + }, + }; + const approval = { + id: RuntimeRequestId.make("cat-file"), + kind: "command" as const, + createdAt: v2Now, + }; + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [parent] }); + registry.set(snapshotAtom(remoteEnvironmentId), { + ...v2ShellSnapshot, + threads: [task, helper], + }); + const parentRef = { environmentId, threadId: parent.id }; + const inputs = threads.childThreadInputsAtom(parentRef); + const disposeNavigation = registry.mount(threads.navigationThreadShellsAtom); + const disposeParent = registry.mount(threads.threadShellAtom(parentRef)); + const disposeInputs = registry.mount(inputs); + const listed = () => + registry + .get(threads.navigationThreadShellsAtom) + .map((thread) => `${thread.environmentId}/${thread.id}`); + expect(listed()).toEqual([`${environmentId}/${parent.id}`]); + + // The helper the task started there asks for approval: the parent here hears of it. + registry.set(snapshotAtom(remoteEnvironmentId), { + ...v2ShellSnapshot, + threads: [task, { ...helper, pendingRuntimeRequest: approval }], + }); + expect(registry.get(threads.threadShellAtom(parentRef))?.pendingSubagentRequest).toBe( + "approval", + ); + expect(registry.get(inputs).map((thread) => `${thread.environmentId}/${thread.id}`)).toEqual([ + `${remoteEnvironmentId}/${helper.id}`, + ]); + + // A task whose parent is gone there is listed on its own, as is one whose + // parent's environment is switched off. + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [] }); + expect(listed()).toEqual([`${remoteEnvironmentId}/${task.id}`]); + // Archiving the parent here leaves the task there unarchived, so it is listed on its own. + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [{ ...parent, archivedAt: v2Now }], + }); + expect(listed()).toEqual([`${remoteEnvironmentId}/${task.id}`]); + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [parent] }); + expect(listed()).toEqual([`${environmentId}/${parent.id}`]); + const catalog = registry.get(catalogValueAtom); + const entries = new Map(catalog.entries); + entries.set(environmentId, { ...entries.get(environmentId)!, enabled: false }); + registry.set(catalogValueAtom, { ...catalog, entries }); + expect(listed()).toEqual([`${remoteEnvironmentId}/${task.id}`]); + disposeInputs(); + disposeParent(); + disposeNavigation(); + registry.dispose(); + }); + + it("brings an approval up a chain of tasks across three environments to its root", () => { + const thirdEnvironmentId = EnvironmentId.make("third-environment-v2"); + const { registry, threads, snapshotAtom } = makeHarness([ + environmentId, + remoteEnvironmentId, + thirdEnvironmentId, + ]); + const root = v2ThreadShell; + const running = { + creationSource: "mcp" as const, + status: "running" as const, + activityRunStatus: "running" as const, + }; + const task = { + ...v2ThreadShell, + ...running, + id: ThreadId.make("remote-task"), + delegatedFrom: { environmentId, threadId: root.id, title: root.title }, + }; + const nestedTask = { + ...v2ThreadShell, + ...running, + id: ThreadId.make("third-task"), + delegatedFrom: { environmentId: remoteEnvironmentId, threadId: task.id, title: task.title }, + }; + // A task the middle one delegates back to the root's own environment. + const returningTask = { + ...nestedTask, + id: ThreadId.make("returning-task"), + }; + const approval = { + id: RuntimeRequestId.make("cat-file"), + kind: "command" as const, + createdAt: v2Now, + }; + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root] }); + registry.set(snapshotAtom(remoteEnvironmentId), { ...v2ShellSnapshot, threads: [task] }); + registry.set(snapshotAtom(thirdEnvironmentId), { + ...v2ShellSnapshot, + threads: [{ ...nestedTask, pendingRuntimeRequest: approval }], + }); + const rootRef = { environmentId, threadId: root.id }; + const taskRef = { environmentId: remoteEnvironmentId, threadId: task.id }; + const disposeRoot = registry.mount(threads.threadShellAtom(rootRef)); + const disposeTask = registry.mount(threads.threadShellAtom(taskRef)); + expect(registry.get(threads.threadShellAtom(taskRef))?.pendingSubagentRequest).toBe("approval"); + expect(registry.get(threads.threadShellAtom(rootRef))?.pendingSubagentRequest).toBe("approval"); + + // Once it is answered, neither stays flagged. + registry.set(snapshotAtom(thirdEnvironmentId), { + ...v2ShellSnapshot, + threads: [nestedTask], + }); + expect(registry.get(threads.threadShellAtom(rootRef))?.pendingSubagentRequest).toBeNull(); + + // Environments that delegate to each other still settle on the root's flag. + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [root, { ...returningTask, pendingRuntimeRequest: approval }], + }); + expect(registry.get(threads.threadShellAtom(rootRef))?.pendingSubagentRequest).toBe("approval"); + disposeTask(); + disposeRoot(); + registry.dispose(); + }); + it.each([ { reason: "switched-off", state: { enabled: false } }, { diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 8d61ff6ac5677..69df72418c2d0 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -45,6 +45,20 @@ const isChildRunLive = ( thread.status === "running" || thread.status === "waiting"; +/** + * Whether a thread shows under its parent rather than in thread lists: a + * subagent, or a task a linked environment delegated here whose parent the + * client lists (`delegatedUnderParent`, by thread key). A task whose parent + * the client cannot list stays in the lists, so its work is never out of reach. + */ +export function isShownUnderParent( + ref: ScopedThreadRef, + lineage: OrchestrationV2ThreadShell["lineage"], + delegatedUnderParent: ReadonlySet, +): boolean { + return lineage.relationshipToParent === "subagent" || delegatedUnderParent.has(threadKey(ref)); +} + /** Provider-native subagents ask on their parent, so they are skipped. */ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentRequest | null { @@ -52,7 +66,7 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq if ( kind === undefined || kind === "auth_refresh" || - thread.lineage.relationshipToParent !== "subagent" || + (thread.lineage.relationshipToParent !== "subagent" && thread.delegatedFrom === undefined) || isProviderNativeSubagentThread(thread) || !isChildRunLive(thread) ) { @@ -61,28 +75,51 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq return kind === "user_input" ? "user_input" : "approval"; } -export function pendingSubagentRequestsByThread( +/** + * The threads to flag for a subagent's live question or approval, up its + * subagent chain. `delegated` are tasks this environment's threads delegated + * to linked environments, with what each one, or its own subagents, asks. + */ +function pendingSubagentRequestsByThread( threads: ReadonlyArray, + delegated: ReadonlyArray<{ + readonly parentThreadId: ThreadId; + readonly request: PendingSubagentRequest; + }> = [], ): ReadonlyMap { const requests = new Map(); let byId: ReadonlyMap | undefined; - for (const child of threads) { - const request = subagentRequest(child); - if (request === null) continue; + const flag = ( + firstParentId: ThreadId | null, + request: PendingSubagentRequest, + seen: Set, + ) => { byId ??= new Map(threads.map((thread) => [thread.id, thread] as const)); - const seen = new Set([child.id]); - let current: OrchestrationV2ThreadShell | undefined = child; - while (current?.lineage.relationshipToParent === "subagent") { - const parentId: ThreadId | null = current.lineage.parentThreadId; - if (parentId === null || seen.has(parentId)) break; + let parentId = firstParentId; + while (parentId !== null && !seen.has(parentId)) { seen.add(parentId); if (requests.get(parentId) !== "approval") requests.set(parentId, request); - current = byId.get(parentId); + const parent = byId.get(parentId); + parentId = + parent?.lineage.relationshipToParent === "subagent" ? parent.lineage.parentThreadId : null; } + }; + for (const child of threads) { + if (child.lineage.relationshipToParent !== "subagent") continue; + const request = subagentRequest(child); + if (request !== null) flag(child.lineage.parentThreadId, request, new Set([child.id])); } + for (const task of delegated) flag(task.parentThreadId, task.request, new Set()); return requests.size === 0 ? EMPTY_SUBAGENT_REQUESTS : requests; } +const sameRequests = ( + left: ReadonlyMap, + right: ReadonlyMap, +) => + left.size === right.size && + [...right].every(([threadId, request]) => left.get(threadId) === request); + export function childThreadInputsTitle( children: ReadonlyArray>, ): string { @@ -177,16 +214,75 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)), ); - const pendingSubagentRequestsAtom = Atom.family((environmentId: EnvironmentId) => { + // What an environment's own subagents ask. Kept apart from the tasks other + // environments run for it, so two environments delegating to each other + // never read each other's totals. + const localSubagentRequestsAtom = Atom.family((environmentId: EnvironmentId) => { let previous = EMPTY_SUBAGENT_REQUESTS; return Atom.make((get) => { const next = pendingSubagentRequestsByThread(get(environmentThreadsAtom(environmentId))); - if ( - next.size === previous.size && - [...next].every(([threadId, request]) => previous.get(threadId) === request) - ) { - return previous; + if (sameRequests(previous, next)) return previous; + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-local-subagent-requests:${environmentId}`)); + }); + + // Flags for environments whose threads delegated tasks, counting what each task + // or anything it delegated on asks. Each pass settles one more hop of a chain; + // flags only grow, so environments delegating to each other settle too. + const delegatedSubagentRequestsAtom = Atom.make((get) => { + const tasksByDelegator = new Map< + EnvironmentId, + Array<{ + readonly runsIn: EnvironmentId; + readonly thread: OrchestrationV2ThreadShell; + readonly parentThreadId: ThreadId; + }> + >(); + for (const runsIn of enabledEnvironmentIds(get(input.catalogValueAtom))) { + for (const thread of get(environmentThreadsAtom(runsIn))) { + const from = thread.delegatedFrom; + if (from === undefined || from.environmentId === runsIn) continue; + const task = { runsIn, thread, parentThreadId: from.threadId }; + const tasks = tasksByDelegator.get(from.environmentId); + if (tasks === undefined) tasksByDelegator.set(from.environmentId, [task]); + else tasks.push(task); } + } + let flags = new Map>(); + const requestsOf = (environmentId: EnvironmentId) => + flags.get(environmentId) ?? get(localSubagentRequestsAtom(environmentId)); + for (let changed = tasksByDelegator.size > 0; changed;) { + changed = false; + const next = new Map(flags); + for (const [environmentId, tasks] of tasksByDelegator) { + const delegated = tasks.flatMap(({ runsIn, thread, parentThreadId }) => { + const own = subagentRequest(thread); + const nested = requestsOf(runsIn).get(thread.id); + const request = own === "approval" ? own : (nested ?? own); + return request === null ? [] : [{ parentThreadId, request }]; + }); + if (delegated.length === 0) continue; + const requests = pendingSubagentRequestsByThread( + get(environmentThreadsAtom(environmentId)), + delegated, + ); + if (sameRequests(requestsOf(environmentId), requests)) continue; + next.set(environmentId, requests); + changed = true; + } + flags = next; + } + return flags; + }).pipe(Atom.withLabel("environment-delegated-subagent-requests")); + + const pendingSubagentRequestsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous = EMPTY_SUBAGENT_REQUESTS; + return Atom.make((get) => { + const next = + get(delegatedSubagentRequestsAtom).get(environmentId) ?? + get(localSubagentRequestsAtom(environmentId)); + if (sameRequests(previous, next)) return previous; previous = next; return next; }).pipe(Atom.withLabel(`environment-pending-subagent-requests:${environmentId}`)); @@ -280,30 +376,47 @@ export function createEnvironmentThreadShellAtoms(input: { }); // Child approvals and questions are hidden with their sidebar rows. Read their - // shell summaries, without subscribing to every child transcript. + // shell summaries, without subscribing to every child transcript. A task + // delegated to a linked environment is a child too, with its own subagents. const childThreadInputsAtomFamily = Atom.family((key: string) => { - const ref = parseThreadKey(key); let previous: ReadonlyArray = []; return Atom.make((get) => { - const children = new Map(); - for (const thread of get(environmentThreadsAtom(ref.environmentId))) { - const parent = thread.lineage.parentThreadId; - if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue; - const siblings = children.get(parent); - if (siblings) siblings.push(thread); - else children.set(parent, [thread]); + const children = new Map< + string, + Array<{ environmentId: EnvironmentId; thread: OrchestrationV2ThreadShell }> + >(); + const addChild = ( + parent: ScopedThreadRef, + environmentId: EnvironmentId, + thread: OrchestrationV2ThreadShell, + ) => { + const siblings = children.get(threadKey(parent)); + if (siblings) siblings.push({ environmentId, thread }); + else children.set(threadKey(parent), [{ environmentId, thread }]); + }; + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { + for (const thread of get(environmentThreadsAtom(environmentId))) { + const parent = thread.lineage.parentThreadId; + if (parent !== null && thread.lineage.relationshipToParent === "subagent") { + addChild({ environmentId, threadId: parent }, environmentId, thread); + } else if (thread.delegatedFrom !== undefined) { + addChild(thread.delegatedFrom, environmentId, thread); + } + } } - const subagentRequests = get(pendingSubagentRequestsAtom(ref.environmentId)); - const seen = new Set([ref.threadId]); - const pending = [ref.threadId]; + const seen = new Set([key]); + const pending = [key]; const next: EnvironmentThreadShell[] = []; for (const parent of pending) { - for (const child of children.get(parent) ?? []) { - if (seen.has(child.id)) continue; - seen.add(child.id); - pending.push(child.id); - if (subagentRequest(child) !== null) { - next.push(scopedThread(ref.environmentId, child, subagentRequests)); + for (const { environmentId, thread } of children.get(parent) ?? []) { + const childKey = threadKey({ environmentId, threadId: thread.id }); + if (seen.has(childKey)) continue; + seen.add(childKey); + pending.push(childKey); + if (subagentRequest(thread) !== null) { + next.push( + scopedThread(environmentId, thread, get(pendingSubagentRequestsAtom(environmentId))), + ); } } } @@ -313,6 +426,7 @@ export function createEnvironmentThreadShellAtoms(input: { previous.length === next.length && previous.every( (thread, index) => + thread.environmentId === next[index]?.environmentId && thread.id === next[index]?.id && thread.title === next[index]?.title && thread.hasPendingApprovals === next[index]?.hasPendingApprovals, @@ -402,14 +516,47 @@ export function createEnvironmentThreadShellAtoms(input: { return next; }).pipe(Atom.withLabel("environment-quiet-thread-shell-list")); + let previousDelegatedUnderParent: ReadonlySet = new Set(); + const delegatedUnderParentAtom = Atom.make((get) => { + const environmentIds = new Set(enabledEnvironmentIds(get(input.catalogValueAtom))); + const next = new Set(); + for (const environmentId of environmentIds) { + for (const thread of get(environmentThreadsAtom(environmentId))) { + const parent = thread.delegatedFrom; + if ( + parent !== undefined && + environmentIds.has(parent.environmentId) && + // An archived parent leaves navigation; its task there stays listed on its own. + get(environmentThreadIndexAtom(parent.environmentId)).get(parent.threadId)?.archivedAt === + null + ) { + next.add(threadKey({ environmentId, threadId: thread.id })); + } + } + } + if ( + next.size === previousDelegatedUnderParent.size && + [...next].every((key) => previousDelegatedUnderParent.has(key)) + ) { + return previousDelegatedUnderParent; + } + previousDelegatedUnderParent = next; + return next; + }).pipe(Atom.withLabel("environment-delegated-under-parent")); + let previousNavigationShells: ReadonlyArray = []; const navigationThreadShellsAtom = Atom.make((get) => { const next: EnvironmentThreadShell[] = []; + const underParent = get(delegatedUnderParentAtom); for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); for (const thread of get(environmentThreadsAtom(environmentId))) { - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") + if ( + thread.archivedAt !== null || + isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent) + ) { continue; + } next.push(scopedThread(environmentId, thread, subagentRequests)); } } @@ -427,6 +574,8 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsAtom, quietThreadShellsAtom, navigationThreadShellsAtom, + delegatedUnderParentAtom, + pendingSubagentRequestsAtom, threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 8ce02f8b650e1..2ee6f3d3bd0c0 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -19,6 +19,7 @@ import { GuestShareId, IsoDateTime, MessageId, + EnvironmentId, NodeId, ForwardCompatibleUnion, ForwardCompatibleUnionArray, @@ -126,6 +127,17 @@ export type OrchestrationV2LinkOrigin = typeof OrchestrationV2LinkOrigin.Type; export const linkOriginIsFenced = (origin: OrchestrationV2LinkOrigin) => origin.access !== "full-access"; +/** + * The thread in a linked environment that delegated this one, when it did: + * this thread is that parent's subagent, run here instead of there. + */ +export const OrchestrationV2DelegatedFrom = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + title: Schema.String, +}); +export type OrchestrationV2DelegatedFrom = typeof OrchestrationV2DelegatedFrom.Type; + export const OrchestrationV2NativeRefStrength = Schema.Literals(["strong", "weak", "none"]); export type OrchestrationV2NativeRefStrength = typeof OrchestrationV2NativeRefStrength.Type; @@ -438,6 +450,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ activeProviderThreadId: Schema.NullOr(ProviderThreadId), historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin), linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), + delegatedFrom: Schema.optional(OrchestrationV2DelegatedFrom), lineage: OrchestrationV2AppThreadLineage, forkedFrom: Schema.NullOr( Schema.Union([ @@ -812,6 +825,32 @@ export type OrchestrationV2SubagentWorkflow = typeof OrchestrationV2SubagentWork */ const DedupedBodyMarker = Schema.optionalKey(Schema.Literal(true)); +/** + * Where an app-owned task runs when it runs in a linked environment instead + * of in a child thread here: that environment and the ordinary thread it runs + * as there. Such a task has no `childThreadId`. + */ +export const OrchestrationV2RemoteTaskChild = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + label: Schema.String, + /** The run there that carries the task; absent on tasks recorded before it was kept. */ + runId: Schema.optional(RunId), + /** + * Nothing is left to stop there: a stop reached it, was refused for good, or the thread + * or link is gone. A run ending there by itself sets it too, unless the task here was + * cancelled or stopped and still owes a stop, which its follower keeps sending across + * restarts. `false`: a Stop here owes one to a task that already had its result. + */ + stoppedThere: Schema.optional(Schema.Boolean), + /** + * Stops asked for here once the task had its result. The count names the + * one owed, so each reaches the thread there once however often it is sent. + */ + stopCount: Schema.optional(NonNegativeInt), +}); +export type OrchestrationV2RemoteTaskChild = typeof OrchestrationV2RemoteTaskChild.Type; + export const OrchestrationV2Subagent = Schema.Struct({ id: NodeId, threadId: ThreadId, @@ -823,6 +862,7 @@ export const OrchestrationV2Subagent = Schema.Struct({ providerInstanceId: ProviderInstanceId, providerThreadId: Schema.NullOr(ProviderThreadId), childThreadId: Schema.NullOr(ThreadId), + remoteChild: Schema.optional(OrchestrationV2RemoteTaskChild), nativeTaskRef: Schema.NullOr(OrchestrationV2ProviderRef), prompt: Schema.String, title: Schema.NullOr(Schema.String), @@ -1663,6 +1703,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ driver: ProviderDriverKind, providerInstanceId: ProviderInstanceId, childThreadId: Schema.NullOr(ThreadId), + remoteChild: Schema.optional(OrchestrationV2RemoteTaskChild), prompt: Schema.String, progress: Schema.optional(Schema.String), result: Schema.NullOr(Schema.String), @@ -1967,6 +2008,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ activeProviderThreadId: Schema.NullOr(ProviderThreadId), historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin), linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), + delegatedFrom: Schema.optional(OrchestrationV2DelegatedFrom), latestRunId: Schema.NullOr(RunId), latestRunRequestedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -2496,6 +2538,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ driver: ProviderDriverKind, providerInstanceId: ProviderInstanceId, childThreadId: Schema.NullOr(ThreadId), + remoteChild: Schema.optional(OrchestrationV2RemoteTaskChild), prompt: Schema.String, progress: Schema.optional(Schema.String), result: Schema.NullOr(Schema.String), @@ -2801,6 +2844,8 @@ export const OrchestrationV2Command = Schema.Union([ worktreePath: Schema.NullOr(TrimmedNonEmptyString), /** Only the server sets this; a client's thread.create never carries one. */ linkOrigin: Schema.optional(OrchestrationV2LinkOrigin), + /** Only the server sets this, with `linkOrigin`, for a linked environment's delegated task. */ + delegatedFrom: Schema.optional(OrchestrationV2DelegatedFrom), importedNativeThread: Schema.optional( Schema.Struct({ ref: Schema.Struct({ @@ -3287,6 +3332,59 @@ const OrchestrationV2InternalCommand = Schema.Union([ }), ), }), + /** + * Records a delegated task that runs in a linked environment: the task, its + * node and its turn item on the parent, as `delegated_task.request` does, + * without a child thread here. `remoteChild` is the thread already launched + * there. The ids derive from `commandId`, so a retry records nothing new. + */ + Schema.Struct({ + type: Schema.Literal("delegated_task.remote.request"), + commandId: CommandId, + parentThreadId: ThreadId, + parentRunId: RunId, + parentNodeId: NodeId, + task: TrimmedNonEmptyString, + title: Schema.optional(TrimmedNonEmptyString), + driver: ProviderDriverKind, + modelSelection: ModelSelection, + remoteChild: OrchestrationV2RemoteTaskChild, + completionWake: Schema.optional(Schema.Literals(["always", "settled_only"])), + }), + /** + * Completes a delegated task that ran in a linked environment, with what its + * thread there ended as. The parent wakes exactly as for a child here. A + * task that already has a result keeps it; `stoppedThere` is still recorded, + * and without it (a Stop or cancel here) the task owes a stop there instead. + */ + Schema.Struct({ + type: Schema.Literal("delegated_task.remote.complete"), + commandId: CommandId, + parentThreadId: ThreadId, + taskId: NodeId, + status: Schema.Literals(["completed", "failed", "cancelled", "interrupted"]), + result: Schema.String, + /** + * `ran-out`: the run there ended by itself; `stopped`: a stop from here reached it. Either + * sets `remoteChild.stoppedThere` on a task this report completes. On a task already + * cancelled or stopped here only `stopped` does, since a run that ended by itself leaves + * the stop owed for whatever follows it. + */ + stoppedThere: Schema.optional(Schema.Literals(["ran-out", "stopped"])), + /** The stop generation this report acknowledges; absent for the original unnumbered stop. */ + stopCount: Schema.optional(NonNegativeInt), + }), + /** + * Owes a stop to the thread there of a remote task that already has its result, as Stop on + * a parent stops a finished child here. The task keeps its status, result and completion + * delivery; its follower sends the stop and reports it with `delegated_task.remote.complete`. + */ + Schema.Struct({ + type: Schema.Literal("delegated_task.remote.stop"), + commandId: CommandId, + parentThreadId: ThreadId, + taskId: NodeId, + }), Schema.Struct({ type: Schema.Literal("checkpoint.rollback.fail"), commandId: CommandId, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index db76b53e01f66..97911133f4db3 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -111,6 +111,18 @@ export const OrchestratorMcpTarget = Schema.Struct({ "Options from `orchestrator_capabilities`, such as {effort:'max'}. Given values override inherited. [] or {} clears.", }), ), + environmentId: Schema.optional( + EnvironmentId.annotate({ + description: + "Linked environment to run in. Provider and model default to yours; others come from `orchestrator_capabilities` with this `environmentId`.", + }), + ), + projectId: Schema.optional( + ProjectId.annotate({ + description: + "With `environmentId`: its project to run in. Default: the one with this project's repository.", + }), + ), }); export type OrchestratorMcpTarget = typeof OrchestratorMcpTarget.Type; @@ -191,7 +203,12 @@ const OrchestratorMcpRunningTaskNext = Schema.Literal(ORCHESTRATOR_MCP_RUNNING_T export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ taskId: NodeId, - childThreadId: ThreadId, + /** Null for a task that runs in a linked environment; see `remoteChild`. */ + childThreadId: Schema.NullOr(ThreadId), + /** The linked environment and thread a remote task runs as. */ + remoteChild: Schema.optional( + Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId, label: Schema.String }), + ), link: Schema.optional(Schema.String), childRunId: Schema.NullOr(RunId), childNodeId: NodeId, @@ -503,6 +520,12 @@ export const OrchestratorMcpThreadWaitInput = Schema.Struct({ threadId: ThreadId, runId: Schema.optional(RunId), timeoutMs: Schema.optional(Schema.Finite), + untilDelegatedWorkSettled: Schema.optional( + Schema.Boolean.annotate({ + description: + "Also wait for its delegated and background tasks and their completions to settle. `resultRun` then names the run with its final answer.", + }), + ), }); export type OrchestratorMcpThreadWaitInput = typeof OrchestratorMcpThreadWaitInput.Type; @@ -512,6 +535,8 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ status: OrchestratorMcpThreadStatus, timedOut: Schema.Boolean, next: Schema.optional(OrchestratorMcpRunningTaskNext), + /** With untilDelegatedWorkSettled, once settled: the run whose reply is the thread's result. */ + resultRun: Schema.optional(Schema.Struct({ runId: RunId, status: OrchestrationV2RunStatus })), }); export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type; From 8b14660b27872c4ebc061ea2049632ae2762e471 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:27 -0700 Subject: [PATCH 8/9] feat(server,web,mobile): an agent sets up a link to another environment (#17129) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/threads/LinkRequestCard.tsx | 514 +++++++++ .../src/features/threads/ThreadFeed.tsx | 12 + apps/mobile/src/lib/filePreview.test.ts | 1 + apps/mobile/src/lib/threadActivity.ts | 14 +- apps/mobile/src/state/peerLinks.ts | 43 + apps/mobile/src/state/peerPairingCode.ts | 24 + apps/server/src/auth/EnvironmentAuth.ts | 18 +- apps/server/src/auth/McpOAuth.test.ts | 19 + apps/server/src/auth/McpOAuth.ts | 7 +- apps/server/src/auth/mcpOAuthHttp.ts | 6 +- apps/server/src/mcp/OrchestratorMcpService.ts | 4 + ...OrchestratorMcpToolkit.integration.test.ts | 4 + apps/server/src/mcp/linkOrigin.test.ts | 4 + apps/server/src/mcp/toolkits/core.test.ts | 12 + .../src/mcp/toolkits/orchestrator/handlers.ts | 25 + .../src/mcp/toolkits/orchestrator/tools.ts | 42 +- .../src/observability/RpcInstrumentation.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 201 ++++ .../src/orchestration-v2/ProjectionStore.ts | 50 +- .../ProviderRuntimeRecoveryService.test.ts | 83 ++ .../ProviderRuntimeRecoveryService.ts | 2 + apps/server/src/peer/PeerForwarding.test.ts | 4 + apps/server/src/peer/PeerForwarding.ts | 8 + apps/server/src/peer/PeerLinkRequests.test.ts | 979 ++++++++++++++++++ apps/server/src/peer/PeerLinkRequests.ts | 412 ++++++++ apps/server/src/peer/PeerLinks.test.ts | 119 ++- apps/server/src/peer/PeerLinks.testkit.ts | 7 +- apps/server/src/peer/PeerLinks.ts | 114 +- apps/server/src/peer/RemoteDelegation.test.ts | 4 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/072_WaitingLinkRequestIndex.ts | 13 + .../reconcileV2PreviewMigration.test.ts | 2 + .../src/provider/AgentContextBudget.test.ts | 4 + .../src/provider/agentContextBudget.json | 6 +- apps/server/src/server.ts | 3 + apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 8 + .../components/auth/ConnectAgentSurface.tsx | 2 +- .../src/components/chat/LinkRequestCard.tsx | 510 +++++++++ .../components/chat/TimelineV2EventRow.tsx | 10 + .../components/settings/LinkAccessPicker.tsx | 63 ++ .../components/settings/LinkMachineSelect.tsx | 87 ++ .../settings/LinkedEnvironmentsSettings.tsx | 189 +++- apps/web/src/session-logic.ts | 2 + apps/web/src/state/peerLinks.ts | 39 +- apps/web/src/state/peerPairingCode.ts | 24 + docs/user/remote-access.md | 11 +- packages/client-runtime/package.json | 8 + .../client-runtime/src/linkRequest.test.ts | 377 +++++++ packages/client-runtime/src/linkRequest.ts | 284 +++++ .../src/state/commandPermissions.test.ts | 24 + .../src/state/peerPairingCode.ts | 95 ++ packages/client-runtime/src/state/server.ts | 10 + packages/client-runtime/src/t3ToolSummary.ts | 10 + packages/contracts/src/auth.ts | 13 + .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/orchestrationV2.ts | 75 ++ packages/contracts/src/orchestratorMcp.ts | 70 ++ packages/contracts/src/peerLink.ts | 66 +- packages/contracts/src/rpc.ts | 9 + .../src/server/orchestrationInstructions.ts | 1 + packages/shared/src/t3McpToolPresentation.ts | 10 + 62 files changed, 4642 insertions(+), 122 deletions(-) create mode 100644 apps/mobile/src/features/threads/LinkRequestCard.tsx create mode 100644 apps/mobile/src/state/peerPairingCode.ts create mode 100644 apps/server/src/peer/PeerLinkRequests.test.ts create mode 100644 apps/server/src/peer/PeerLinkRequests.ts create mode 100644 apps/server/src/persistence/Migrations/072_WaitingLinkRequestIndex.ts create mode 100644 apps/web/src/components/chat/LinkRequestCard.tsx create mode 100644 apps/web/src/components/settings/LinkAccessPicker.tsx create mode 100644 apps/web/src/components/settings/LinkMachineSelect.tsx create mode 100644 apps/web/src/state/peerPairingCode.ts create mode 100644 packages/client-runtime/src/linkRequest.test.ts create mode 100644 packages/client-runtime/src/linkRequest.ts create mode 100644 packages/client-runtime/src/state/peerPairingCode.ts diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx new file mode 100644 index 0000000000000..ef5516b8d095b --- /dev/null +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -0,0 +1,514 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type LinkableMachine, + linkRequestAnswerInput, + linkRequestDefaultAccess, + linkRequestDisplay, + linkRequestFailureMessage, + linkRequestInitialMachine, + type LinkRequestItem, + linkRequestTargetName, + rankMachinesByHint, +} from "@t3tools/client-runtime/link-request"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthAccessWriteScope, + type AuthMcpClientAccess, + type EnvironmentId, + type OrchestrationV2ProjectedTurnItem, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { useMemo, useRef, useState } from "react"; +import { Modal, Platform, Pressable, ScrollView, View, type ColorValue } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; +import { useLinkableMachines } from "../../state/peerLinks"; +import { mintPairingCodeOn } from "../../state/peerPairingCode"; +import { serverEnvironment } from "../../state/server"; +import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsChoiceRow } from "../settings/components/SettingsChoiceRow"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { Button } from "../../components/ui/Button"; +import { RUNTIME_MODE_CHOICES } from "./thread-settings-options"; + +/** The same five levels as web's access picker: read-only, then the runtime modes. */ +const ACCESS_CHOICES: ReadonlyArray<{ + readonly access: AuthMcpClientAccess; + readonly label: string; + readonly description: string; +}> = [ + { + access: "read-only", + label: "Read only", + description: "Read projects and threads. Cannot start, message or change anything.", + }, + ...RUNTIME_MODE_CHOICES.map((choice) => ({ ...choice, access: choice.mode })), +]; +const accessLabel = (access: AuthMcpClientAccess) => + ACCESS_CHOICES.find((choice) => choice.access === access)?.label ?? access; + +const LINK_SYMBOL: AppSymbolName = { ios: "link", android: "desktop_windows" }; + +/** The machine list's row for linking by an address typed in. */ +const ANOTHER_ADDRESS = "another-address"; + +/** Whether the agent left the machine for the user to pick. */ +const picksMachine = (item: LinkRequestItem) => + item.environmentId === undefined && item.url === undefined; + +/** + * Feed card for a link an agent asked the user to make. The user picks the + * access in a sheet; the pairing code lives only in that sheet's state and + * the RPC payload, and is never logged, alerted, or persisted. + */ +export function LinkRequestCard(props: { + readonly environmentId: EnvironmentId; + readonly projectedItem: OrchestrationV2ProjectedTurnItem; + readonly iconColor: ColorValue; +}) { + const { item, visibility } = props.projectedItem; + if (item.type !== "link_request") return null; + const display = linkRequestDisplay(item, visibility, accessLabel); + if (display.kind === "pending") { + return ; + } + const icon: AppSymbolName = + display.kind === "pending-elsewhere" + ? LINK_SYMBOL + : display.outcome === "linked" + ? "checkmark" + : display.outcome === "failed" + ? "xmark" + : "minus"; + return ( + + + + {display.label} + + + ); +} + +function PendingLinkRequest(props: { + readonly environmentId: EnvironmentId; + readonly item: LinkRequestItem; +}) { + const { item } = props; + const [open, setOpen] = useState(false); + const [declining, setDeclining] = useState(false); + const [error, setError] = useState(null); + const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + label: "decline link request", + reportFailure: false, + reportDefect: false, + }); + const canAnswer = useAtomValue( + serverEnvironment.answerLinkRequest.permissionAtom(props.environmentId), + ); + const target = linkRequestTargetName(item); + + const decline = async () => { + const input = linkRequestAnswerInput(item, { type: "decline" }); + if (input === null) return; + setDeclining(true); + setError(null); + const result = await answer({ environmentId: props.environmentId, input }).finally(() => + setDeclining(false), + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + } + }; + + return ( + + + + {picksMachine(item) + ? "Link a machine to this environment" + : `Link ${target} to this environment`} + + {item.reason.trim() ? ( + {item.reason} + ) : null} + {item.label !== undefined && item.url !== undefined ? ( + + {item.url} + + ) : null} + + {error !== null ? ( + + {error} + + ) : null} + {canAnswer ? ( + <> + + + + ) : ( +

    + Only a session that manages access here can answer this. +

    + )} + + + ); +} + +/** + * What the dialog links: a machine from the client's catalog, or an address. + * `urls` is empty when the client knows no direct address for the machine, + * so the user types one. + */ +type DialogTarget = + | { readonly kind: "none" } + | { + readonly kind: "machine"; + readonly environmentId: EnvironmentId; + readonly label: string; + readonly urls: ReadonlyArray; + readonly linked: boolean; + } + | { readonly kind: "address" }; + +function resolveTarget( + item: LinkRequestItem, + machines: ReadonlyArray, + choice: string | null, +): DialogTarget { + if (item.environmentId === undefined && item.url !== undefined) return { kind: "address" }; + const chosen = item.environmentId ?? choice; + if (chosen === null) return { kind: "none" }; + if (chosen === ANOTHER_ADDRESS) return { kind: "address" }; + const machine = machines.find((candidate) => candidate.environmentId === chosen); + return { + kind: "machine", + environmentId: machine?.environmentId ?? (chosen as EnvironmentId), + label: machine?.label ?? linkRequestTargetName(item), + urls: [...new Set([...(machine?.urls ?? []), ...(item.url === undefined ? [] : [item.url])])], + linked: machine?.linked ?? false, + }; +} + +/** The dialog's steps: where to link, then what agents may do there. */ +type LinkStep = "machine" | "access"; + +function LinkRequestDialog(props: { + readonly environmentId: EnvironmentId; + readonly item: LinkRequestItem; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + const { item } = props; + const formId = useId(); + const pickMachine = picksMachine(item); + const listed = useLinkableMachines(props.environmentId, props.open); + const machines = rankMachinesByHint(listed, item.hint); + // Null until the user picks; until then the hint's best match is picked. + const [choice, setChoice] = useState(null); + const [address, setAddress] = useState(item.url ?? ""); + const [access, setAccess] = useState(() => linkRequestDefaultAccess(item)); + const [pairingCode, setPairingCode] = useState(""); + const [step, setStep] = useState("machine"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Machines whose minting already failed, so the dialog asks for a code instead. + const [mintFailed, setMintFailed] = useState>(new Set()); + // Enter then a click can both run before a re-render; this guard is synchronous. + const inFlight = useRef(false); + const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + label: "answer link request", + // The failure cause holds the request, and with it the code; keep it out of the console. + reportFailure: false, + reportDefect: false, + }); + const selected = choice ?? linkRequestInitialMachine(item, machines); + const target = resolveTarget(item, machines, selected); + const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item); + // This client can mint a code there when it is connected to that + // environment with a session that may create pairing links. + const targetId = target.kind === "machine" ? target.environmentId : null; + const connected = usePreparedConnection(targetId); + const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); + const canMint = + targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); + const needsAddress = + target.kind === "address" || (target.kind === "machine" && target.urls.length === 0); + // An address the agent gave is already filled in; only one the user must type is asked for. + const asksAddress = needsAddress && !(item.url !== undefined && item.environmentId === undefined); + const useExisting = target.kind === "machine" && target.linked; + // A named machine with an address it answers at has nothing to choose before access. + const steps: ReadonlyArray = + pickMachine || asksAddress ? ["machine", "access"] : ["access"]; + const current: LinkStep = steps.includes(step) ? step : "access"; + const machineReady = + target.kind !== "none" && (useExisting || !needsAddress || address.trim().length > 0); + const ready = machineReady && (useExisting || canMint || pairingCode.trim().length > 0); + + const reset = () => { + setChoice(null); + setAddress(item.url ?? ""); + setAccess(linkRequestDefaultAccess(item)); + setPairingCode(""); + setStep("machine"); + setError(null); + }; + + // Every close resets, so a reopened dialog never shows the last answer or its pairing code. + const close = () => { + props.onOpenChange(false); + reset(); + }; + + // Settles to the answer to send, an error to show, or nothing to do. + const prepareAnswer = async () => { + if (target.kind === "none") return null; + if (target.kind === "machine" && target.linked) { + return linkRequestAnswerInput(item, { + type: "use-existing", + environmentId: target.environmentId, + }); + } + let code = pairingCode; + if (canMint && targetId !== null) { + const minted = await mintPairingCodeOn(targetId, access); + if (Result.isFailure(minted)) { + setMintFailed((failed) => new Set(failed).add(targetId)); + setError(linkRequestFailureMessage(minted.failure)); + return null; + } + code = minted.success; + } + return linkRequestAnswerInput(item, { + type: "link", + target: + target.kind === "address" + ? { url: address } + : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, + access, + pairingCode: code, + }); + }; + + const submit = async () => { + if (inFlight.current || target.kind === "none") return; + inFlight.current = true; + setBusy(true); + setError(null); + const input = await prepareAnswer(); + const result = + input === null ? null : await answer({ environmentId: props.environmentId, input }); + inFlight.current = false; + setBusy(false); + if (result === null) return; + if (result._tag === "Success") { + // The card switches to its answered row once the item updates. + close(); + } else if (!isAtomCommandInterrupted(result)) { + setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + } + }; + + // The machine step ends the dialog when the machine is linked here already. + const finishesHere = current === "access" || useExisting; + + return ( + { + // An answer that lands after a close would still link, and would close a reopened dialog. + if (!next && busy) return; + if (next) props.onOpenChange(true); + else close(); + }} + > + + + {steps.length > 1 && !useExisting ? ( + busy || (index === 1 && !machineReady)} + onStepChange={(index) => { + const next = steps[index]; + if (next !== undefined) setStep(next); + }} + /> + ) : null} + + +
    { + event.preventDefault(); + if (finishesHere) void submit(); + else if (machineReady) setStep("access"); + }} + > + {current === "machine" ? ( + <> + {pickMachine ? ( + { + setChoice(next); + setError(null); + }} + /> + ) : null} + {asksAddress ? ( + + ) : null} + + ) : ( + <> + {item.url !== undefined && !pickMachine ? ( +

    {item.url}

    + ) : null} + {useExisting ? null : ( + <> + {canMint ? null : ( + + )} + + + )} + + )} + {useExisting ? ( +

    + This environment is already linked to {targetName}. The agent can use that link as + it is; change its access in Settings → Connections. +

    + ) : null} + {error !== null ? ( +

    + {error} +

    + ) : null} + +
    + + {current === "access" && steps.length > 1 ? ( + + ) : ( + + )} + + +
    +
    + ); +} + +/** The user's other machines, best match for the agent's hint first, then an address. */ +function MachinePicker(props: { + readonly machines: ReadonlyArray; + readonly value: string | null; + readonly disabled: boolean; + readonly onChange: (value: string) => void; +}) { + const labelId = useId(); + return ( +
    + + Machine + + props.onChange(next as string)} + > + {props.machines.map((machine) => ( + + ))} + + +
    + ); +} diff --git a/apps/web/src/components/chat/TimelineV2EventRow.tsx b/apps/web/src/components/chat/TimelineV2EventRow.tsx index 650c930cd110d..04d6effdd456d 100644 --- a/apps/web/src/components/chat/TimelineV2EventRow.tsx +++ b/apps/web/src/components/chat/TimelineV2EventRow.tsx @@ -18,6 +18,7 @@ import { SubagentElapsed, V2LifecycleRow, } from "./V2LifecycleRow"; +import { LinkRequestCard } from "./LinkRequestCard"; import { SecretRequestCard } from "./SecretRequestCard"; import { TimelineRowCtx, type TimelineRow } from "./timelineRowContext"; import { v2EventPresentation, subagentGroupTiming } from "./TimelineV2EventRow.logic"; @@ -37,6 +38,15 @@ export function V2EventTimelineRow({ row }: { row: Extract ); } + if (item.type === "link_request") { + return ( + + ); + } if (isV2LifecycleItem(item)) { return ( void; + readonly disabled?: boolean; +}) { + const labelId = useId(); + const selected = accessConfig[value]; + return ( +
    + + What agents here may do there + + +

    + {selected.description} An agent never gets more access there than its own mode here. Below + full access, it can change only threads this link started, not that machine's own threads, + projects or settings. +

    +
    + ); +} diff --git a/apps/web/src/components/settings/LinkMachineSelect.tsx b/apps/web/src/components/settings/LinkMachineSelect.tsx new file mode 100644 index 0000000000000..9977f0bfc699e --- /dev/null +++ b/apps/web/src/components/settings/LinkMachineSelect.tsx @@ -0,0 +1,87 @@ +import type { LinkableMachine } from "@t3tools/client-runtime/link-request"; +import { useId } from "react"; + +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Badge } from "../ui/badge"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; + +/** The machine choice for linking by an address typed in. */ +export const ANOTHER_ADDRESS = "another-address"; + +/** Whether a machine is linked already, or how this app knows it when not connected to it. */ +export function LinkMachineBadge({ machine }: { readonly machine: LinkableMachine }) { + if (machine.linked) { + return ( + + Linked + + ); + } + if (machine.connected) return null; + return ( + + {machine.connectOnly ? "T3 Connect" : "Not connected"} + + ); +} + +function MachineName({ machine }: { readonly machine: LinkableMachine }) { + return ( + + + {machine.label} + + ); +} + +/** The user's other machines, including those on their T3 Connect account, then an address. */ +export function LinkMachineSelect({ + machines, + value, + onChange, + disabled, +}: { + readonly machines: ReadonlyArray; + readonly value: string | null; + readonly onChange: (value: string) => void; + readonly disabled: boolean; +}) { + const labelId = useId(); + return ( +
    + + Machine + + +
    + ); +} diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index c4804281f54c1..3b19489dd1e97 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -1,19 +1,29 @@ import { useAtomValue } from "@effect/atom-react"; -import { AuthMcpClientAccess, type EnvironmentId, type PeerLinkSummary } from "@t3tools/contracts"; +import { linkRequestFailureMessage } from "@t3tools/client-runtime/link-request"; +import { + AuthAccessWriteScope, + type AuthMcpClientAccess, + type EnvironmentId, + type PeerLinkSummary, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import { PlusIcon } from "lucide-react"; -import { useId, useState } from "react"; +import { useId, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; -import { peerLinkEnvironment } from "~/state/peerLinks"; +import { mintPairingCodeOn } from "~/state/peerPairingCode"; +import { peerLinkEnvironment, useLinkableMachines } from "~/state/peerLinks"; import { useEnvironmentQuery } from "~/state/query"; +import { useEnvironmentScope, usePreparedConnection } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; -import { AccessOption, accessConfig } from "../auth/ConnectAgentSurface"; +import { accessConfig } from "../auth/ConnectAgentSurface"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { Button } from "../ui/button"; import { @@ -28,9 +38,10 @@ import { DialogTrigger, } from "../ui/dialog"; import { Input } from "../ui/input"; -import { RadioGroup } from "../ui/radio-group"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { LinkAccessPicker } from "./LinkAccessPicker"; +import { ANOTHER_ADDRESS, LinkMachineSelect } from "./LinkMachineSelect"; import { SettingsRow, SettingsSection } from "./settingsLayout"; import { useNowMinute } from "../../hooks/useNowMinute"; import { searchableSetting } from "./settingsSearch"; @@ -157,28 +168,77 @@ function LinkedEnvironmentRow({ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: EnvironmentId }) { const [open, setOpen] = useState(false); - const [url, setUrl] = useState(""); + const machines = useLinkableMachines(environmentId, open); + // Null until the user picks; with no other machines known, only an address is left. + const [choice, setChoice] = useState(null); + const [address, setAddress] = useState(""); const [pairingCode, setPairingCode] = useState(""); const [access, setAccess] = useState("approval-required"); const [busy, setBusy] = useState(false); + // Machines whose minting already failed, so the dialog asks for a code instead. + const [mintFailed, setMintFailed] = useState>(new Set()); + // Enter then a click can both run before a re-render; this guard is synchronous. + const inFlight = useRef(false); const formId = useId(); - const accessLabelId = `${formId}-access`; const link = useAtomCommand(peerLinkEnvironment.link, { label: "link environment" }); + const selected = choice ?? (machines.length === 0 ? ANOTHER_ADDRESS : null); + const machine = machines.find((candidate) => candidate.environmentId === selected) ?? null; + // This client can mint a code there when it is connected to that + // environment with a session that may create pairing links. + const targetId = machine?.environmentId ?? null; + const connected = usePreparedConnection(targetId); + const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); + const canMint = + targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); + const needsAddress = selected === ANOTHER_ADDRESS || machine?.urls.length === 0; + const ready = + selected !== null && + (!needsAddress || address.trim().length > 0) && + (canMint || pairingCode.trim().length > 0); const handleOpenChange = (next: boolean) => { setOpen(next); if (!next) { - setUrl(""); + setChoice(null); + setAddress(""); setPairingCode(""); setAccess("approval-required"); + setMintFailed(new Set()); } }; + // Settles to the code to link with, or null after reporting why there is none. + const resolvePairingCode = async () => { + if (!canMint || targetId === null) return pairingCode.trim(); + const minted = await mintPairingCodeOn(targetId, access); + if (Result.isSuccess(minted)) return minted.success; + setMintFailed((failed) => new Set(failed).add(targetId)); + failureToast("Could not link the environment", linkRequestFailureMessage(minted.failure)); + return null; + }; const submit = async () => { + const [url, ...alternateUrls] = + machine === null || needsAddress ? [address.trim()] : machine.urls; + if (inFlight.current || !ready || url === undefined) return; + inFlight.current = true; setBusy(true); - const result = await link({ - environmentId, - input: { url: url.trim(), pairingCode: pairingCode.trim(), access }, - }); + const code = await resolvePairingCode(); + const result = + code === null + ? null + : await link({ + environmentId, + input: { + url, + ...(alternateUrls.length > 0 ? { alternateUrls } : {}), + ...(machine === null + ? {} + : { expectedEnvironmentId: machine.environmentId, expectedLabel: machine.label }), + pairingCode: code, + access, + }, + }); + inFlight.current = false; setBusy(false); + if (result === null) return; if (result._tag === "Success") { toastManager.add({ type: "success", title: `Linked ${result.value.label}` }); handleOpenChange(false); @@ -214,66 +274,81 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi
    { event.preventDefault(); void submit(); }} > - - -
    - - What agents here may do there - - setAccess(value as AuthMcpClientAccess)} - > - {AuthMcpClientAccess.literals.map((option) => ( - - ))} - + ) : null} + {machine?.linked ? (

    - An agent never gets more access there than its own mode here. Below full access, it - can change only threads this link started, not that environment's own threads, - projects or settings. + Already linked. Linking again renews it with the access you pick.

    -
    + ) : null} + {needsAddress ? ( + + ) : null} + {selected === null || canMint ? null : ( + + )} + }> Cancel - diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7fef6c52fe31d..eb61b244075a3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -300,6 +300,7 @@ const STANDALONE_V2_ITEM_TYPES = new Set readable.has(environment.environmentId)); } + +/** + * The user's other machines, for picking one to link `environmentId` to, + * including those only on their T3 Connect account. Reads that environment's + * links only while `enabled`, since listing them asks every linked machine + * whether it answers. + */ +export function useLinkableMachines(environmentId: EnvironmentId, enabled: boolean) { + const { environments } = useEnvironments(); + const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom); + const refreshDiscovery = useAtomCommand(relayEnvironmentDiscovery.refresh, { + reportFailure: false, + }); + // Connect hosts are listed only once something asked for them, so ask on open. + const discoverIfUnasked = useEffectEvent(() => { + if (discovery.environments.size === 0 && !discovery.refreshing) void refreshDiscovery(); + }); + // oxlint-disable-next-line t3code/react-effect-budget -- discovery follows the picker's visibility, which a card's mount or a dialog's open sets + useEffect(() => { + if (enabled) discoverIfUnasked(); + }, [enabled]); + const links = useEnvironmentQuery( + enabled ? peerLinkEnvironment.list({ environmentId, input: {} }) : null, + ); + return linkableMachines({ + environments, + threadEnvironmentId: environmentId, + links: links.data?.links ?? [], + connect: discovery.environments, + }); +} diff --git a/apps/web/src/state/peerPairingCode.ts b/apps/web/src/state/peerPairingCode.ts new file mode 100644 index 0000000000000..3ac16819ecc96 --- /dev/null +++ b/apps/web/src/state/peerPairingCode.ts @@ -0,0 +1,24 @@ +import { createPeerPairingCodeCommand } from "@t3tools/client-runtime/state/peerPairingCode"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { AuthMcpClientAccess, EnvironmentId } from "@t3tools/contracts"; +import * as Result from "effect/Result"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +const mintPairingCode = createPeerPairingCodeCommand(connectionAtomRuntime); + +/** + * A fresh pairing code minted on `environmentId` with this client's session + * there, for linking with `access`. Fails with `PeerPairingCodeError` when the + * session may not create one. + */ +export async function mintPairingCodeOn( + environmentId: EnvironmentId, + access: AuthMcpClientAccess, +): Promise> { + const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: access }); + return result._tag === "Success" + ? Result.succeed(result.value) + : Result.fail(squashAtomCommandFailure(result)); +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index a14285d97446f..801dabf3b317d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -253,10 +253,17 @@ Link two machines, such as your laptop and a VPS, and agents on one can work on the other: launch, message, wait on and stop its threads, answer their questions, and manage its queues, projects and scheduled tasks. On the machine you will work from, open **Settings → Connections → Linked environments**, -choose **Link**, and enter the other machine's address and a pairing code from -it. From a terminal, use +choose **Link**, and pick the other machine or enter its address. The list +holds the machines this app knows and those on your T3 Connect account. If this +app is connected to that machine with a session that manages access there, it +makes the pairing code itself; otherwise paste one from that machine's +**Settings → Connections**. From a terminal, use `t3 environment link
    --pairing-code --access `. +You can also ask an agent to link a machine by name, such as "delegate this to +my vps". It shows a card in the thread where you pick the machine the same way +and the access, then approve. + - The access you pick is the most agents from this machine may do there. An agent also never gets more there than its own mode here. - Threads a link starts are marked **From** and this machine's name there. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 949ef95e0fb46..c08c6e59a2ec8 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -55,6 +55,10 @@ "types": "./src/secretRequest.ts", "default": "./src/secretRequest.ts" }, + "./link-request": { + "types": "./src/linkRequest.ts", + "default": "./src/linkRequest.ts" + }, "./scheduled-task-webhook": { "types": "./src/scheduledTaskWebhook.ts", "default": "./src/scheduledTaskWebhook.ts" @@ -211,6 +215,10 @@ "types": "./src/state/peerLinks.ts", "default": "./src/state/peerLinks.ts" }, + "./state/peerPairingCode": { + "types": "./src/state/peerPairingCode.ts", + "default": "./src/state/peerPairingCode.ts" + }, "./state/preview": { "types": "./src/state/preview.ts", "default": "./src/state/preview.ts" diff --git a/packages/client-runtime/src/linkRequest.test.ts b/packages/client-runtime/src/linkRequest.test.ts new file mode 100644 index 0000000000000..f6ea56259c9f0 --- /dev/null +++ b/packages/client-runtime/src/linkRequest.test.ts @@ -0,0 +1,377 @@ +import { EnvironmentId, type ServerConfig, ThreadId, TurnItemId } from "@t3tools/contracts"; +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; +import * as Option from "effect/Option"; +import { describe, expect, it } from "vite-plus/test"; + +import { BearerConnectionProfile } from "./connection/catalog.ts"; +import { BearerConnectionTarget, RelayConnectionTarget } from "./connection/model.ts"; +import type { EnvironmentPresentation } from "./connection/presentation.ts"; +import type { RelayDiscoveredEnvironment } from "./relay/discovery.ts"; +import { + linkableMachines, + linkRequestAnswerInput, + linkRequestDisplay, + linkRequestFailureMessage, + linkRequestInitialMachine, + rankMachinesByHint, +} from "./linkRequest.ts"; + +const item = { + id: TurnItemId.make("turn-item:link-request:1"), + threadId: ThreadId.make("thread-1"), +}; +const asked = { url: "https://box.example.ts.net", label: "Box" }; +const accessLabel = (access: string) => `<${access}>`; + +describe("linkRequestDisplay", () => { + it("names the environment linked and the access the user chose", () => { + expect( + linkRequestDisplay( + { ...asked, linkStatus: "linked", linkedLabel: "Box", linkedAccess: "auto" }, + "local", + accessLabel, + ), + ).toEqual({ kind: "answered", outcome: "linked", label: "Linked Box · " }); + }); + + it("shows why a link failed, and never offers the actions outside the asking thread", () => { + expect( + linkRequestDisplay( + { ...asked, linkStatus: "failed", failure: "Pairing code expired." }, + "local", + accessLabel, + ), + ).toMatchObject({ outcome: "failed", label: "Could not link: Pairing code expired." }); + expect( + linkRequestDisplay({ ...asked, linkStatus: "pending" }, "inherited", accessLabel), + ).toMatchObject({ kind: "pending-elsewhere" }); + }); +}); + +describe("linkRequestAnswerInput", () => { + const vps = { + environmentId: EnvironmentId.make("environment-vps"), + label: "VPS", + urls: ["https://vps.ts.net", "http://10.0.0.2:3773"], + }; + + it("answers with the machine picked and every address known for it", () => { + expect( + linkRequestAnswerInput(item, { + type: "link", + target: vps, + access: "auto", + pairingCode: " abc ", + }), + ).toEqual({ + threadId: item.threadId, + turnItemId: item.id, + answer: { type: "link", ...vps, access: "auto", pairingCode: "abc" }, + }); + expect( + linkRequestAnswerInput(item, { + type: "link", + target: { url: " https://box.ts.net " }, + access: "auto", + pairingCode: "abc", + }), + ).toMatchObject({ answer: { urls: ["https://box.ts.net"] } }); + }); + + it("refuses a blank code, a blank address, or a machine with no address", () => { + const blank = (target: Parameters[1] & { type: "link" }) => + linkRequestAnswerInput(item, target); + expect(blank({ type: "link", target: vps, access: "auto", pairingCode: " " })).toBeNull(); + expect( + blank({ type: "link", target: { url: " " }, access: "auto", pairingCode: "abc" }), + ).toBeNull(); + expect( + blank({ type: "link", target: { ...vps, urls: [] }, access: "auto", pairingCode: "abc" }), + ).toBeNull(); + }); +}); + +const presentation = ( + id: string, + label: string, + options: { + readonly saved?: ReadonlyArray; + readonly reported?: ReadonlyArray; + readonly connected?: boolean; + } = {}, +): EnvironmentPresentation & { readonly environmentId: EnvironmentId } => { + const environmentId = EnvironmentId.make(id); + const [first, ...rest] = (options.saved ?? []).map((httpBaseUrl, index) => { + const connectionId = `${id}-${index}`; + return { + target: new BearerConnectionTarget({ environmentId, label, connectionId }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId, + label, + httpBaseUrl, + wsBaseUrl: httpBaseUrl, + }), + ), + }; + }); + return { + environmentId, + entry: { + ...(first ?? { + target: new RelayConnectionTarget({ environmentId, label }), + profile: Option.none(), + }), + ...(rest.length === 0 ? {} : { alternateRoutes: rest }), + enabled: true, + }, + connection: { + phase: options.connected === false ? "offline" : "connected", + error: null, + traceId: null, + }, + serverConfig: + options.reported === undefined + ? null + : ({ + environment: { platform: { machine: "laptop" } }, + directEndpoints: options.reported.map((httpBaseUrl) => ({ + kind: "tailnet", + httpBaseUrl, + })), + } as unknown as ServerConfig), + }; +}; + +const discovered = ( + id: string, + label: string, + machine?: "mac-mini", +): RelayDiscoveredEnvironment => ({ + environment: { + environmentId: EnvironmentId.make(id), + label, + endpoint: { + httpBaseUrl: `https://${id}.t3.example`, + wsBaseUrl: `wss://${id}.t3.example`, + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-10-01T00:00:00.000Z", + }, + availability: "online", + status: + machine === undefined + ? Option.none() + : Option.some({ + environmentId: EnvironmentId.make(id), + endpoint: { + httpBaseUrl: `https://${id}.t3.example`, + wsBaseUrl: `wss://${id}.t3.example`, + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-10-01T00:00:00.000Z", + descriptor: { + environmentId: EnvironmentId.make(id), + label, + platform: { os: "darwin", arch: "arm64", machine }, + serverVersion: "1.0.0", + capabilities: {}, + }, + } as unknown as RelayEnvironmentStatusResponse), + error: Option.none(), +}); + +describe("linkableMachines", () => { + it("lists the other machines with reported addresses before saved ones, once each", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const vps = presentation("environment-vps", "VPS", { + saved: ["https://vps.example.com", "https://vps.ts.net"], + reported: ["https://vps.ts.net"], + }); + const mini = presentation("environment-mini", "Mini", { connected: false }); + expect( + linkableMachines({ + environments: [here, vps, mini], + threadEnvironmentId: here.environmentId, + links: [{ environmentId: mini.environmentId, status: "unreachable" }], + connect: new Map(), + }), + ).toEqual([ + { + environmentId: vps.environmentId, + label: "VPS", + machine: "laptop", + connected: true, + connectOnly: false, + linked: false, + urls: ["https://vps.ts.net", "https://vps.example.com"], + }, + { + environmentId: mini.environmentId, + label: "Mini", + machine: "server", + connected: false, + connectOnly: false, + linked: true, + urls: [], + }, + ]); + }); + + it("reaches a machine saved only over T3 Connect at its Connect address", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const box = presentation("environment-box", "Box"); + const [listed] = linkableMachines({ + environments: [here, box], + threadEnvironmentId: here.environmentId, + links: [], + connect: new Map([[box.environmentId, discovered(box.environmentId, "Box")]]), + }); + expect(listed?.urls).toEqual(["https://environment-box.t3.example"]); + // Until discovery has found it, there is no address to offer. + expect( + linkableMachines({ + environments: [here, box], + threadEnvironmentId: here.environmentId, + links: [], + connect: new Map(), + })[0]?.urls, + ).toEqual([]); + }); + + it("also offers the Connect address of a machine saved by direct address, last", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const vps = presentation("environment-vps", "VPS", { + saved: ["http://192.168.1.20:3773"], + reported: ["https://vps.ts.net"], + }); + const [listed] = linkableMachines({ + environments: [here, vps], + threadEnvironmentId: here.environmentId, + links: [], + connect: new Map([[vps.environmentId, discovered(vps.environmentId, "VPS")]]), + }); + expect(listed?.urls).toEqual([ + "https://vps.ts.net", + "http://192.168.1.20:3773", + "https://environment-vps.t3.example", + ]); + }); + + it("no longer counts a machine as linked once its link expired", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const vps = presentation("environment-vps", "VPS", { saved: ["https://vps.ts.net"] }); + const mini = presentation("environment-mini", "Mini", { saved: ["http://mini.local:3773"] }); + const listed = linkableMachines({ + environments: [here, vps, mini], + threadEnvironmentId: here.environmentId, + links: [ + { environmentId: vps.environmentId, status: "expired" }, + { environmentId: mini.environmentId, status: "unreachable" }, + { environmentId: EnvironmentId.make("environment-old"), status: "expired" }, + ], + connect: new Map([["environment-old", discovered("environment-old", "Old box")]]), + }); + expect(listed.map((machine) => [machine.label, machine.linked])).toEqual([ + ["VPS", false], + ["Mini", true], + ["Old box", false], + ]); + }); + + it("offers machines only on T3 Connect after the saved ones, at their Connect address", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const vps = presentation("environment-vps", "VPS", { saved: ["https://vps.ts.net"] }); + const listed = linkableMachines({ + environments: [here, vps], + threadEnvironmentId: here.environmentId, + links: [{ environmentId: EnvironmentId.make("environment-old"), status: "reachable" }], + connect: new Map([ + // This environment and saved machines are listed once, as saved. + [here.environmentId, discovered(here.environmentId, "Here")], + [vps.environmentId, discovered(vps.environmentId, "VPS")], + ["environment-mini", discovered("environment-mini", "Mac mini", "mac-mini")], + ["environment-old", discovered("environment-old", "Old box")], + ]), + }); + expect(listed.map((machine) => [machine.label, machine.connectOnly])).toEqual([ + ["VPS", false], + ["Mac mini", true], + ["Old box", true], + ]); + expect(listed[1]).toEqual({ + environmentId: EnvironmentId.make("environment-mini"), + label: "Mac mini", + machine: "mac-mini", + connected: false, + connectOnly: true, + linked: false, + urls: ["https://environment-mini.t3.example"], + }); + expect(listed[2]).toMatchObject({ machine: "server", linked: true }); + }); +}); + +describe("rankMachinesByHint", () => { + const machines = [ + { label: "Old vps box" }, + { label: "VPS-2" }, + { label: "Mac mini" }, + { label: "vps" }, + ]; + + it("puts an exact name first, then a prefix, then a substring, keeping order within each", () => { + expect(rankMachinesByHint(machines, " VPS ").map((machine) => machine.label)).toEqual([ + "vps", + "VPS-2", + "Old vps box", + "Mac mini", + ]); + expect(rankMachinesByHint(machines, undefined)).toBe(machines); + }); + + it("preselects the card's machine, or the hint's match, and nothing for a hint that matches none", () => { + const listed = [ + { environmentId: EnvironmentId.make("environment-mini"), label: "Mac mini" }, + { environmentId: EnvironmentId.make("environment-vps"), label: "Hetzner VPS" }, + ].map( + (machine) => + ({ + ...machine, + machine: "server", + connected: true, + connectOnly: false, + linked: false, + urls: [], + }) as const, + ); + expect(linkRequestInitialMachine({ hint: "vps" }, listed)).toBe("environment-vps"); + // Agents pass the user's words, which name the machine among others. + expect(linkRequestInitialMachine({ hint: "my mac mini" }, listed)).toBe("environment-mini"); + expect(linkRequestInitialMachine({ hint: "my mac" }, listed)).toBe("environment-mini"); + expect(linkRequestInitialMachine({ hint: "the hetzner vps box" }, listed)).toBe( + "environment-vps", + ); + expect(linkRequestInitialMachine({ hint: "desk" }, listed)).toBeNull(); + expect(linkRequestInitialMachine({ hint: "minimal" }, listed)).toBeNull(); + expect( + linkRequestInitialMachine( + { environmentId: EnvironmentId.make("environment-x"), hint: "vps" }, + listed, + ), + ).toBe("environment-x"); + }); +}); + +describe("linkRequestFailureMessage", () => { + it("passes through only known user-facing errors", () => { + expect( + linkRequestFailureMessage({ _tag: "PeerLinkRequestError", message: "Already answered." }), + ).toBe("Already answered."); + expect(linkRequestFailureMessage({ _tag: "SchemaError", message: "code=abc" })).toBe( + "Could not answer the request. Try again.", + ); + }); +}); diff --git a/packages/client-runtime/src/linkRequest.ts b/packages/client-runtime/src/linkRequest.ts new file mode 100644 index 0000000000000..8df10bda4726c --- /dev/null +++ b/packages/client-runtime/src/linkRequest.ts @@ -0,0 +1,284 @@ +import { + type AuthMcpClientAccess, + type EnvironmentId, + type EnvironmentMachineKind, + type OrchestrationV2TurnItem, + type PeerLinkRequestAnswerInput, + type PeerLinkSummary, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +import type { EnvironmentPresentation } from "./connection/presentation.ts"; +import { connectionRoutes, routeHttpBaseUrl } from "./connection/routes.ts"; +import type { RelayDiscoveredEnvironment } from "./relay/discovery.ts"; + +export type LinkRequestItem = Extract; + +/** The access a request opens with: the agent's suggestion, else supervised. */ +export const linkRequestDefaultAccess = ( + item: Pick, +): AuthMcpClientAccess => item.requestedAccess ?? "approval-required"; + +/** What the card calls the other environment: its own name, else the address asked for. */ +export const linkRequestTargetName = (item: Pick): string => + item.label?.trim() || item.url || "a machine"; + +/** A machine the user could link a thread's environment to. */ +export interface LinkableMachine { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly machine: EnvironmentMachineKind; + readonly connected: boolean; + /** Found on the user's T3 Connect account rather than saved in this client. */ + readonly connectOnly: boolean; + /** Whether the thread's environment already has a link to it. */ + readonly linked: boolean; + /** + * Where the client knows it answers, most preferred first: the addresses it + * reports, then its saved routes, then its T3 Connect address. The server + * keeps only those that answer as this machine. Empty for a machine reached + * only over SSH, whose forward lives in this client. + */ + readonly urls: ReadonlyArray; +} + +/** + * The catalog's machines other than the thread's own, in catalog order, then those only T3 + * Connect knows. `links` are the thread environment's; an expired one no longer counts as + * linked. `connect` is what relay discovery found, and a server reaches a machine there as a + * browser does, so its Connect address is usable. + */ +export function linkableMachines(input: { + readonly environments: ReadonlyArray< + EnvironmentPresentation & { readonly environmentId: EnvironmentId } + >; + readonly threadEnvironmentId: EnvironmentId; + readonly links: ReadonlyArray>; + readonly connect: ReadonlyMap; +}): ReadonlyArray { + const linkedIds = new Set( + input.links.flatMap((link) => (link.status === "expired" ? [] : [link.environmentId])), + ); + const saved = input.environments + .filter((environment) => environment.environmentId !== input.threadEnvironmentId) + .map((environment) => { + const reported = (environment.serverConfig?.directEndpoints ?? []).map( + (endpoint) => endpoint.httpBaseUrl, + ); + const routes = connectionRoutes(environment.entry).flatMap((route) => { + const url = route.target._tag === "RelayConnectionTarget" ? null : routeHttpBaseUrl(route); + return url === null ? [] : [url]; + }); + const connect = input.connect.get(environment.environmentId)?.environment.endpoint; + return { + environmentId: environment.environmentId, + label: environment.entry.target.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + connected: environment.connection.phase === "connected", + connectOnly: false, + linked: linkedIds.has(environment.environmentId), + urls: [ + ...new Set([ + ...reported, + ...routes, + ...(connect === undefined ? [] : [connect.httpBaseUrl]), + ]), + ], + }; + }); + const savedIds = new Set(saved.map((machine) => machine.environmentId)); + const connectOnly = [...input.connect.values()] + .filter( + ({ environment }) => + environment.environmentId !== input.threadEnvironmentId && + !savedIds.has(environment.environmentId), + ) + .map(({ environment, status }) => { + const descriptor = Option.getOrUndefined(status)?.descriptor; + return { + environmentId: environment.environmentId, + label: environment.label, + machine: resolveEnvironmentMachineKind( + descriptor === undefined ? null : { environment: descriptor }, + ), + connected: false, + connectOnly: true, + linked: linkedIds.has(environment.environmentId), + urls: [environment.endpoint.httpBaseUrl], + }; + }); + return [...saved, ...connectOnly]; +} + +const words = (text: string) => + ` ${text + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .join(" ") + .trim()} `; +const NO_MATCH = 4; + +/** + * How well a machine's name matches the agent's hint, which is often the + * user's own words ("my box"): exact, prefix, substring, then a hint that + * names it among other words ("the box on hetzner"). + */ +const hintRank = (label: string, hint: string) => { + const name = label.trim().toLowerCase(); + const wanted = hint + .trim() + .toLowerCase() + .replace(/^(?:my|our|the)\s+/, ""); + if (name === wanted) return 0; + if (name.startsWith(wanted)) return 1; + if (name.includes(wanted)) return 2; + if (words(wanted).includes(words(name))) return 3; + return NO_MATCH; +}; + +/** The machines best match for the agent's hint first, each rank in catalog order. */ +export function rankMachinesByHint( + machines: ReadonlyArray, + hint: string | undefined, +): ReadonlyArray { + if (hint === undefined || hint.trim().length === 0) return machines; + return machines + .map((machine, index) => ({ machine, index, rank: hintRank(machine.label, hint) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map(({ machine }) => machine); +} + +/** The machine a card opens on: the one it names, else the hint's best match. */ +export function linkRequestInitialMachine( + item: Pick, + machines: ReadonlyArray, +): EnvironmentId | null { + if (item.environmentId !== undefined) return item.environmentId; + if (item.hint === undefined) return null; + const [best] = rankMachinesByHint(machines, item.hint); + return best !== undefined && hintRank(best.label, item.hint) < NO_MATCH + ? best.environmentId + : null; +} + +/** What a link request card shows: the actions while pending, otherwise a one-line outcome. */ +export type LinkRequestDisplay = + | { readonly kind: "pending" } + | { readonly kind: "pending-elsewhere"; readonly label: string } + | { + readonly kind: "answered"; + readonly outcome: "linked" | "declined" | "ended" | "failed"; + readonly label: string; + }; + +/** + * `visibility` is the projected row's: a request inherited from another + * thread (a fork) can only be answered where it was asked. `accessLabel` + * names an access level the way the client's access picker does. + */ +export function linkRequestDisplay( + item: Pick< + LinkRequestItem, + "linkStatus" | "label" | "url" | "linkedLabel" | "linkedAccess" | "failure" + >, + visibility: "local" | "inherited" | "synthetic", + accessLabel: (access: AuthMcpClientAccess) => string, +): LinkRequestDisplay { + switch (item.linkStatus) { + case "pending": + return visibility === "local" + ? { kind: "pending" } + : { kind: "pending-elsewhere", label: "Waiting for an answer in the original thread" }; + case "linked": + return { + kind: "answered", + outcome: "linked", + label: `Linked ${item.linkedLabel ?? linkRequestTargetName(item)}${ + item.linkedAccess === undefined ? "" : ` · ${accessLabel(item.linkedAccess)}` + }`, + }; + case "declined": + return { kind: "answered", outcome: "declined", label: "Declined" }; + case "failed": + return { + kind: "answered", + outcome: "failed", + label: `Could not link: ${item.failure ?? "the other environment refused"}`, + }; + case "cancelled": + return { kind: "answered", outcome: "ended", label: "Request ended" }; + } +} + +/** + * Builds the RPC payload for an answer. A link with a blank code returns null, + * since the server rejects it; callers keep Link disabled instead. + */ +export function linkRequestAnswerInput( + item: Pick, + answer: + | { + readonly type: "link"; + /** The machine picked, or an address typed in its place. */ + readonly target: + | Pick + | { readonly url: string }; + readonly access: AuthMcpClientAccess; + readonly pairingCode: string; + } + | { readonly type: "use-existing"; readonly environmentId: EnvironmentId } + | { readonly type: "decline" }, +): PeerLinkRequestAnswerInput | null { + const base = { threadId: item.threadId, turnItemId: item.id }; + if (answer.type !== "link") return { ...base, answer }; + const pairingCode = answer.pairingCode.trim(); + if (pairingCode.length === 0) return null; + const { target } = answer; + if ("url" in target) { + const url = target.url.trim(); + if (url.length === 0) return null; + return { ...base, answer: { type: "link", urls: [url], access: answer.access, pairingCode } }; + } + const [first, ...rest] = target.urls; + if (first === undefined) return null; + return { + ...base, + answer: { + type: "link", + environmentId: target.environmentId, + label: target.label, + urls: [first, ...rest], + access: answer.access, + pairingCode, + }, + }; +} + +/** Failures whose message is written for the user and never echoes the request payload. */ +const USER_FACING_FAILURE_TAGS = new Set([ + "PeerLinkRequestError", + "EnvironmentAuthorizationError", + "PeerPairingCodeError", +]); + +/** + * Inline error copy for a failed answer. Only known errors pass their message + * through: anything else (transport or encoding failures) gets the generic + * copy, so the pairing code can never surface in the UI. + */ +export function linkRequestFailureMessage(failure: unknown): string { + if ( + typeof failure === "object" && + failure !== null && + "_tag" in failure && + typeof failure._tag === "string" && + USER_FACING_FAILURE_TAGS.has(failure._tag) && + "message" in failure && + typeof failure.message === "string" && + failure.message.trim().length > 0 + ) { + return failure.message; + } + return "Could not answer the request. Try again."; +} diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 86fa956365389..ed28cd13e3a00 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -423,6 +423,30 @@ it.effect( ), ); +it.effect("requires access management to answer a link request", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + registry.set(sessions(env), AsyncResult.success(grant(true))); + const answer = createCommandPermissions(runtime, WS_METHODS.peerLinksAnswerRequest); + expect(registry.get(answer.permissionAtom(env))).toBe(false); + expect((yield* answer.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe( + AuthAccessWriteScope, + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthAccessWriteScope], + permissions: [AuthAccessWriteScope], + }), + ); + expect(registry.get(answer.permissionAtom(env))).toBe(true); + yield* answer.authorize(registry, env); + }), + ), +); + it.effect("honors exact empty permissions and preserves legacy parent grants", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/peerPairingCode.ts b/packages/client-runtime/src/state/peerPairingCode.ts new file mode 100644 index 0000000000000..814eeb88d5446 --- /dev/null +++ b/packages/client-runtime/src/state/peerPairingCode.ts @@ -0,0 +1,95 @@ +/** + * A one-use pairing code for linking, minted on the environment being linked to with the + * user's own session there, so a link card can be answered without fetching a code by hand. + * It carries exactly what an agent sign-in with the chosen access needs. + */ +import { type AuthMcpClientAccess, mcpClientScopes } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/http/HttpClient"; +import * as Option from "effect/Option"; +import type { Atom } from "effect/reactivity"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; +import { createEnvironmentCommand } from "./runtime.ts"; + +const MINT_TIMEOUT_MS = 8_000; +const PEER_PAIRING_CODE_LABEL = "T3 Code link"; + +/** Minting failed; the user can still paste a code from the other environment. */ +export class PeerPairingCodeError extends Data.TaggedError("PeerPairingCodeError")<{ + readonly reason: "not_allowed" | "unavailable"; + readonly cause: unknown; +}> { + override get message(): string { + return this.reason === "not_allowed" + ? "Your session there cannot create pairing codes. Paste a code from there instead." + : "Could not get a pairing code from there. Paste a code from there instead."; + } +} + +const mintPeerPairingCode = Effect.fn("clientRuntime.state.mintPeerPairingCode")(function* ( + prepared: PreparedConnection, + access: AuthMcpClientAccess, +): Effect.fn.Return { + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); + const remoteAuthorization = yield* Effect.serviceOption( + RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, + ); + const minted = yield* executeAuthenticatedEnvironmentHttpRequest({ + prepared, + signer, + remoteAuthorization, + group: "auth", + method: "POST", + url: (urls) => urls.pairingCredential(), + timeoutMs: MINT_TIMEOUT_MS, + request: ({ client, headers }) => + client.pairingCredential({ + headers, + payload: { label: PEER_PAIRING_CODE_LABEL, scopes: mcpClientScopes(access) }, + }), + }).pipe( + Effect.mapError( + (cause) => + new PeerPairingCodeError({ + reason: + cause._tag === "EnvironmentScopeRequiredError" || + cause._tag === "EnvironmentOperationForbiddenError" + ? "not_allowed" + : "unavailable", + cause, + }), + ), + ); + return minted.credential; +}); + +/** + * Mints a code on the environment it runs in. Each run mints its own: a code + * is one-use, so two overlapping answers must never share one. + */ +export function createPeerPairingCodeCommand( + runtime: Atom.AtomRuntime, +) { + return createEnvironmentCommand(runtime, { + label: "environment-data:peer-pairing-code:mint", + execute: Effect.fn(function* (access: AuthMcpClientAccess) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new PeerPairingCodeError({ + reason: "unavailable", + cause: "The environment is not connected.", + }); + } + return yield* mintPeerPairingCode(prepared.value, access); + }), + }); +} diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f9f1f91407047..77db29b9e9403 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1525,6 +1525,16 @@ export function createServerEnvironmentAtoms( JSON.stringify([environmentId, input.threadId, input.turnItemId]), }, }), + // Like a secret card's answer: off the config lane, one per card at a time. + answerLinkRequest: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:peer-links:answer-request", + tag: WS_METHODS.peerLinksAnswerRequest, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.threadId, input.turnItemId]), + }, + }), refreshUsageRates: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-usage-rates", tag: WS_METHODS.serverRefreshUsageRates, diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 1aed057817cbe..1e9629d5803aa 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -339,6 +339,16 @@ export function summarizeT3ToolCalls( case "environment-links": label = phrase("Listed", "list", `linked environments ${times}`); break; + case "environment-link": + label = phrase("Asked to link", "ask to link", quantity(selected.length, "environment")); + break; + case "environment-unlink": + label = phrase( + "Forgot", + "forget", + quantity(countEntities(entityIds("environmentId")), "linked environment"), + ); + break; case "attachment-prepare": label = phrase( "Prepared", diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index fbdf2c3a34e6f..7e4cc84b74156 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -408,6 +408,19 @@ export const AuthMcpClientAccess = Schema.Literals([ ]); export type AuthMcpClientAccess = typeof AuthMcpClientAccess.Type; +/** The scopes an agent session gets for an access level, and a pairing code must carry. */ +export const mcpClientScopes = (access: AuthMcpClientAccess): ReadonlyArray => + access === "read-only" + ? [AuthOrchestrationReadScope] + : access === "full-access" + ? [ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + AuthSourceControlWriteScope, + ] + : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; + export const AuthMcpProtectedResourceMetadata = Schema.Struct({ resource: Schema.String, authorization_servers: Schema.Array(Schema.String), diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 5ff35ad13341e..364b3d191af37 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -54,6 +54,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.guestShareCreate]: AuthAccessWriteScope, [WS_METHODS.peerLinksLink]: AuthAccessWriteScope, [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope, + // A link lets every agent here reach the other environment: Settings' bar. + [WS_METHODS.peerLinksAnswerRequest]: AuthAccessWriteScope, } as const; export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 2ee6f3d3bd0c0..18d7c03cd77bf 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1506,6 +1506,42 @@ const OrchestrationV2SecretRequestFields = { secretStatus: OrchestrationV2SecretRequestStatus, } as const; +export const OrchestrationV2LinkRequestStatus = Schema.Literals([ + "pending", + "linked", + "declined", + "cancelled", + "failed", +]); +export type OrchestrationV2LinkRequestStatus = typeof OrchestrationV2LinkRequestStatus.Type; + +/** + * An agent asked the user to link another environment. The user picks the + * access; the pairing code never passes through orchestration, so the item + * carries only what was asked and how it ended. + */ +const OrchestrationV2LinkRequestFields = { + type: Schema.Literal("link_request"), + /** The address the agent named, if it named one. */ + url: Schema.optional(TrimmedNonEmptyString), + reason: Schema.String, + /** + * The environment the agent named, or the one answering at `url`. Without + * either the user picks the machine in the card. + */ + environmentId: Schema.optional(EnvironmentId), + label: Schema.optional(Schema.String), + /** A phrase from the user's words, such as "vps", that preselects a machine. */ + hint: Schema.optional(Schema.String), + /** The access the agent suggested; the user's choice is final. */ + requestedAccess: Schema.optional(AuthMcpClientAccess), + linkStatus: OrchestrationV2LinkRequestStatus, + linkedEnvironmentId: Schema.optional(EnvironmentId), + linkedLabel: Schema.optional(Schema.String), + linkedAccess: Schema.optional(AuthMcpClientAccess), + failure: Schema.optional(Schema.String), +} as const; + export const OrchestrationV2TurnItem = Schema.Union([ Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -1695,6 +1731,10 @@ export const OrchestrationV2TurnItem = Schema.Union([ ...OrchestrationV2TurnItemBaseFields, ...OrchestrationV2SecretRequestFields, }), + Schema.Struct({ + ...OrchestrationV2TurnItemBaseFields, + ...OrchestrationV2LinkRequestFields, + }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, type: Schema.Literal("subagent"), @@ -2530,6 +2570,10 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ ...OrchestrationV2TurnItemJsonBaseFields, ...OrchestrationV2SecretRequestFields, }), + Schema.Struct({ + ...OrchestrationV2TurnItemJsonBaseFields, + ...OrchestrationV2LinkRequestFields, + }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, type: Schema.Literal("subagent"), @@ -3434,6 +3478,37 @@ const OrchestrationV2InternalCommand = Schema.Union([ delivery: Schema.optional(OrchestrationV2SecretRequestDelivery), secretStatus: OrchestrationV2SecretRequestStatus, }), + /** + * Records or updates a link an agent asked the user to make. Internal so no + * client can mark a request linked without the link being made. + */ + Schema.Struct({ + type: Schema.Literal("link_request.record"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + nodeId: NodeId, + turnItemId: TurnItemId, + url: Schema.optional(TrimmedNonEmptyString), + reason: Schema.String, + environmentId: Schema.optional(EnvironmentId), + label: Schema.optional(Schema.String), + hint: Schema.optional(Schema.String), + requestedAccess: Schema.optional(AuthMcpClientAccess), + linkStatus: OrchestrationV2LinkRequestStatus, + linkedEnvironmentId: Schema.optional(EnvironmentId), + linkedLabel: Schema.optional(Schema.String), + linkedAccess: Schema.optional(AuthMcpClientAccess), + failure: Schema.optional(Schema.String), + /** Tells the agent how the card ended, in the same commit that closes it. */ + wake: Schema.optional( + Schema.Struct({ + messageId: MessageId, + text: Schema.String, + notification: OrchestrationV2Notification, + }), + ), + }), ]); export type OrchestrationV2InternalCommand = typeof OrchestrationV2InternalCommand.Type; diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 97911133f4db3..9b9100287f9c6 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -619,6 +619,8 @@ export type OrchestratorMcpEnvironmentLink = typeof OrchestratorMcpEnvironmentLi export const OrchestratorMcpEnvironmentLinksResult = Schema.Struct({ environmentId: EnvironmentId, links: Schema.Array(OrchestratorMcpEnvironmentLink), + /** What to do when the machine the user means is not among `links`. */ + notListed: Schema.String, }); export type OrchestratorMcpEnvironmentLinksResult = typeof OrchestratorMcpEnvironmentLinksResult.Type; @@ -764,6 +766,74 @@ export const OrchestratorMcpRequestSecretResult = Schema.Union([ ]); export type OrchestratorMcpRequestSecretResult = typeof OrchestratorMcpRequestSecretResult.Type; +export const OrchestratorMcpEnvironmentLinkInput = Schema.Struct({ + environmentId: Schema.optional(EnvironmentId).annotate({ + description: + "The machine, when known, e.g. from a mentioned machine. Otherwise the user picks it in the card.", + }), + url: Schema.optional(TrimmedNonEmptyString).annotate({ + description: + "Only an address the user gave: https, Tailscale, LAN or loopback. Never guess one.", + }), + hint: Schema.optional(TrimmedNonEmptyString).annotate({ + description: 'The user\'s words for the machine, e.g. "vps". Preselects it in the card.', + }), + reason: Schema.optional(TrimmedNonEmptyString).annotate({ + description: "One sentence for the card.", + }), + requestedAccess: Schema.optional(AuthMcpClientAccess).annotate({ + description: "Suggested access. The user picks; default approval-required.", + }), + clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), +}); +export type OrchestratorMcpEnvironmentLinkInput = typeof OrchestratorMcpEnvironmentLinkInput.Type; + +export const OrchestratorMcpEnvironmentLinkResult = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("pending").annotate({ + description: + "The card is waiting for the user. Their answer arrives as a message in this thread.", + }), + turnItemId: TurnItemId, + }), + Schema.Struct({ + status: Schema.Literal("linked"), + environmentId: EnvironmentId.annotate({ + description: "Pass it as environmentId to act there; t3_environment_links lists it.", + }), + label: Schema.String, + access: AuthMcpClientAccess.annotate({ description: "The access the user chose." }), + }), + Schema.Struct({ + status: Schema.Literals(["declined", "cancelled"]).annotate({ + description: + "declined: the user chose not to. cancelled: the card closed unanswered, when the thread was stopped or archived.", + }), + }), + Schema.Struct({ + status: Schema.Literal("failed"), + message: Schema.String.annotate({ + description: "Why the other environment refused the link, as the user saw it.", + }), + }), +]); +export type OrchestratorMcpEnvironmentLinkResult = typeof OrchestratorMcpEnvironmentLinkResult.Type; + +export const OrchestratorMcpEnvironmentUnlinkInput = Schema.Struct({ + environmentId: EnvironmentId.annotate({ + description: "The linked environment to forget, from t3_environment_links.", + }), +}); +export type OrchestratorMcpEnvironmentUnlinkInput = + typeof OrchestratorMcpEnvironmentUnlinkInput.Type; + +export const OrchestratorMcpEnvironmentUnlinkResult = Schema.Struct({ + environmentId: EnvironmentId, + removed: Schema.Boolean, +}); +export type OrchestratorMcpEnvironmentUnlinkResult = + typeof OrchestratorMcpEnvironmentUnlinkResult.Type; + export const OrchestratorMcpDeleteScheduledTaskInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, scheduledTaskId: ScheduledTaskId, diff --git a/packages/contracts/src/peerLink.ts b/packages/contracts/src/peerLink.ts index 022b1b9b9f8c5..7882e8d2c74c2 100644 --- a/packages/contracts/src/peerLink.ts +++ b/packages/contracts/src/peerLink.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { AuthMcpClientAccess } from "./auth.ts"; -import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts"; /** * A link from this environment to another one (the peer). This environment @@ -40,6 +40,13 @@ export const PeerLinkCreateInput = Schema.Struct({ /** A pairing code from the peer (`t3 pair` there, or its Connections settings). */ pairingCode: TrimmedNonEmptyString, access: AuthMcpClientAccess, + /** + * The environment the user means. Addresses that answer as another one are + * skipped, and only those that answer as this one are kept. + */ + expectedEnvironmentId: Schema.optionalKey(EnvironmentId), + /** What the user calls that environment, to name it when it cannot be reached. */ + expectedLabel: Schema.optionalKey(TrimmedNonEmptyString), }); export type PeerLinkCreateInput = typeof PeerLinkCreateInput.Type; @@ -64,3 +71,60 @@ export class PeerLinkError extends Schema.TaggedError()("PeerLink message: Schema.String, cause: Schema.optionalKey(Schema.Defect()), }) {} + +/** + * The user's answer to an agent asking to link an environment. The pairing + * code is used once to link and is never recorded; the thread learns only the + * outcome and the access the user chose. + */ +export const PeerLinkRequestAnswerInput = Schema.Struct({ + threadId: ThreadId, + turnItemId: TurnItemId, + answer: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("link"), + /** The machine the user picked, when they picked one rather than typing an address. */ + environmentId: Schema.optionalKey(EnvironmentId), + /** What the client calls that machine. */ + label: Schema.optionalKey(TrimmedNonEmptyString), + /** The addresses the client knows for that machine, most preferred first. */ + urls: Schema.NonEmptyArray(TrimmedNonEmptyString), + access: AuthMcpClientAccess, + pairingCode: TrimmedNonEmptyString, + }), + /** Answers with a machine this environment is already linked to, as it is. */ + Schema.Struct({ type: Schema.Literal("use-existing"), environmentId: EnvironmentId }), + Schema.Struct({ type: Schema.Literal("decline") }), + ]), +}); +export type PeerLinkRequestAnswerInput = typeof PeerLinkRequestAnswerInput.Type; + +const PEER_LINK_REQUEST_FAILURE_MESSAGES = { + load_failed: "Could not load the link request.", + not_found: "This link request no longer exists.", + already_answered: "This link request was already answered.", + record_failed: "Could not update the link request.", +} as const; + +export const PeerLinkRequestFailureReason = Schema.Literals( + Object.keys(PEER_LINK_REQUEST_FAILURE_MESSAGES) as Array< + keyof typeof PEER_LINK_REQUEST_FAILURE_MESSAGES + >, +); +export type PeerLinkRequestFailureReason = typeof PeerLinkRequestFailureReason.Type; + +/** + * Answering a link request failed before anything was linked. A link the + * other environment refused is not this error: the card records it as failed. + */ +export class PeerLinkRequestError extends Schema.TaggedError()( + "PeerLinkRequestError", + { + reason: PeerLinkRequestFailureReason, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return PEER_LINK_REQUEST_FAILURE_MESSAGES[this.reason]; + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e0a6486668e10..9c32c6de25bd5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -22,6 +22,8 @@ import { PeerLinkError, PeerLinkRemoveInput, PeerLinkRemoveResult, + PeerLinkRequestAnswerInput, + PeerLinkRequestError, PeerLinkSummary, } from "./peerLink.ts"; import { @@ -497,6 +499,7 @@ export const WS_METHODS = { peerLinksList: "peerLinks.list", peerLinksLink: "peerLinks.link", peerLinksUnlink: "peerLinks.unlink", + peerLinksAnswerRequest: "peerLinks.answerRequest", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -797,6 +800,11 @@ const WsPeerLinksUnlinkRpc = Rpc.make(WS_METHODS.peerLinksUnlink, { error: Schema.Union([PeerLinkError, EnvironmentAuthorizationError]), }); +const WsPeerLinksAnswerRequestRpc = Rpc.make(WS_METHODS.peerLinksAnswerRequest, { + payload: PeerLinkRequestAnswerInput, + error: Schema.Union([PeerLinkRequestError, EnvironmentAuthorizationError]), +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1977,6 +1985,7 @@ export const WsRpcGroup = RpcGroup.make( WsPeerLinksListRpc, WsPeerLinksLinkRpc, WsPeerLinksUnlinkRpc, + WsPeerLinksAnswerRequestRpc, WsServerDiscoverSourceControlRpc, WsServerSearchAcpRegistryRpc, WsServerPrepareAcpRegistryAgentRpc, diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 7790bed28f9b3..5ce74766c4018 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -14,6 +14,7 @@ Ordinary coding tasks need no T3 tools. `- \`t3_thread_launch\` and \`create_threads\` make new top-level threads, only when the user asks for a new thread. "Subagent", "delegate", and "parallel" never mean one. Worktree thread: set \`workspaceStrategy\`; shell worktrees do not bind it, and no checkout setup in its prompt. No \`workspaceStrategy\`: project root, not your worktree. - Given a thread id, \`t3_thread_read\` it before you answer about it. - Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` to a T3 tool to act there. Ids are then that machine's, so pass \`threadId\`. +- A machine the user names that is not linked ("delegate this to my vps"): call \`t3_environment_link\` with a \`hint\` from their words. Never ask for a pairing code in chat. - In your reply, a thread's first mention is its \`link\` verbatim. No \`link\`: \`[title](t3-thread://v1/)\`. Never a plain title or bare id. A thread on a linked machine: its title and machine, not its link. - A secret: call \`request_secret\` yourself. Pass its \`secretRef\` to a T3 tool, or use \`delivery: "file"\` for a shell command. Never ask in chat or invent one. When asked only to schedule, \`schedule_task\` without running its prompt. - T3 tools may load late. Try the name once before you call it absent.`, diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 4c30698db8cef..5848861f47ac4 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -52,6 +52,8 @@ export type T3McpToolSummaryAction = | "project-clone" | "environment-read" | "environment-links" + | "environment-link" + | "environment-unlink" | "environment-update" | "attachment-prepare" | "attachment-discard" @@ -326,6 +328,14 @@ const T3_MCP_TOOLS: Readonly> = { ["List", "Listing", "Listed", "linked environments"], "environment-links", ), + t3_environment_link: tool( + ["Ask to link", "Asking to link", "Asked to link", "an environment"], + "environment-link", + ), + t3_environment_unlink: tool( + ["Forget", "Forgetting", "Forgot", "a linked environment"], + "environment-unlink", + ), t3_environment_preferences_update: tool( ["Update", "Updating", "Updated", "environment preferences"], "environment-update", From 5e9d56416f6b395095c772c597e79cd5f94e3052 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:04:27 -0700 Subject: [PATCH 9/9] feat(server,web,mobile): @-mention a machine, and linked environments become a Labs beta (#17130) Co-authored-by: Claude Opus 5.5 (1M context) --- .../assets/context-chip-icons/server.png | Bin 0 -> 506 bytes .../scripts/sync-context-chip-icons.mjs | 1 + .../src/components/ComposerContextSheet.tsx | 3 + .../connection/LinkedEnvironmentsSection.tsx | 3 +- ...SettingsServerControlsRouteScreen.test.tsx | 1 + .../settings/ThreadSettingsSections.tsx | 45 ++++++ .../src/features/settings/settings-search.ts | 2 +- .../threads/ComposerCommandPopover.tsx | 13 ++ .../src/features/threads/ThreadComposer.tsx | 8 + .../threads/use-composer-command-menu.ts | 45 +++++- .../src/lib/composerChipPresentation.test.ts | 7 + .../src/lib/composerChipPresentation.ts | 2 + apps/mobile/src/lib/composerContext.ts | 16 ++ .../src/lib/contextChipIcons.generated.ts | 1 + apps/mobile/src/state/environments.ts | 17 ++- apps/server/src/auth/McpOAuth.test.ts | 8 +- apps/server/src/auth/McpOAuth.ts | 13 ++ apps/server/src/cli/environment.ts | 6 + apps/server/src/mcp/McpHttpServer.test.ts | 140 +++++++++++++++++- apps/server/src/mcp/McpHttpServer.ts | 22 ++- apps/server/src/mcp/McpInvocationContext.ts | 9 ++ apps/server/src/mcp/McpToolAccess.ts | 1 + .../src/mcp/toolkits/orchestrator/tools.ts | 11 +- apps/server/src/peer/PeerForwarding.ts | 23 ++- apps/server/src/peer/PeerLinkRequests.ts | 6 + apps/server/src/peer/PeerLinks.test.ts | 39 +++++ apps/server/src/peer/PeerLinks.testkit.ts | 5 + apps/server/src/peer/PeerLinks.ts | 15 ++ apps/server/src/peer/PeerMcpClient.ts | 9 ++ apps/server/src/peer/RemoteDelegation.test.ts | 47 ++++++ .../web/src/components/ChatView.logic.test.ts | 1 + apps/web/src/components/ChatView.logic.ts | 6 + apps/web/src/components/ContextChip.tsx | 1 + .../src/components/EnvironmentContextChip.tsx | 40 +++++ apps/web/src/components/chat/ChatComposer.tsx | 59 ++++++++ .../components/chat/ComposerCommandMenu.tsx | 18 +++ .../chat/UserMessageContextChips.tsx | 11 ++ apps/web/src/components/chat/chatViewSend.ts | 1 + .../components/chat/composerContextUndo.ts | 8 +- .../chat/useComposerContextImport.ts | 15 +- .../components/chat/useComposerMenuItems.ts | 27 +++- .../src/components/chat/useComposerSend.ts | 22 ++- .../chat/useQueuedRunEdit.browser.test.tsx | 1 + .../src/components/chat/useQueuedRunEdit.ts | 3 + .../composerContextPresentation.tsx | 34 ++++- .../components/contextPresentationRegistry.ts | 4 + .../components/settings/LabsSettingsPanel.tsx | 33 +++++ .../src/components/settings/settingsSearch.ts | 7 + apps/web/src/composerDraftStore.test.ts | 64 +++++++- apps/web/src/composerDraftStore.ts | 106 ++++++++++++- apps/web/src/lib/composerContextRecords.ts | 23 +++ apps/web/src/lib/contextChipAccents.ts | 1 + apps/web/src/state/peerLinks.ts | 13 +- docs/internals/remote.md | 7 + docs/user/remote-access.md | 11 +- evals/mcp-tool-use/run.ts | 25 ++-- .../src/composerThreadItems.test.ts | 49 +++++- .../client-runtime/src/composerThreadItems.ts | 50 ++++++- packages/client-runtime/src/linkRequest.ts | 7 + .../contracts/src/composerContext.test.ts | 6 + packages/contracts/src/composerContext.ts | 14 ++ packages/contracts/src/peerLink.ts | 1 + packages/contracts/src/settings.ts | 6 + .../src/composerContextReferences.test.ts | 21 +++ .../shared/src/composerContextReferences.ts | 6 + 65 files changed, 1174 insertions(+), 45 deletions(-) create mode 100644 apps/mobile/assets/context-chip-icons/server.png create mode 100644 apps/web/src/components/EnvironmentContextChip.tsx diff --git a/apps/mobile/assets/context-chip-icons/server.png b/apps/mobile/assets/context-chip-icons/server.png new file mode 100644 index 0000000000000000000000000000000000000000..92bc2ce457f16e0f12f68b26b08079186954b21f GIT binary patch literal 506 zcmV;6@c2mMuLR z-9u5?JY*#jQm~U$6;kc|CuKL0JU`=cY=1K$8UY5t6_5k>kvwj!8F0Ky;%C4TSQ9Uw zzydhkHu)#un{?O!Mc3q?Ntb33fMH@^-d733CIObD&s$TsUwdA72X24^iv%}TU;?ZH zjd9?6DGMleZIM!m;A89y&I7@R7Ae3{AgA0HJoQCd=@JH~ll=1*Eb>?kHVjI%M<#y6mJ_YIW*mVvSiB*Ej<)KsZ zwdt@3G{#tPW1qK{eG{%@0++zu9$a~KecV{bJ6(+lA%sKz0OSuy0r>+e~^RH^=dqLjV8(07*qoM6N<$g2Z*xD*ylh literal 0 HcmV?d00001 diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs index aac4983d75e2c..d99d4d3c98478 100644 --- a/apps/mobile/scripts/sync-context-chip-icons.mjs +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -43,6 +43,7 @@ const lucideIcons = [ "message-circle", "messages-square", "mouse-pointer-click", + "server", "terminal", ]; diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 20c7485ef15d6..8121bfdece763 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -324,6 +324,9 @@ export function ComposerContextSheet(props: { ) : null} ) : null} + {record.kind === "environment" ? ( + + ) : null} {record.kind === "skill" ? ( diff --git a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx index 66dd7dfd8f485..9153c9fa6a1c8 100644 --- a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx +++ b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx @@ -1,3 +1,4 @@ +import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request"; import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks"; import { useFocusEffect } from "@react-navigation/native"; import { AuthAccessReadScope, type EnvironmentId } from "@t3tools/contracts"; @@ -18,7 +19,7 @@ export function LinkedEnvironmentsSection(props: { }) { const config = useEnvironmentServerConfig(props.environmentId); const canRead = useEnvironmentScope(props.environmentId, AuthAccessReadScope); - const supported = canRead && config?.environment.capabilities.peerLinks === true; + const supported = canRead && offersLinkedEnvironments(config); const links = useEnvironmentQuery( supported ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) : null, ); diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 179eceba6c6fd..ef62306accbdc 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -57,6 +57,7 @@ vi.mock("../../state/session", () => ({ ), })); vi.mock("../../state/server", () => ({ serverEnvironment: { updateSettings: {} } })); +vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [] }) })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.updateSettings, })); diff --git a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx index f982820a71610..5149ffa54c6fc 100644 --- a/apps/mobile/src/features/settings/ThreadSettingsSections.tsx +++ b/apps/mobile/src/features/settings/ThreadSettingsSections.tsx @@ -11,7 +11,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/ui/AppText"; +import { useEnvironments } from "../../state/environments"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { SettingsScreen } from "./components/SettingsScreen"; @@ -31,6 +34,7 @@ export function SettingsLabsRouteScreen() { contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + @@ -214,6 +218,47 @@ export function NewThreadSettingsSection() { ); } +/** Per machine, since each machine's own setting decides whether it links and answers links. */ +function BetaSettingsSection() { + const { environments } = useEnvironments(); + const supported = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.peerLinks === true, + ); + const writable = useEnvironmentsWithScope(supported, AuthSettingsWriteScope); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "linked environments beta update", + reportFailure: true, + }); + if (supported.length === 0) return null; + return ( + + + {supported.map((environment) => ( + + void updateSettings({ + environmentId: environment.environmentId, + input: { patch: { linkedEnvironmentsEnabled: value } }, + }) + } + /> + ))} + + + Let agents on a machine work on your other machines, and theirs on it. Turn it on for each + machine. While it is off, nothing new starts over a link; tasks already running finish. + + + ); +} + function LegacySettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const preferences = useAtomValue(mobilePreferencesAtom); diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 84bc68ec712bc..c11a89a7c3a10 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -83,7 +83,7 @@ const SETTINGS_SEARCH_TERMS = { "submodules", "start from remote", ], - SettingsLabs: ["legacy", "plan mode", "beta"], + SettingsLabs: ["legacy", "plan mode", "beta", "linked environments", "link machine"], SettingsEnvironmentMaintenance: ["updates", "provider updates", "restart", "manage environments"], SettingsAbout: [ "version", diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index cd924e4c61f03..cbe89f5fb8768 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -3,6 +3,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import type { + EnvironmentId, + EnvironmentMachineKind, PullRequestContextMetadata, ScopedThreadRef, ServerProviderSkill, @@ -14,6 +16,7 @@ import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-n import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; +import { ENVIRONMENT_MACHINE_SYMBOLS } from "../../components/EnvironmentMachineSymbol"; import { GlassSurface } from "../../components/ui/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; @@ -40,6 +43,14 @@ export type ComposerCommandItem = readonly label: string; readonly description: string; } + | { + readonly id: string; + readonly type: "environment"; + readonly environmentId: EnvironmentId; + readonly machine: EnvironmentMachineKind; + readonly label: string; + readonly description: string; + } | { readonly id: string; readonly type: "slash-command"; @@ -110,6 +121,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null { return null; case "thread": return "text.bubble"; + case "environment": + return ENVIRONMENT_MACHINE_SYMBOLS[item.machine]; } } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7ac1ad83aa8b5..1e55ebc3d5884 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -66,6 +66,7 @@ import { import { appAtomRegistry } from "../../state/atom-registry"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject, useThreadShells } from "../../state/entities"; +import { useMentionableEnvironments } from "../../state/environments"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { AppText as Text } from "../../components/ui/AppText"; @@ -99,6 +100,8 @@ import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor"; import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit"; import { useComposerCommandMenu } from "./use-composer-command-menu"; +import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems"; +import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request"; import { ComposerDictationCancelAction, ComposerDictationDraftContent, @@ -117,6 +120,7 @@ import { export const COMPOSER_COLLAPSED_CHROME = 60; +const NO_MACHINES: ReadonlyArray = []; const COLLAPSED_EDITOR_DEFAULT_INSET = 6; // iOS puts line-height slack above glyphs. DM Sans's lowercase midline is // descender (0.31em) + half x-height (0.504em) above the line bottom. @@ -523,11 +527,15 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps return report !== null; }; + const mentionableEnvironments = useMentionableEnvironments(); const composerMenu = useComposerCommandMenu({ draftMessage: draftMessage, ownerKey: composerDraftKey, environmentId: props.environmentId, threadShells: useThreadShells(), + environments: offersLinkedEnvironments(props.serverConfig) + ? mentionableEnvironments + : NO_MACHINES, currentThreadId: props.selectedThread.id, projectCwd: props.projectCwd, pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index af4091a5f023d..89afc891015ac 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -7,13 +7,22 @@ import type { ThreadId, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; +import { + type ComposerEnvironmentCandidate, + matchComposerEnvironmentItems, + matchComposerThreadItems, +} from "@t3tools/client-runtime/composerThreadItems"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; const EMPTY_THREAD_SHELLS: ReadonlyArray = []; +const EMPTY_ENVIRONMENTS: ReadonlyArray = []; import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; -import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; +import { + environmentComposerContext, + pullRequestComposerContext, + threadComposerContext, +} from "../../lib/composerContext"; import { uuidv4 } from "../../lib/uuid"; import { getComposerDraftSnapshot, @@ -201,6 +210,7 @@ export function useComposerCommandMenu({ ownerKey, environmentId, threadShells = EMPTY_THREAD_SHELLS, + environments = EMPTY_ENVIRONMENTS, currentThreadId = null, projectCwd, pullRequestProjectId = null, @@ -219,6 +229,7 @@ export function useComposerCommandMenu({ readonly ownerKey: string | null; readonly environmentId: EnvironmentId | null; readonly threadShells?: ReadonlyArray; + readonly environments?: ReadonlyArray; readonly currentThreadId?: ThreadId | null; readonly projectCwd: string | null; readonly pullRequestProjectId?: ProjectId | null; @@ -452,8 +463,12 @@ export function useComposerCommandMenu({ now, }) : []; + const environmentItems = environmentId + ? matchComposerEnvironmentItems({ environments, environmentId, query: trigger.query }) + : []; return [ ...threadItems, + ...environmentItems, ...pathSearch.entries.map((entry) => { const parts = entry.path.split("/"); return { @@ -473,6 +488,32 @@ export function useComposerCommandMenu({ const onSelect = (item: ComposerCommandItem) => { if (!trigger) return; + if (item.type === "environment") { + if (!ownerKey || trigger.kind !== "path") return; + const record = environmentComposerContext(item.environmentId, item.label); + const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? []; + const alreadyAttached = existing.some((entry) => entry.contextId === record.contextId); + if (!alreadyAttached && existing.length >= COMPOSER_CONTEXT_MAX_RECORDS) { + Dialog.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + const result = replaceTextRange( + draftMessage, + trigger.rangeStart, + trigger.rangeEnd, + `${formatComposerContextReference(record)} `, + ); + onChangeDraftMessage(result.text); + if (!alreadyAttached) { + const draft = getComposerDraftSnapshot(ownerKey); + setComposerDraftContext(ownerKey, { + version: 1, + records: [...(draft.context?.records ?? []), record], + }); + } + setSelection({ start: result.cursor, end: result.cursor }); + return; + } if (item.type === "thread") { if (!ownerKey || trigger.kind !== "path") return; const shell = threadShells.find( diff --git a/apps/mobile/src/lib/composerChipPresentation.test.ts b/apps/mobile/src/lib/composerChipPresentation.test.ts index fa578904dcb54..21474f6de7e96 100644 --- a/apps/mobile/src/lib/composerChipPresentation.test.ts +++ b/apps/mobile/src/lib/composerChipPresentation.test.ts @@ -22,6 +22,7 @@ const CHIP_ACCENTS = [ "pull-request", "skill", "thread", + "environment", ].map((kind) => contextChipPresentation(kind)), ...[ { state: "open", isDraft: false }, @@ -107,3 +108,9 @@ describe("contextChipAccent", () => { expect(contextChipAccent("#009f6e", theme, theme["--color-md-body"])).toBe("#009f6e"); }); }); + +describe("contextChipPresentation", () => { + it("draws a mentioned machine as a machine, not the generic file chip", () => { + expect(contextChipPresentation("environment")).toEqual({ accent: "#3f9a4f", icon: "server" }); + }); +}); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 34e70e94197bd..4f3a77daf5d90 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -43,6 +43,8 @@ const CONTEXT_CHIP_PRESENTATIONS = { accent: "#009c96", icon: "messages-square", }, + // A machine: the record carries only its identity, so every machine wears one glyph. + environment: { accent: "#3f9a4f", icon: "server" }, } as const satisfies Record; const CHIP_CONTRAST = 5; diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index f6c3a0e5c5536..34b4e3fcdcdf5 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -14,6 +14,8 @@ import { type ScopedThreadRef, type ServerProviderSkill, type ThreadContextRecord, + type EnvironmentContextRecord, + type EnvironmentId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { @@ -115,6 +117,20 @@ export function threadComposerContext(ref: ScopedThreadRef, title: string): Thre }; } +/** Same identity as web: one record per machine, so mentioning it again reuses the chip. */ +export function environmentComposerContext( + environmentId: EnvironmentId, + label: string, +): EnvironmentContextRecord { + return { + version: 1, + kind: "environment", + contextId: ComposerContextId.make(`environment_${environmentId}`), + label: sanitizeComposerContextLabel(label, "environment"), + environmentId, + }; +} + export function pullRequestComposerContext( pullRequest: PullRequestContextMetadata, id: string, diff --git a/apps/mobile/src/lib/contextChipIcons.generated.ts b/apps/mobile/src/lib/contextChipIcons.generated.ts index 10a8b6af70adb..809f76dca49c3 100644 --- a/apps/mobile/src/lib/contextChipIcons.generated.ts +++ b/apps/mobile/src/lib/contextChipIcons.generated.ts @@ -11,6 +11,7 @@ export const CONTEXT_CHIP_ICON_SOURCES = { "message-circle": require("../../assets/context-chip-icons/message-circle.png"), "messages-square": require("../../assets/context-chip-icons/messages-square.png"), "mouse-pointer-click": require("../../assets/context-chip-icons/mouse-pointer-click.png"), + server: require("../../assets/context-chip-icons/server.png"), skill: require("../../assets/context-chip-icons/skill.png"), terminal: require("../../assets/context-chip-icons/terminal.png"), } as const satisfies Readonly>; diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 776a615f46afb..7bfd41e1ca883 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -4,7 +4,8 @@ import { hasRelayRoute, type EnvironmentPresentation as BaseEnvironmentPresentation, } from "@t3tools/client-runtime/connection"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems"; +import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; @@ -55,3 +56,17 @@ export function useEnvironments() { export function useEnvironmentConnectionState(environmentId: EnvironmentId) { return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); } + +/** The user's machines as `@` mention candidates, drawn with their machine kind. */ +export function useMentionableEnvironments(): ReadonlyArray { + const { environments } = useEnvironments(); + return useMemo( + () => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + })), + [environments], + ); +} diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 5c73cc03fff9b..3dc35aca981b9 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -21,6 +21,7 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as Sqlite from "../persistence/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as McpOAuth from "./McpOAuth.ts"; @@ -46,7 +47,12 @@ const makeLayerRoutes = (capture: (auth: EnvironmentAuth.EnvironmentAuth["Servic ), HttpApiBuilder.layer(AuthTestApi).pipe( Layer.provide(AuthHttp.layer), - Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), + Layer.provide( + McpOAuthHttp.layer.pipe( + Layer.provide(McpOAuth.layer), + Layer.provide(ServerSettings.layerTest()), + ), + ), Layer.provide(AuthHttp.layerAuthenticatedAuth), ), ).pipe( diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 3be31570b4d44..8f664f5bc7cba 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -28,6 +28,7 @@ import * as Schema from "effect/Schema"; import { HttpServerRequest } from "effect/http"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -239,6 +240,11 @@ const make = Effect.gen(function* () { const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const linksEnabled = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.linkedEnvironmentsEnabled), + Effect.orElseSucceed(() => false), + ); const signingKey = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.orDie); @@ -317,6 +323,13 @@ const make = Effect.gen(function* () { } // RFC 7591 allows ignoring unsupported registration options; all clients use public PKCE. const peerLink = metadata.software_id === MCP_PEER_LINK_SOFTWARE_ID; + if (peerLink && !(yield* linksEnabled)) { + return yield* new AuthMcpRegistrationError({ + error: "invalid_client_metadata", + error_description: + "Linked environments is off on that machine. Turn it on in its Settings → Labs.", + }); + } return { clientId: signClientId(name, redirectUris, peerLink), name, diff --git a/apps/server/src/cli/environment.ts b/apps/server/src/cli/environment.ts index d831bc44e828e..c8d6a2e0112ee 100644 --- a/apps/server/src/cli/environment.ts +++ b/apps/server/src/cli/environment.ts @@ -15,6 +15,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as PeerLinks from "../peer/PeerLinks.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { authLocationFlags, type CliAuthLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -41,6 +42,11 @@ const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* issuedAt: 1, }; const layerServer = McpHttpServer.layerMcpTransport.pipe( + Layer.provide(ServerSettings.layerTest()), Layer.provide( Layer.mock(McpSessionRegistry.McpSessionRegistry)({ resolve: (presented) => @@ -782,7 +786,9 @@ it.effect("admits provider and OAuth client credentials and points only clients ), }), ).pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provideMerge( + McpHttpServer.layerMcpTransport.pipe(Layer.provide(ServerSettings.layerTest())), + ), Layer.provide( Layer.mock(McpSessionRegistry.McpSessionRegistry)({ resolve: (token) => @@ -857,6 +863,130 @@ it.effect("admits provider and OAuth client credentials and points only clients ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("hides tools for links and refuses another environment while the beta is off", () => + Effect.scoped( + Effect.gen(function* () { + const token = "linksToken"; + const scope: McpInvocationContext.McpThreadInvocationScope = { + environmentId, + requestNamespace: token, + thread: { + threadId: ThreadId.make("thread-links"), + providerSessionId: token, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const LinksToolkit = Toolkit.make( + Tool.make("links", { + description: "Lists links.", + success: Schema.Struct({ links: Schema.Number }), + failure: OrchestratorMcpFailure, + failureMode: "return", + }).annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff), + Tool.make("where", { + description: "Says where it ran.", + parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget }), + success: Schema.Struct({ ranIn: Schema.String }), + failure: OrchestratorMcpFailure, + failureMode: "return", + }), + ); + const settingsContext = yield* Layer.build(ServerSettings.layerTest()); + const settings = Context.get(settingsContext, ServerSettings.ServerSettingsService); + const serverLayer = McpHttpServer.toolkitRegistration( + LinksToolkit, + McpToolAccess.toLayer(LinksToolkit, { + links: McpToolAccess.reads(() => Effect.succeed({ links: 0 })), + where: McpToolAccess.reads(() => Effect.succeed({ ranIn: "here" })), + }), + ).pipe( + Layer.provideMerge( + McpHttpServer.layerMcpTransport.pipe( + Layer.provide(Layer.succeedContext(settingsContext)), + ), + ), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (presented) => Effect.succeed(presented === token ? scope : undefined), + }), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + const rpc = (method: string, params: object) => + httpClient + .post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + JSON.stringify({ jsonrpc: "2.0", id: 2, method, params }), + "application/json", + ), + }) + .pipe(Effect.flatMap((response) => response.json)); + const listed = () => + rpc("tools/list", {}).pipe( + Effect.map((body) => + (body as { result: { tools: ReadonlyArray<{ name: string }> } }).result.tools.map( + (tool) => tool.name, + ), + ), + ); + const ranIn = (where: EnvironmentId) => + rpc("tools/call", { name: "where", arguments: { environmentId: where } }).pipe( + Effect.map( + (body) => + ( + body as { + result: { + isError?: boolean; + structuredContent?: unknown; + content: ReadonlyArray<{ text: string }>; + }; + } + ).result, + ), + ); + const elsewhere = EnvironmentId.make("environment-elsewhere"); + + expect(yield* listed()).toEqual(["where"]); + expect(yield* ranIn(elsewhere)).toMatchObject({ + isError: true, + content: [{ text: expect.stringContaining("Linked environments is off on this machine") }], + }); + expect((yield* ranIn(environmentId)).structuredContent).toEqual({ ranIn: "here" }); + + yield* settings.updateSettings({ linkedEnvironmentsEnabled: true }); + expect(yield* listed()).toEqual(["links", "where"]); + // On, the call goes on to the forwarding, which this server lacks. + expect(yield* ranIn(elsewhere)).toMatchObject({ + isError: true, + content: [{ text: expect.stringContaining("cannot reach linked environments") }], + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("lists pull request watch tools only to threads that are not delegated children", () => Effect.scoped( Effect.gen(function* () { @@ -881,7 +1011,9 @@ it.effect("lists pull request watch tools only to threads that are not delegated ["childToken", scopeFor("childToken", true)], ]); const serverLayer = McpHttpServer.layerPullRequestsToolkit.pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provideMerge( + McpHttpServer.layerMcpTransport.pipe(Layer.provide(ServerSettings.layerTest())), + ), Layer.provide( Layer.mock(McpSessionRegistry.McpSessionRegistry)({ resolve: (token) => Effect.succeed(scopes.get(token)), @@ -986,7 +1118,9 @@ it.effect( ), }), ).pipe( - Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provideMerge( + McpHttpServer.layerMcpTransport.pipe(Layer.provide(ServerSettings.layerTest())), + ), Layer.provide(McpToolAccessTestkit.liveThreadsLayer), Layer.provide( Layer.mock(McpSessionRegistry.McpSessionRegistry)({ diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 4d3805809df3d..1173b4cdcade7 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -24,6 +24,7 @@ import { import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as DeviceService from "../device/DeviceService.ts"; import * as HtmlRender from "../htmlRender/HtmlRender.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; @@ -181,9 +182,23 @@ const withModeLimit = ( : { ...invocation, client: { ...invocation.client, narrowedTo: limit.value } }; }; +// The Linked environments beta is off here: links wait until it is on again. +const linkedEnvironmentsOff = HttpServerResponse.jsonUnsafe( + { + error: "linked_environments_off", + message: "Linked environments is off on this machine. Turn it on in its Settings → Labs.", + }, + { status: 403, headers: { "cache-control": "no-store" } }, +); + const makeMcpAuthMiddleware = Effect.gen(function* () { const registry = yield* McpSessionRegistry.McpSessionRegistry; const clients = yield* Effect.serviceOption(McpClientAuthenticator); + const serverSettings = yield* ServerSettings.ServerSettingsService; + const linksEnabled = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.linkedEnvironmentsEnabled), + Effect.orElseSucceed(() => false), + ); return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { const request = yield* HttpServerRequest.HttpServerRequest; const authorization = request.headers.authorization; @@ -208,8 +223,13 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { } const limited = withModeLimit(invocation, request); if (limited === undefined) return invalidModeLimit; + const linkedEnvironments = yield* linksEnabled; + if (limited.client?.linked === true && !linkedEnvironments) return linkedEnvironmentsOff; return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, limited), + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...limited, + linkedEnvironments, + }), Effect.map(normalizeMcpHttpResponse), ); }) satisfies McpAuthMiddleware; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 8e0f55f36b9e9..26795c51696bd 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -101,6 +101,8 @@ export interface McpInvocationScope { readonly requestNamespace: string; readonly thread: McpThreadCaller | undefined; readonly client: McpClientCaller | undefined; + /** False while the Linked environments beta is off here; `/mcp` reads it per request. */ + readonly linkedEnvironments?: boolean; } export class McpInvocationContext extends Context.Service< @@ -118,6 +120,13 @@ export const hiddenFromSubagents = (): boolean => { return scope?.thread?.subagent !== true; }; +/** `McpSchema.EnabledWhen` for tools that only serve links, hidden while the beta is off. */ +export const hiddenWhileLinksOff = (): boolean => { + const fiber = Fiber.getCurrent(); + const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext); + return scope?.linkedEnvironments !== false; +}; + export type McpCapabilityError = C extends "preview" ? PreviewAutomationUnavailableError : McpCapabilityUnavailableError; diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index a36b58a2b2d38..b82d9bb5ae59c 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -399,6 +399,7 @@ const routed = Effect.fn("McpToolAccess.routed")(function* )(params); if (Option.isNone(forwarding)) return yield* linksUnavailable; return yield* forwarding.value.call(scope, tool, target, { ...params, environmentId }); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 859004d9ce66d..10cf6e2106efc 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -38,7 +38,7 @@ import { ThreadMetadataMcpUpdateResult, TrimmedNonEmptyString, } from "@t3tools/contracts"; -import { Tool, Toolkit } from "effect/ai"; +import { McpSchema, Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; @@ -316,7 +316,8 @@ const EnvironmentLinksTool = Tool.make("t3_environment_links", { .annotate(Tool.Title, "List linked environments") .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false) - .annotate(Tool.Idempotent, true); + .annotate(Tool.Idempotent, true) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff); const EnvironmentLinkTool = Tool.make("t3_environment_link", { description: @@ -329,7 +330,8 @@ const EnvironmentLinkTool = Tool.make("t3_environment_link", { }) .annotate(Tool.Title, "Link another environment") .annotate(Tool.Destructive, false) - .annotate(Tool.OpenWorld, true); + .annotate(Tool.OpenWorld, true) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff); const EnvironmentUnlinkTool = Tool.make("t3_environment_unlink", { description: @@ -342,7 +344,8 @@ const EnvironmentUnlinkTool = Tool.make("t3_environment_unlink", { }) .annotate(Tool.Title, "Forget a linked environment") .annotate(Tool.Destructive, true) - .annotate(Tool.Idempotent, true); + .annotate(Tool.Idempotent, true) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff); export const OrchestratorToolkit = Toolkit.make( OrchestratorCapabilitiesTool, diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index ddcf3d0fa06a5..b8391e285c18f 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -80,6 +80,13 @@ export class PeerForwarding extends Context.Service< } >()("t3/peer/PeerForwarding") {} +/** What an agent hears when it names another machine while the beta is off here. */ +export const linkedEnvironmentsOff = new OrchestratorMcpFailure({ + code: "capability_denied", + message: + "Linked environments is off on this machine, so no tool can act on another machine. Leave out environmentId to work here; the user can turn it on in Settings → Labs.", +}); + /** The linked environment a call names, or nothing when it names this one. */ export const remoteTarget = (scope: Scope, environmentId: EnvironmentId | undefined) => environmentId === scope.environmentId ? undefined : environmentId; @@ -204,14 +211,25 @@ const make = Effect.gen(function* () { } }); - const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => - tool.name === "t3_thread_wait" + // While the beta is off, agents start nothing over links. Tasks already running there keep + // following through readThread, waitForThread and interruptThread, so they still finish. + const requireEnabled = links.enabled.pipe( + Effect.flatMap((enabled) => (enabled ? Effect.void : Effect.fail(linkedEnvironmentsOff))), + ); + + const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => { + const forwarded: Effect.Effect< + Tool.Success, + OrchestratorMcpFailure + > = tool.name === "t3_thread_wait" ? (waitForThread( scope, environmentId, params as OrchestratorMcpThreadWaitInput, ) as Effect.Effect, OrchestratorMcpFailure>) : forward(scope, tool.name, tool.parametersSchema, tool.successSchema, environmentId, params); + return Effect.andThen(requireEnabled, forwarded); + }; /** * The next step for an agent whose machine is not linked: without it, an @@ -223,6 +241,7 @@ const make = Effect.gen(function* () { const listLinks: PeerForwarding["Service"]["links"] = Effect.fn("PeerForwarding.listLinks")( function* (scope) { yield* callerLimits(scope); + yield* requireEnabled; const listed = yield* links.list.pipe( Effect.mapError( (error) => diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index 12a96831d3dec..cdae666138355 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -143,6 +143,12 @@ const make = Effect.gen(function* () { "t3_environment_link", ); const threadId = threadScope.thread.threadId; + if (!(yield* peerLinks.enabled)) { + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: PeerLinks.LINKED_ENVIRONMENTS_OFF, + }); + } const unreadable = (error: unknown) => new OrchestratorMcpFailure({ code: "orchestration_error", diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 2d6a0c267e341..f16a91f26c0ad 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -24,6 +24,7 @@ import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as McpToolAccess from "../mcp/McpToolAccess.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { descriptorOf, layerLinkingEnvironment, @@ -84,6 +85,7 @@ const makeA = ( links: Context.get(context, PeerLinks.PeerLinks), peer: Context.get(context, PeerMcpClient.PeerMcpClient), sql: Context.get(context, SqlClient.SqlClient), + settings: Context.get(context, ServerSettings.ServerSettingsService), })), ); @@ -155,6 +157,43 @@ it.effect("links with a pairing code, and the peer holds its agents to the limit ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect( + "each machine's Linked environments beta gates links, and a link waits while it is off", + () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + const link = a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + + yield* a.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + expect(yield* link.pipe(Effect.flip)).toMatchObject({ + reason: "disabled", + message: PeerLinks.LINKED_ENVIRONMENTS_OFF, + }); + expect(yield* Ref.get(b.bearers)).toEqual([]); + + yield* a.settings.updateSettings({ linkedEnvironmentsEnabled: true }); + yield* b.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + const refusedThere = yield* link.pipe(Effect.flip); + expect(refusedThere.reason).toBe("pairing_rejected"); + expect(refusedThere.message).toContain("Linked environments is off on that machine"); + expect(yield* linkedSessions(b)).toEqual([]); + + yield* b.settings.updateSettings({ linkedEnvironmentsEnabled: true }); + yield* link; + yield* b.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + // Not final, so a task already running there picks up again once it is back on. + const waiting = yield* probe(a).pipe(Effect.flip); + expect(waiting.code).toBe("orchestration_error"); + expect(waiting.message).toContain("has Linked environments turned off"); + yield* b.settings.updateSettings({ linkedEnvironmentsEnabled: true }); + expect(yield* probe(a)).toEqual({ caller: "T3 Code · Laptop", modes: "auto/default" }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("a link whose row cannot be stored keeps none of its token", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index 6c41af9e3634a..0f7e39355b95d 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -28,6 +28,7 @@ import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as Sqlite from "../persistence/Sqlite.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as PeerMcpClient from "./PeerMcpClient.ts"; class PeerTestApi extends HttpApi.make("environment") @@ -70,6 +71,7 @@ export const servePeer = ( getDescriptor: Ref.get(descriptor), }); const authContext = yield* EnvironmentAuth.layer.pipe( + Layer.provideMerge(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })), Layer.provide(Sqlite.layerMemory), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(ServerEnvironment.layerIdentity), @@ -127,6 +129,8 @@ export const servePeer = ( return { url, auth, + /** Its settings, as for turning its Linked environments beta off. */ + settings: Context.get(authContext, ServerSettings.ServerSettingsService), descriptor, bearers, /** How often its descriptor was asked for, as a probe of whether it answers. */ @@ -154,6 +158,7 @@ export const layerLinkingEnvironment = ( ) => PeerMcpClient.layer.pipe( Layer.provideMerge(PeerLinks.layer), + Layer.provideMerge(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })), Layer.provideMerge(Sqlite.layerMemory), Layer.provide(Layer.mergeAll(secrets, FetchHttpClient.layer)), // Services that act for this environment, such as delegation, name it too. diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 04b59b677e96f..f47ea7d006a36 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -31,6 +31,7 @@ import { isLocalLoopbackHost, isTailnetHost } from "@t3tools/shared/hostClassifi import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerSettings from "../serverSettings.ts"; /** The redirect registered for a link. Nothing ever listens there: the code comes back in the response. */ const LINK_REDIRECT_URI = "http://127.0.0.1/t3code-peer-link"; @@ -49,6 +50,9 @@ export interface ResolvedPeerLink { readonly token: string; } +export const LINKED_ENVIRONMENTS_OFF = + "Linked environments is off on this machine. Turn it on in Settings → Labs."; + /** * Links from this environment to others. Linking signs this environment in to * the peer's `/mcp` as an outside agent with a pairing code from the peer, the @@ -58,6 +62,8 @@ export interface ResolvedPeerLink { export class PeerLinks extends Context.Service< PeerLinks, { + /** Whether the Linked environments beta (Settings → Labs) is on here. */ + readonly enabled: Effect.Effect; readonly link: (input: PeerLinkCreateInput) => Effect.Effect; /** * The environment answering at `url`, or nothing when it is not a usable @@ -159,6 +165,11 @@ const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const httpClient = yield* HttpClient.HttpClient; const environment = yield* ServerEnvironment.ServerEnvironment; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const enabled = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.linkedEnvironmentsEnabled), + Effect.orElseSucceed(() => false), + ); /** A fresh name for one link's token; the row records it. */ const newSecretName = crypto.randomBytes(16).pipe( @@ -388,6 +399,9 @@ const make = Effect.gen(function* () { }); const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) { + if (!(yield* enabled)) { + return yield* new PeerLinkError({ reason: "disabled", message: LINKED_ENVIRONMENTS_OFF }); + } const urls = [input.url, ...(input.alternateUrls ?? [])]; // The user picked a machine: link through the addresses that prove to // be it, so a reused LAN address never receives the pairing code. @@ -591,6 +605,7 @@ const make = Effect.gen(function* () { ); return PeerLinks.of({ + enabled, link, describe: describeUrl, list, diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index e85e39aebfd68..b953d458f8e27 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -76,6 +76,13 @@ const fromLinkError = (error: PeerLinkError) => message: error.message, }); +/** Not final, so a task already running there resumes once the beta is on there again. */ +const linkedEnvironmentsOffThere = new OrchestratorMcpFailure({ + code: "orchestration_error", + message: + "The linked environment has Linked environments turned off. Turn it on in its Settings → Labs.", +}); + /** The body of an MCP response: JSON, or a single server-sent event carrying JSON. */ const responseJson = (contentType: string | undefined, text: string) => contentType?.includes("text/event-stream") === true @@ -126,6 +133,7 @@ const make = Effect.gen(function* () { limitHeader, ); const sessionId = initialized.headers["mcp-session-id"]; + if (initialized.status === 403) return yield* linkedEnvironmentsOffThere; if (initialized.status !== 200 || sessionId === undefined) { return yield* new OrchestratorMcpFailure({ code: initialized.status === 401 ? "capability_denied" : "orchestration_error", @@ -182,6 +190,7 @@ const make = Effect.gen(function* () { "The linked environment no longer accepts this link. It may have been revoked there; link it again.", }); } + if (response.status === 403) return yield* linkedEnvironmentsOffThere; if (response.status !== 200) { return yield* new OrchestratorMcpFailure({ code: "orchestration_error", diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 5d3661ba1fce0..73c547546d1d6 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -603,6 +603,7 @@ const makeLaptop = ( const server = Context.get(here, McpServer.McpServer); return { links: Context.get(linking, PeerLinks.PeerLinks), + settings: Context.get(linking, ServerSettings.ServerSettingsService), orchestrator: Context.get(here, Orchestrator.OrchestratorV2), remote: Context.get(here, RemoteDelegation.RemoteDelegation), threads: Context.get(here, ThreadManagement.ThreadManagementService), @@ -843,6 +844,52 @@ it.effect("a task delegated to the box wakes the laptop's parent when it ends th ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect( + "with the beta off here, a task already running there still ends, and no new one starts", + () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); + + yield* a.settings.updateSettings({ linkedEnvironmentsEnabled: false }); + const refused = yield* a.call("delegate_task", { + task: "Run the linter on the box.", + target: { + environmentId: box.environmentId, + providerInstanceId: instanceId, + model: "gpt-5.4", + }, + mode: "async", + clientRequestId: "lint-on-box", + }); + expect(refused.isError).toBe(true); + expect(JSON.stringify(refused.content)).toContain( + PeerForwarding.linkedEnvironmentsOff.message, + ); + expect((yield* a.orchestrator.getThreadProjection(parentThreadId)).subagents).toHaveLength( + 1, + ); + + yield* Ref.set(thread.reply, "All 412 tests pass on the box."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "All 412 tests pass on the box.", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("a task named only by environment inherits the parent's model, within the link", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 363e5db0b6071..9ec467e81885a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1014,6 +1014,7 @@ describe("snapshotComposerSend", () => { previewAnnotations: [], reviewComments: [], threadContexts: [], + environmentContexts: [], }); expect(snapshot.terminalContexts).toEqual([live]); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0e36a3ef4681e..24fe3c614516c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -39,6 +39,7 @@ import { type ScopedProjectRef, type ScopedThreadRef, type ThreadContextRecord, + type EnvironmentContextRecord, type ThreadId, type ThreadLinkedPullRequest, type RunId, @@ -1195,6 +1196,7 @@ export interface PlanFollowUpComposerSnapshot { readonly reviewComments: ReadonlyArray; readonly previewAnnotations: ReadonlyArray; readonly threadContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } export function restorePlanFollowUpComposer(input: { @@ -1204,6 +1206,7 @@ export function restorePlanFollowUpComposer(input: { readonly writeReviewComments: (comments: ReadonlyArray) => void; readonly writePreviewAnnotations: (annotations: ReadonlyArray) => void; readonly writeThreadContexts: (records: ReadonlyArray) => void; + readonly writeEnvironmentContexts: (records: ReadonlyArray) => void; readonly resetCursor: (options: { cursor: number; prompt: string; @@ -1215,6 +1218,7 @@ export function restorePlanFollowUpComposer(input: { input.writeReviewComments(input.snapshot.reviewComments); input.writePreviewAnnotations(input.snapshot.previewAnnotations); input.writeThreadContexts(input.snapshot.threadContexts); + input.writeEnvironmentContexts(input.snapshot.environmentContexts); input.resetCursor({ cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), prompt: input.snapshot.prompt, @@ -1374,6 +1378,7 @@ export interface ComposerSendContents { readonly previewAnnotations: ReadonlyArray; readonly reviewComments: ReadonlyArray; readonly threadContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } /** @@ -1393,6 +1398,7 @@ export function snapshotComposerSend(contents: ComposerSendContents) { previewAnnotations: [...contents.previewAnnotations], reviewComments: [...contents.reviewComments], threadContexts: [...contents.threadContexts], + environmentContexts: [...contents.environmentContexts], messageText: contents.terminalContexts .filter((context) => !terminalContexts.includes(context)) .reduce( diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index b6abe578770c8..fcd4c9e4fed30 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -27,6 +27,7 @@ const contextChipVariants = cva( "pr-closed": "[--context-chip-accent:var(--context-chip-pr-closed)]", skill: "[--context-chip-accent:var(--context-chip-skill)]", thread: "[--context-chip-accent:var(--context-chip-thread)]", + environment: "[--context-chip-accent:var(--context-chip-environment)]", citation: "[--context-chip-accent:var(--context-chip-citation)]", "doc-pdf": "[--context-chip-accent:var(--context-chip-doc-pdf)]", "doc-word": "[--context-chip-accent:var(--context-chip-doc-word)]", diff --git a/apps/web/src/components/EnvironmentContextChip.tsx b/apps/web/src/components/EnvironmentContextChip.tsx new file mode 100644 index 0000000000000..9730fc7c342c9 --- /dev/null +++ b/apps/web/src/components/EnvironmentContextChip.tsx @@ -0,0 +1,40 @@ +import { type EnvironmentContextRecord, resolveEnvironmentMachineKind } from "@t3tools/contracts"; + +import { useEnvironment } from "~/state/environments"; +import { ContextChip, ContextChipLabel } from "./ContextChip"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Inline chip for a mentioned machine, in the composer and in sent messages. Prefers the + * name this client knows the machine by, and draws it with its machine icon. + */ +export function EnvironmentContextChip(props: { + record: Pick; + copyMarkdown?: string; +}) { + const environment = useEnvironment(props.record.environmentId); + const label = environment?.label.trim() || props.record.label; + return ( + + + + {label} + + } + /> + + {environment ? "T3 Code environment" : "Machine not in this app"} + + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 26761d92d8fe4..1d17f03e41b67 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -22,6 +22,7 @@ import type { PreviewAnnotationPayload, ProviderApprovalDecision, ThreadContextRecord, + EnvironmentContextRecord, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, @@ -169,6 +170,8 @@ import { terminalContextRecord, threadContextRecord, threadContextReference, + environmentContextRecord, + environmentContextReference, } from "~/lib/composerContextRecords"; import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; @@ -336,6 +339,7 @@ export interface ChatComposerHandle { previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; threadContexts: ThreadContextRecord[]; + environmentContexts: EnvironmentContextRecord[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -721,6 +725,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; const composerThreadContexts = attachmentDraft.threadContexts; + const composerEnvironmentContexts = composerDraft.environmentContexts; const { pendingSnapShotAnimations, pendingSnapShotIds, @@ -772,6 +777,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) reviewComments: composerReviewComments, previewAnnotations: composerPreviewAnnotations, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, images: composerImages, files: composerFiles, uploadsByImageId, @@ -1153,6 +1159,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSkills, selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, + questionAttachmentTarget, }); const composerMenuOpen = Boolean(composerTrigger); @@ -1304,6 +1311,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerDraftThreadContexts = useComposerDraftStore((store) => store.addThreadContexts); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); + const addComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.addEnvironmentContexts, + ); + const setComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.setEnvironmentContexts, + ); const buildContextClipboardFragment = (contextIds: ReadonlyArray): string | null => { const wanted = new Set(contextIds); const copiedFiles = new Map(); @@ -1320,6 +1333,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) .filter((c) => wanted.has(terminalContextReference(c).contextId)) .map(terminalContextRecord), ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), + ...composerEnvironmentContexts.filter((record) => wanted.has(record.contextId)), ...composerReviewComments .filter((c) => wanted.has(reviewCommentContextId(c.id))) .map(reviewCommentContextRecord), @@ -1693,6 +1707,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerDraftThreadContexts(composerDraftTarget, nextThreads); } + const liveEnvironmentIds = new Set( + composerEnvironmentContexts.map((record) => record.contextId), + ); + const restoredEnvironments = [...referenced].flatMap((contextId) => { + if (liveEnvironmentIds.has(contextId)) return []; + const record = retained.environments.get(contextId); + return record ? [record] : []; + }); + const nextEnvironments = [ + ...composerEnvironmentContexts.filter((record) => referenced.has(record.contextId)), + ...restoredEnvironments, + ]; + for (const record of composerEnvironmentContexts) { + if (!referenced.has(record.contextId)) retained.environments.set(record.contextId, record); + } + if ( + nextEnvironments.length !== composerEnvironmentContexts.length || + restoredEnvironments.length > 0 + ) { + setComposerDraftEnvironmentContexts(composerDraftTarget, nextEnvironments); + } + for (const comment of composerReviewComments) { const contextId = reviewCommentContextId(comment.id); if (!referenced.has(contextId)) { @@ -1991,6 +2027,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.type === "environment") { + if (trigger.kind !== "path") return; + const record = environmentContextRecord(item.environmentId, item.label); + const replacement = `${formatInlineContextReference(environmentContextReference(record))} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + addComposerDraftEnvironmentContexts(composerDraftTarget, [record], { + appendReference: false, + }); + setComposerHighlightedItemId(null); + } + return; + } }; const onSelectComposerItemStable = useStableCallback(onSelectComposerItem); @@ -3134,6 +3190,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -3189,6 +3246,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerPreviewAnnotations, composerReviewComments, + composerThreadContexts, + composerEnvironmentContexts, focusComposer, environmentId, primaryEnvironmentId, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 4d556d48be701..d2f224b3f5bec 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -5,6 +5,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import { + type EnvironmentId, + type EnvironmentMachineKind, type ProjectEntry, type ProviderDriverKind, type PullRequestContextMetadata, @@ -27,6 +29,7 @@ import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../compo import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { ComposerBanner } from "./ComposerBanner"; import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; @@ -77,6 +80,14 @@ export type ComposerCommandItem = thread: ScopedThreadRef; label: string; description: string; + } + | { + id: string; + type: "environment"; + environmentId: EnvironmentId; + machine: EnvironmentMachineKind; + label: string; + description: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -202,6 +213,13 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "thread" ? (