diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 06983eaa71fd..d7e17b2652ed 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,16 +1,24 @@ -import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; +import { defineConfig, type CodeRabbitConfig, type CodeRabbitContext } from "@coderabbitai/config"; + +// The SDK requires unexported enums here; these literals match the service's configuration schema. +type KnowledgeBase = NonNullable; +const localScope = "local" as NonNullable["scope"]; +const disabledIntegration = "disabled" as NonNullable["usage"]; const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); export default defineConfig((ctx) => ({ + inheritance: false, reviews: { high_level_summary: false, review_status: false, request_changes_workflow: false, + related_issues: false, + related_prs: false, allow_author_approval: !isGated(ctx), auto_review: { - enabled: true, + enabled: false, }, pre_merge_checks: { docstrings: { mode: "off" }, @@ -29,6 +37,15 @@ export default defineConfig((ctx) => ({ ], }, knowledge_base: { + automatic_linking_mode: "disabled", + linked_repositories: [], + issues: { scope: localScope }, + pull_requests: { scope: localScope }, + learnings: { scope: localScope }, + jira: { usage: disabledIntegration }, + linear: { usage: disabledIntegration }, + mcp: { usage: disabledIntegration }, + web_search: { enabled: false }, code_guidelines: { filePatterns: [ { files: "docs/internals/effect-services.md", applyTo: "{apps,packages,infra}/**/*.ts" }, diff --git a/README.md b/README.md index 8aa78f81b9ad..d0b1df95252c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> Community native messaging preview for T3 Code v0.1.2: [overview](docs/community-patches/native-messaging/README.md), [setup](docs/community-patches/native-messaging/INSTALL.md), and [source-patch download](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1). Independent fork; the upstream project README follows. + # T3 Code T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 18919cea55ec..30ccb6fd5210 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, @@ -83,6 +84,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 2f7c24536caa..b26b4fd9f0db 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 000000000000..d831bc44e828 --- /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 fbb5970560bc..00fa09008513 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -266,6 +266,8 @@ export const make = Effect.gen(function* () { : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), serverBrowser: true, + peerLinks: true, + mcpModeLimitHeader: true, }, }; diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 85ccc3ab926e..497a28f7c666 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"; @@ -809,3 +812,145 @@ it.effect("admits provider and OAuth client credentials and points only clients }), ).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)({ + 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 5c6c4fece44b..c760f831b2fa 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"; @@ -153,6 +155,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); @@ -178,8 +209,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; @@ -1030,7 +1063,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/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index f830da3d4a03..06b22f1d726d 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"; @@ -30,14 +31,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; } /** - * A read-only client never reaches a write (`McpToolAccess` refuses it first), - * so it maps to the lowest mode rather than to nothing. + * `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 clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => - client === undefined || client.access === "read-only" ? "approval-required" : client.access; +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, + }; +}; /** * A thread caller's omitted target falls back to its own thread; a client diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 7bd0d66fa040..f840b36c31df 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"; @@ -340,3 +344,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 5cc8dc0c38b8..891a51d86c90 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 { @@ -41,8 +46,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 { @@ -62,13 +78,20 @@ let handlersLayer: , EX, RX>( */ 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; } } @@ -101,60 +124,73 @@ 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.flatMap(() => writingCaller), - Effect.flatMap(() => handle(params)), - ), + declare( + (params: P) => + requireThreadCaller.pipe( + Effect.flatMap(() => 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, + ); /** * Unlike `writes` it does not need thread control; the handler checks its * capability, as the capability toolkits do. */ 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); + } } - } - // The target's user can raise its modes after the check above; the - // orchestrator checks again under the thread's lock and records its - // refusal here, since handlers wrap dispatch errors their own way. - 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), + // The target's user can raise its modes after the check above; the + // orchestrator checks again under the thread's lock and records its + // refusal here, since handlers wrap dispatch errors their own way. + 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) => @@ -177,21 +213,23 @@ 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, ); +}; const fullAccessRequired = "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; @@ -209,7 +247,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; @@ -246,14 +287,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.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index edaa8777b422..9deb8aac2162 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -104,7 +104,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, @@ -1158,10 +1158,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); @@ -2896,6 +2893,54 @@ 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)); + yield* threadManagement + .stopDelegatedTasks({ threadId: input.threadId, commandId, reason: input.reason }) + .pipe(Effect.catch(stopFailed)); + // Queue holds and completion disposal also update runs. Only the + // original interrupt request identifies the run Stop reached. + const request = stopped.storedEvents.find( + (stored) => + stored.event.type === "turn-item.updated" && + stored.event.payload.type === "run_interrupt_request" && + (stored.event.payload.providerTurnId !== null || + stopped.storedEvents.some( + (updated) => + updated.event.type === "run.updated" && + updated.event.runId === stored.event.runId && + updated.event.payload.status === "interrupted", + )), + ); + const interrupted = request?.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 09b4bd4f424d..88e590f9bc3b 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/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index ba8dc4c3e576..9cc010a02cb8 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 diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 02216cec576d..c5031b77423c 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -51,6 +51,7 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.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)({})), @@ -71,6 +72,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"); @@ -542,6 +544,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)({})), ), @@ -597,6 +600,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)({})), ), ), ), @@ -679,6 +683,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( 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)({})), ), @@ -771,6 +776,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/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 0df604a85011..d996026b9bf4 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"; @@ -140,6 +141,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.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 567cb9272732..0a15244b0bb1 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -2,6 +2,7 @@ import { OrchestratorMcpCapabilitiesInput, OrchestratorMcpCapabilitiesResult, OrchestrationV2StopWorkflowInput, + OrchestratorMcpEnvironmentLinksResult, OrchestratorMcpCreateThreadsInput, OrchestratorMcpCreateThreadsResult, OrchestratorMcpDelegateTaskInput, @@ -36,6 +37,7 @@ import { Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; 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 +47,8 @@ const dependencies = [ ThreadManagementService.ThreadManagementService, OrchestratorMcpService.OrchestratorMcpService, ]; +/** Listing this environment's links. */ +const linkDependencies = [...dependencies, PeerForwarding.PeerForwarding]; const threadMetadataDependencies = [ McpInvocationContext.McpInvocationContext, ThreadManagementService.ThreadManagementService, @@ -53,7 +57,7 @@ const threadMetadataDependencies = [ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { description: - "List the V2 provider instances, a short list of their models from the same live catalog as the composer, inherited runtime settings, and app-owned orchestration features available to this caller. The list has the user's favorite and custom models, your own model, every current Codex, Claude, Muse and Grok model, and the first 8 current models of other providers. Other legacy models are left out. omittedModels counts models left out; pass search, such as 'sonnet 4.6', to find them. delegate_task accepts any catalog model, listed or not. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy.", + "List the V2 provider instances, a short list of their models from the same live catalog as the composer, inherited runtime settings, and app-owned orchestration features available to this caller. The list has the user's favorite and custom models, your own model, every current Codex, Claude, Muse and Grok model, and the first 8 current models of other providers. Other legacy models are left out. omittedModels counts models left out; pass search, such as 'sonnet 4.6', to find them. delegate_task accepts any catalog model, listed or not. For a separate top-level thread in a new or existing worktree, use t3_thread_launch with workspaceStrategy. Pass environmentId to list a linked environment's providers instead.", parameters: OrchestratorMcpCapabilitiesInput, success: OrchestratorMcpCapabilitiesResult, failure: OrchestratorMcpFailure, @@ -194,7 +198,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Pass environmentId to list a linked environment's threads; projectId is then required. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title. Name a thread in a linked environment by title instead: its link would open here.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -208,7 +212,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "Read durable state and a paginated timeline from any T3 thread in this environment, or in a linked one with environmentId. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title. Name a thread in a linked environment by title instead: its link would open here.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, @@ -236,7 +240,7 @@ const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to any T3 thread in this environment. The target cannot have broader permission modes than the caller. Use delegate_task with the full review context and a new clientRequestId for independent review rounds. For a follow-up that needs the same child's conversation, send to its childThreadId. After the original task completes, a requested follow-up creates a fresh task in that child thread and returns taskId when it starts a turn or restarts a turn without its own task; its completion notifies the parent, and the original result stays unchanged. A steer that joins a running turn returns that turn's task, if it has one. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to any T3 thread in this environment, or in a linked one with environmentId. The target cannot have broader permission modes than the caller. Use delegate_task with the full review context and a new clientRequestId for independent review rounds. For a follow-up that needs the same child's conversation, send to its childThreadId. After the original task completes, a requested follow-up creates a fresh task in that child thread and returns taskId when it starts a turn or restarts a turn without its own task; its completion notifies the parent, and the original result stays unchanged. A steer that joins a running turn returns that turn's task, if it has one. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, @@ -249,7 +253,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for a T3 thread run to reach a terminal durable state. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.", + "Wait for a T3 thread run to reach a terminal durable state, here or in a linked environment with environmentId. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, @@ -263,7 +267,7 @@ const ThreadWaitTool = Tool.make("t3_thread_wait", { const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { description: - "Request interruption of a running turn in any T3 thread in this environment. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. clientRequestId makes retries idempotent.", + "Request interruption of a running turn in any T3 thread in this environment, or in a linked one with environmentId. Without runId, the newest interruptible run is selected. Terminal runs and threads without an active turn return without another side effect. stop=true stops the thread as its Stop button does instead: it also holds queued messages, ends pull request watches and stops the tasks it delegated. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadInterruptInput, success: OrchestratorMcpThreadInterruptResult, failure: OrchestratorMcpFailure, @@ -273,6 +277,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_thread_wait, t3_thread_read or t3_project_list, to act there. 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, @@ -291,4 +308,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 4c22393bf476..874095d600e1 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -22,10 +22,12 @@ 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"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -37,6 +39,8 @@ const clientLaunchHarness = (input: { readonly launched: Array; readonly workspaceRoot?: string; readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; + readonly accepted?: boolean; + readonly messageCount?: number; }) => { const projectId = ProjectId.make("project:client-target"); const modelSelection = { @@ -70,8 +74,16 @@ const clientLaunchHarness = (input: { issuedAt: 0, capabilities: new Set(["orchestration" as const]), }), - Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getMessageCount: () => Effect.succeed(input.messageCount ?? 0), + }), Layer.mock(ThreadLaunch.ThreadLaunchService)({ + getAcceptedLaunch: ({ threadId }) => + Effect.succeed( + input.accepted === true && threadId !== undefined + ? Option.some({ ...liveThreadShell(threadId), projectId, modelSelection }) + : Option.none(), + ), checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void), launch: (launch) => { input.launched.push(launch); @@ -101,6 +113,7 @@ const clientLaunchHarness = (input: { : Option.none(), ), }), + Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), NodeServices.layer, ).pipe( @@ -197,6 +210,44 @@ 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]).toContain("client%3Asession-1"); + 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 binds only an existing checkout that is one of the project's worktrees", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -256,6 +307,58 @@ it.effect("a launch binds only an existing checkout that is one of the project's }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect.each([ + { name: "first message", messageCount: 0, message: "Begin work.", checksBase: true }, + { name: "message replay", messageCount: 1, message: "Begin work.", checksBase: false }, + { name: "idle replay", messageCount: 0, message: undefined, checksBase: false }, +])("an accepted launch validates its base for $name", ({ messageCount, message, checksBase }) => + Effect.gen(function* () { + const launched: Array = []; + let checks = 0; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + accepted: true, + messageCount, + checkWorktreeBase: ({ projectId }) => { + checks++; + return Effect.fail( + new ThreadLaunch.ThreadLaunchBaseRefError({ + projectId, + projectTitle: "Client target", + workspaceRoot: "/projects/client-target", + baseRef: "missing-base", + }), + ); + }, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const result = (yield* toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + clientRequestId: "accepted-idle", + ...(message === undefined ? {} : { message }), + workspaceStrategy: { type: "worktree", baseRef: "missing-base", startFromOrigin: false }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies))).at(-1)?.result; + expect(checks).toBe(checksBase ? 1 : 0); + expect(launched).toHaveLength(checksBase ? 0 : 1); + expect(result).toMatchObject( + checksBase + ? { + code: "invalid_request", + message: expect.stringContaining("The accepted thread remains idle."), + } + : { projectId }, + ); + }), +); + it.effect("a launch into a new worktree creates nothing when its base ref has no commit", () => Effect.gen(function* () { const launched: Array = []; diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 380a1b52ec96..633c29b0e1b2 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,9 +1,16 @@ -import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + ThreadId, + OrchestratorMcpFailure, + ProjectId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; @@ -12,6 +19,7 @@ import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; 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"; @@ -58,9 +66,18 @@ 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 } = context; - const commandId = yield* newCommandId(); + // 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() + : CommandId.make( + `mcp:launch:${encodeURIComponent(scope.requestNamespace)}:${encodeURIComponent(input.clientRequestId)}`, + ); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); const attachments = input.attachments ?? []; @@ -91,6 +108,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ), )).projectId : yield* resolveProjectId(context, input.projectId); + // Accepted creates reuse their model. Only their first message needs + // base validation; message replays reuse stored preparation. + const acceptedLaunch = + input.clientRequestId === undefined + ? undefined + : Option.getOrUndefined( + yield* ThreadLaunch.ThreadLaunchService.pipe( + Effect.flatMap((launches) => + launches.getAcceptedLaunch({ commandId, threadId, projectId }), + ), + Effect.mapError(unavailable), + ), + ); const readProject = Project.ProjectService.pipe( Effect.flatMap((projects) => projects.getById(projectId)), Effect.mapError(unavailable), @@ -108,7 +138,18 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { // A launch is accepted before its worktree is provisioned, so a base // ref that cannot resolve is refused while the agent can still act on it. const strategy = input.workspaceStrategy; - if (strategy?.type === "worktree") + const acceptedIdleFirstMessage = + acceptedLaunch !== undefined && + strategy?.type === "worktree" && + (input.message !== undefined || attachments.length > 0) && + (yield* ThreadManagement.ThreadManagementService.pipe( + Effect.flatMap((threads) => threads.getMessageCount(threadId)), + Effect.mapError(unavailable), + )) === 0; + if ( + (acceptedLaunch === undefined || acceptedIdleFirstMessage) && + strategy?.type === "worktree" + ) yield* ThreadLaunch.ThreadLaunchService.pipe( Effect.flatMap((launches) => launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }), @@ -117,7 +158,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (error) => new OrchestratorMcpFailure({ code: "invalid_request", - message: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${ + message: `${error.message} ${acceptedLaunch === undefined ? "No thread was created." : "The accepted thread remains idle."} Pass a branch, tag, or commit that exists there${ strategy.startFromOrigin === true ? "; the project has no usable remote to fetch it from." : ", or set startFromOrigin:true if it exists only on a remote." @@ -142,6 +183,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ) : undefined; const modelSelection = + acceptedLaunch?.modelSelection ?? input.modelSelection ?? caller?.modelSelection ?? projectDefaults?.defaultModelSelection ?? diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 2114565d8201..38c620d0d1e7 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -13,6 +13,8 @@ import { ProjectCreatePayload, ProjectUpdatePayload, ProjectId, + OrchestratorMcpClientRequestId, + OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -45,8 +47,9 @@ const shared = { const ProjectListTool = Tool.make("t3_project_list", { ...shared, description: - "List registered projects in this environment. Pages use the current project snapshot and may shift between calls.", + "List registered projects in this environment, or in a linked one with environmentId. Pages use the current project snapshot and may shift between calls.", parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, cursor: Schema.optional(NonNegativeInt), limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), }), @@ -61,7 +64,10 @@ const ProjectReadTool = Tool.make("t3_project_read", { ...shared, description: "Read a registered project in this environment, including its workspace and saved scripts.", - parameters: Schema.Struct({ projectId: ProjectId }), + parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, + projectId: ProjectId, + }), }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); @@ -104,8 +110,9 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. To fetch and start from the base branch\'s remote, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Pass clientRequestId to retry safely: the same key returns the thread the first call created instead of launching another. Retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread in this environment for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. Name a thread in a linked environment by title instead: its link would open here. Without a key, after errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with. Pass environmentId to launch in a linked environment; projectId and modelSelection then name that environment\'s project and providers (t3_project_list and orchestrator_capabilities with the same environmentId list them), and attachments are not supported.', parameters: Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: Schema.optional(ProjectId), scratch: Schema.optional( Schema.Boolean.annotate({ @@ -130,6 +137,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/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 3f3942e75d08..43554490dc98 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/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 49b344961b3f..962b574890c4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1467,6 +1467,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__t3_queue_list", "mcp__t3-code__t3_queue_read", "mcp__t3-code__html_preview", diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index bf50bbc8395e..680efcd594c4 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -199,6 +199,11 @@ export interface EventSinkV2Shape { readonly latestSequence: (input?: { readonly threadId?: ThreadId; }) => Effect.Effect; + readonly read: (input: { + readonly threadId: ThreadId; + readonly throughSequence: number; + readonly eventType: OrchestrationV2DomainEvent["type"]; + }) => Stream.Stream; readonly readByCommandId: (input: { readonly commandId: CommandId; }) => Stream.Stream; @@ -982,6 +987,14 @@ const layerBase: Layer.Layer< }), ), ), + read: (input) => + eventStore + .read(input) + .pipe( + Stream.mapError( + (cause) => new EventSinkStreamError({ threadId: input.threadId, cause }), + ), + ), readByCommandId: (input) => eventStore.readByCommandId(input).pipe( Stream.mapError( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 8c8620088fa9..fbf81d4ed1e5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -383,6 +383,12 @@ export interface OrchestratorV2Shape { readonly getThreadEventSequence: ( threadId: ThreadId, ) => Effect.Effect; + /** Committed history at a fixed sequence, without subscribing to live events. */ + readonly readStoredEvents: (input: { + readonly threadId: ThreadId; + readonly throughSequence: number; + readonly eventType: OrchestrationV2DomainEvent["type"]; + }) => Stream.Stream; readonly streamStoredEvents: Stream.Stream; readonly streamStoredEventsFrom: (input?: { readonly threadId?: ThreadId; @@ -574,6 +580,15 @@ function hasLiveRun(projection: Pick): ); } +/** A queued run cancelled before it started owns no work, so Stop never reaches it. */ +function latestDequeuedRun( + runs: ReadonlyArray, +): OrchestrationV2Run | undefined { + return runs.findLast( + (run) => run.status !== "queued" && !(run.status === "cancelled" && run.startedAt === null), + ); +} + function withPullRequestWatch( link: ThreadPullRequestLink, watch: ThreadPullRequestWatch | undefined, @@ -8845,6 +8860,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } for (const providerThread of input.projection.providerThreads) { if ((providerThread.pendingBackgroundTasks?.length ?? 0) === 0) continue; + if ( + providerThread.lastRunOrdinal !== null && + providerThread.lastRunOrdinal > input.throughRunOrdinal + ) + continue; if (providerThread.id === input.stoppedProviderThreadId) { // A refused newer start can take ownership and reject the stopped // turn's roster clear. Reconcile it without hiding surviving work. @@ -9393,20 +9413,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio now, }); } - // A new turn may have started since Stop; its work is not this Stop's. - if ( - projection.runs.some( - (run) => - run.id !== stoppedRun.id && - (run.status === "preparing" || run.status === "starting" || run.status === "running"), - ) - ) { - return; - } + const backgroundRunIds = new Set( + projection.runs.filter((run) => run.ordinal <= stoppedRun.ordinal).map((run) => run.id), + ); + const backgroundProjection = projection.runs.some((run) => !backgroundRunIds.has(run.id)) + ? { + ...projection, + turnItems: projection.turnItems.filter( + (item) => item.runId !== null && backgroundRunIds.has(item.runId), + ), + subagents: projection.subagents.filter( + (task) => task.runId !== null && backgroundRunIds.has(task.runId), + ), + } + : projection; yield* settleBackgroundWork({ command, events, - projection, + projection: backgroundProjection, stoppedProviderThreadId: command.providerThreadId, throughRunOrdinal: stoppedRun.ordinal, now, @@ -9539,6 +9563,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command: Extract, events: Ref.Ref>, effects: Ref.Ref>, + stopRunIds?: ReadonlySet, ) { const projection = yield* loadProjectionForCommand( command, @@ -9575,14 +9600,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio run?.providerThreadId === null ? undefined : projection.providerThreads.find((candidate) => candidate.id === run?.providerThreadId); + const backgroundProjection = + stopRunIds === undefined + ? projection + : { + ...projection, + runs: projection.runs.filter((candidate) => stopRunIds.has(candidate.id)), + turnItems: projection.turnItems.filter( + (item) => item.runId !== null && stopRunIds.has(item.runId), + ), + subagents: projection.subagents.filter( + (task) => task.runId !== null && stopRunIds.has(task.runId), + ), + }; const hasBackgroundWork = - run?.id === projection.runs.findLast((candidate) => candidate.status !== "queued")?.id && + run?.id === latestDequeuedRun(backgroundProjection.runs)?.id && derivePendingBackgroundWork({ latestRun: run, - providerThreads: projection.providerThreads, - turnItems: projection.turnItems, - activeProviderThreadId: projection.thread.activeProviderThreadId, - runs: projection.runs, + providerThreads: backgroundProjection.providerThreads.filter( + (thread) => + stopRunIds === undefined || + thread.lastRunOrdinal === null || + thread.lastRunOrdinal <= (run?.ordinal ?? -1), + ), + turnItems: backgroundProjection.turnItems, + activeProviderThreadId: + stopRunIds === undefined + ? projection.thread.activeProviderThreadId + : (run?.providerThreadId ?? null), + runs: backgroundProjection.runs, }).length > 0; // A failed start has no provider turn. Background work still belongs // to the provider thread, so Stop reaches its latest accepted turn. @@ -9671,8 +9717,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio { readonly turn: (typeof projection.providerTurns)[number]; readonly runOrdinal: number } >(); for (const item of pendingBackgroundTurnItems({ - turnItems: projection.turnItems, - runs: projection.runs, + turnItems: backgroundProjection.turnItems, + runs: backgroundProjection.runs, })) { const turn = projection.providerTurns.find( (candidate) => candidate.id === item.providerTurnId, @@ -9858,7 +9904,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* settleBackgroundWork({ command, events, - projection, + projection: backgroundProjection, stoppedProviderThreadId: providerThread.id, throughRunOrdinal: run.ordinal, now, @@ -10047,7 +10093,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const dispatchThreadStop = Effect.fn("Orchestrator.dispatchThreadStop")(function* ( - command: Extract, + // Client Stops fit this internal shape without its retry bound. + command: Extract, events: Ref.Ref>, effects: Ref.Ref>, ) { @@ -10062,18 +10109,62 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); if (projection.thread.deletedAt !== null) return undefined; - const latestRun = projection.runs.findLast((run) => run.status !== "queued"); + let stopRuns = projection.runs; + if (command.throughSequence !== undefined) { + // A queued run already belonged to Stop even if it started before the delegated walk. + const existed = yield* eventSink + .read({ + threadId: command.threadId, + throughSequence: command.throughSequence, + eventType: "run.created", + }) + .pipe( + Stream.runFold( + () => new Set(), + (found, stored) => { + if (stored.event.type === "run.created") found.add(stored.event.payload.id); + return found; + }, + ), + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + stopRuns = projection.runs.filter((run) => existed.has(run.id)); + // Partial retries keep queue/watch changes thread-wide while the original run is current. + } + const latestRun = latestDequeuedRun(stopRuns); + if (command.throughSequence !== undefined && latestRun === undefined) return undefined; + const keepNewerWork = latestDequeuedRun(projection.runs)?.id !== latestRun?.id; + const stopRunIds = new Set(stopRuns.map((run) => run.id)); + const orphanProjection = keepNewerWork + ? { + ...projection, + subagents: projection.subagents.filter( + (task) => task.runId !== null && stopRunIds.has(task.runId), + ), + } + : projection; const target = - projection.runs.findLast( + stopRuns.findLast( (run) => run.status === "preparing" || run.status === "starting" || run.status === "running", ) ?? (derivePendingBackgroundWork({ latestRun, - providerThreads: projection.providerThreads, - turnItems: projection.turnItems, - activeProviderThreadId: projection.thread.activeProviderThreadId, - runs: projection.runs, + providerThreads: projection.providerThreads.filter( + (thread) => + !keepNewerWork || + thread.lastRunOrdinal === null || + thread.lastRunOrdinal <= (latestRun?.ordinal ?? -1), + ), + turnItems: projection.turnItems.filter( + (item) => !keepNewerWork || (item.runId !== null && stopRunIds.has(item.runId)), + ), + activeProviderThreadId: keepNewerWork + ? (latestRun?.providerThreadId ?? null) + : projection.thread.activeProviderThreadId, + runs: stopRuns, }).length > 0 ? latestRun : undefined); @@ -10087,11 +10178,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandId: command.commandId, threadId: command.threadId, runId: target.id, - holdQueue: true, + holdQueue: !keepNewerWork, ...(command.reason === undefined ? {} : { reason: command.reason }), }, interruptEvents, interruptEffects, + keepNewerWork ? stopRunIds : undefined, ), ); // A failed interrupt may have written part of its events, so they are kept apart. @@ -10100,7 +10192,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const enqueued = yield* Ref.get(interruptEffects); yield* Ref.update(events, (existing) => [...existing, ...written]); yield* Ref.update(effects, (existing) => [...existing, ...enqueued]); - yield* cancelIdleOrphans({ command, events, projection }); + yield* cancelIdleOrphans({ command, events, projection: orphanProjection }); return interrupted.value; } yield* Effect.logWarning("thread.stop could not interrupt the running turn", { @@ -10112,13 +10204,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const now = yield* DateTime.now; // Record that Stop reached the turn it could not interrupt and the thread's last executed // run, so a late tool call from their agent starts nothing. - const executed = latestExecutedRun(projection.runs); + const executed = latestExecutedRun(stopRuns); const reached = new Map(); if (target !== undefined) reached.set(target.id, target); if (executed !== null && !hasLiveRun({ runs: [executed] })) reached.set(executed.id, executed); for (const run of reached.values()) { yield* markStoppedRun({ command, events, thread: projection.thread, run, now }); } + if (keepNewerWork) { + yield* cancelIdleOrphans({ command, events, projection: orphanProjection }); + return undefined; + } yield* holdStoppedThread({ command, events, projection, cohortRunIds: [], now }); yield* cancelIdleOrphans({ command, events, projection }); return undefined; @@ -11619,6 +11715,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + if ( + command.type === "message.dispatch" && + command.dispatchMode?.type === "defer_start" && + (yield* DispatchModeLimit) !== undefined + ) { + const runCreated = storedEvents.find( + (stored) => stored.event.type === "run.created", + )?.event; + if (runCreated?.type === "run.created") { + const current = yield* projectionStore + .getThreadRecords(command.threadId, ["runs"]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + // Replaying this receipt can resume workspace preparation, so it must + // respect the caller's current ceiling just like a fresh dispatch. + if ( + current.runs.some( + (run) => run.id === runCreated.payload.id && run.status === "preparing", + ) + ) { + yield* refuseAboveDispatchModeLimit(command, command.threadId, current.thread); + } + } + } if (command.type === "queue.resume" || command.type === "queued-run.start") { yield* mapDispatchError(command)( startNextQueuedRun( @@ -11703,7 +11826,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A settle that ended nothing still records its receipt: a replayed Stop // effect then finds it instead of settling work that appeared since. const resultSequence = yield* Effect.gen(function* () { - const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) }); + // Stop receipts also bound other threads; other commands keep their thread cursor. + const sequence = yield* eventSink.latestSequence( + command.type === "thread.stop" ? undefined : { threadId: commandThreadId(command) }, + ); yield* commandReceipts.insertIfAbsent({ commandId: command.commandId, threadId: commandThreadId(command), @@ -12168,6 +12294,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio eventSink .latestSequence({ threadId }) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + readStoredEvents: (input) => + eventSink + .read(input) + .pipe(Stream.mapError((cause) => new OrchestratorDomainEventStreamError({ cause }))), streamStoredEvents: eventSink.stream().pipe( Stream.mapError( (cause) => @@ -12324,6 +12454,12 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + readStoredEvents: () => + Stream.fail( + new OrchestratorDomainEventStreamError({ + cause: "Orchestration V2 live runtime is not configured.", + }), + ), streamStoredEventsFrom: () => Stream.fail( new OrchestratorDomainEventStreamError({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 0391412fe5f9..842889df21b8 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -14,6 +14,7 @@ import { ComposerContextId, type ChatAttachment, CommandId, + EnvironmentId, DEFAULT_SERVER_SETTINGS, GitCommandError, MessageId, @@ -58,6 +59,10 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as McpToolAccess from "../mcp/McpToolAccess.ts"; +import * as ProjectHandlers from "../mcp/toolkits/project/handlers.ts"; +import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; @@ -113,6 +118,7 @@ const claudeAdapter = { } as ProviderAdapter.ProviderAdapterV2["Service"]; interface HarnessOptions { + readonly getProject?: ProjectService.ProjectService["Service"]["getById"]; readonly closeTerminal?: TerminalManager.TerminalManager["Service"]["close"]; readonly adapters?: ReadonlyArray; readonly managedFolders?: Layer.Layer; @@ -178,14 +184,16 @@ function makeHarness(options: HarnessOptions = {}) { bootstrap: () => Effect.die("unused"), update: () => Effect.die("unused"), delete: () => Effect.die("unused"), - getById: (id) => - Effect.succeed( - id === projectId - ? Option.some(project) - : id === otherProjectId - ? Option.some(otherProject) - : Option.none(), - ), + getById: + options.getProject ?? + ((id) => + Effect.succeed( + id === projectId + ? Option.some(project) + : id === otherProjectId + ? Option.some(otherProject) + : Option.none(), + )), getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), snapshot: Effect.die("unused"), getShell: () => Effect.die("unused"), @@ -1386,6 +1394,180 @@ it.effect("replays a server-allocated launch", () => }), ); +it.effect("MCP replays accepted launches and checks worktree membership", () => + Effect.gen(function* () { + const setupEntered = yield* Deferred.make(); + const allowSetup = yield* Deferred.make(); + let changed = false; + const harness = makeHarness({ + getProject: (id) => + Effect.sync(() => + id === projectId + ? Option.some({ ...project, defaultModelSelection: changed ? null : modelSelection }) + : id === otherProjectId + ? Option.some(otherProject) + : Option.none(), + ), + hasCommit: ({ refName }) => Effect.sync(() => refName === "HEAD" || !changed), + git: { hasRefNamed: () => Effect.succeed(false) }, + runSetup: () => + Deferred.succeed(setupEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowSetup)), + Effect.as({ status: "no-script" as const }), + ), + }); + const context = McpInvocationContext.McpInvocationContext.of({ + environmentId: EnvironmentId.make("launch-retry"), + requestNamespace: "client:launch-retry", + thread: undefined, + client: { sessionId: "launch-retry", label: "Test", access: "full-access" }, + issuedAt: 0, + capabilities: new Set(["orchestration" as const]), + }); + const dependencies = Layer.mergeAll( + harness.layer, + NodeCrypto.layer, + NodeServices.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, context), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + listWorktreePaths: () => Effect.succeed([project.workspaceRoot, "/repo-worktrees/feature"]), + }), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-launch-retry-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ); + yield* Effect.gen(function* () { + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide(McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer)), + ); + const input = { + title: "Only once", + projectId, + clientRequestId: "lost-response", + runtimeMode: "approval-required" as const, + message: "Do the work", + workspaceStrategy: { type: "worktree" as const, baseRef: "temporary-base" }, + }; + const launch = (params: Parameters>[1]) => + toolkit.handle("t3_thread_launch", params).pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((results) => results.at(-1)?.result), + ); + const fs = yield* FileSystem.FileSystem; + const outsidePath = yield* fs.makeTempDirectoryScoped({ prefix: "launch-unregistered-" }); + const outsideStrategy = { type: "existing_worktree" as const, worktreePath: outsidePath }; + const refusal = { + code: "invalid_request", + message: + "worktreePath must be one of the project's git worktrees. t3_worktree_list shows them.", + }; + const idleInput = { + title: "Idle accepted launch", + projectId, + clientRequestId: "idle-retry", + workspaceStrategy: { type: "root" as const }, + }; + assert.include(yield* launch(idleInput), { runId: null, status: null }); + assert.include( + yield* launch({ + ...idleInput, + message: "Run outside the project", + workspaceStrategy: outsideStrategy, + }), + refusal, + ); + const threads = yield* ThreadManagement.ThreadManagementService; + const idle = yield* threads.getThreadProjection( + ThreadId.make("mcp:launch:client%3Alaunch-retry:idle-retry"), + ); + assert.equal(idle.messages.length, 0); + assert.equal(idle.runs.length, 0); + assert.equal(idle.thread.worktreePath, null); + assert.equal(harness.createWorktree.mock.calls.length, 0); + assert.equal(harness.runSetup.mock.calls.length, 0); + + const first = yield* launch(input); + assert.isDefined(first); + assert.notProperty(first, "code"); + yield* Deferred.await(setupEntered); + changed = true; + + assert.include(yield* launch({ ...input, workspaceStrategy: outsideStrategy }), refusal); + + assert.deepEqual(yield* launch(input), first); + assert.deepEqual(yield* launch({ ...input, modelSelection: claudeModelSelection }), first); + assert.include(yield* launch({ ...input, projectId: otherProjectId }), { + code: "orchestration_error", + }); + assert.include( + yield* launch(input).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...context, + client: { ...context.client!, access: "read-only" }, + }), + ), + { code: "capability_denied" }, + ); + assert.include(yield* launch({ ...input, clientRequestId: "fresh" }), { + code: "invalid_request", + }); + assert.include( + yield* launch({ + ...input, + clientRequestId: "fresh-root", + workspaceStrategy: { type: "root" }, + }), + { code: "invalid_request" }, + ); + const threadId = ThreadId.make("mcp:launch:client%3Alaunch-retry:lost-response"); + assert.equal((yield* threads.getThreadShell(threadId))?.runtimeMode, "approval-required"); + yield* threads.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("owner:raise-launch-modes"), + threadId, + runtimeMode: "full-access", + }); + assert.include( + yield* launch(input).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...context, + client: { ...context.client!, access: "approval-required" }, + }), + ), + { code: "orchestration_error" }, + ); + const projection = yield* threads.getThreadProjection(threadId); + assert.equal(projection.thread.runtimeMode, "full-access"); + assert.equal(projection.messages.length, 1); + assert.equal(projection.runs.length, 1); + assert.equal(projection.runs[0]?.status, "preparing"); + assert.deepEqual(projection.runs[0]?.workspacePreparation, input.workspaceStrategy); + assert.equal(projection.thread.worktreePath, "/repo-worktrees/feature"); + assert.equal(harness.createWorktree.mock.calls.length, 1); + assert.equal(harness.runSetup.mock.calls.length, 1); + yield* Deferred.succeed(allowSetup, undefined); + yield* threads.streamStoredEventsFrom({ threadId }).pipe( + Stream.filter( + (stored) => + stored.commandId === CommandId.make(`${threadId}:release`) && + stored.event.type === "run.updated", + ), + Stream.runHead, + ); + assert.include( + yield* launch(input).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...context, + client: { ...context.client!, access: "approval-required" }, + }), + ), + { threadId, status: "starting" }, + ); + }).pipe(Effect.provide(dependencies)); + }), +); + it.effect("replays an accepted launch after the project disables the instance", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1423,6 +1605,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* () { @@ -1641,19 +1857,38 @@ it.effect("schedules an accepted preparing message exactly once across concurren text: "Resume preparation", attachments: [], modelSelection: input.modelSelection, - dispatchMode: { type: "defer_start" }, + dispatchMode: { type: "defer_start", workspaceStrategy: input.workspaceStrategy }, createdBy: input.createdBy, creationSource: input.creationSource, }); const preparing = yield* threads.getThreadProjection(input.threadId); assert.equal(preparing.runs[0]?.status, "preparing"); - const [first, second] = yield* Effect.all([launches.launch(input), launches.launch(input)], { + const retry = { ...input, workspaceStrategy: { type: "root" as const } }; + const [first, second] = yield* Effect.all([launches.launch(retry), launches.launch(retry)], { concurrency: "unbounded", }); - yield* Deferred.await(setupEntered); + const prepared = yield* Effect.raceFirst( + Deferred.await(setupEntered).pipe(Effect.as(true)), + threads.streamStoredEventsFrom({ threadId: input.threadId }).pipe( + Stream.filter( + (stored) => + stored.commandId === CommandId.make(input.commandId + ":release") && + stored.event.type === "run.updated", + ), + Stream.runHead, + Effect.as(false), + ), + ); + assert.isTrue(prepared, "the accepted worktree setup must run before releasing the run"); assert.isTrue(first.resumed); assert.isTrue(second.resumed); + assert.equal(harness.createWorktree.mock.calls.length, 1); + assert.equal(harness.runSetup.mock.calls[0]?.[0].worktreePath, "/repo-worktrees/feature"); + assert.deepEqual( + (yield* threads.getThreadProjection(input.threadId)).runs[0]?.workspacePreparation, + input.workspaceStrategy, + ); assert.equal(harness.runSetup.mock.calls.length, 1); yield* Deferred.succeed(allowSetup, undefined); }).pipe(Effect.provide(harness.layer)); @@ -1766,6 +2001,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch }), ), ), + getAcceptedLaunch: launches.getAcceptedLaunch, checkWorktreeBase: launches.checkWorktreeBase, retryPreparation: launches.retryPreparation, prepareRun: launches.prepareRun, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index f1c3d432a10b..892a457368ce 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -11,6 +11,7 @@ import { type OrchestrationV2CreationSource, type OrchestrationV2ProviderThreadNativeMetadata, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, type ProviderDriverKind, type ProviderInteractionMode, ProjectId, @@ -49,6 +50,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 = @@ -166,6 +168,9 @@ export class ThreadLaunchService extends Context.Service< ThreadLaunchResult, ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError >; + readonly getAcceptedLaunch: ( + input: Pick, + ) => Effect.Effect, ThreadLaunchError>; /** * `launch` accepts such a launch and reports the failure inside the * thread; a caller that needs it before a thread exists checks first. @@ -214,7 +219,11 @@ const make = Effect.gen(function* () { yield* Effect.addFinalizer(() => Scope.close(preparationScope, Exit.void)); const mapError = - (input: PreparationInput, operation: ThreadLaunchError["operation"], threadId?: ThreadId) => + ( + input: Pick, + operation: ThreadLaunchError["operation"], + threadId?: ThreadId, + ) => (cause: unknown) => new ThreadLaunchError({ operation, @@ -224,11 +233,39 @@ const make = Effect.gen(function* () { cause, }); - const readReceipt = (input: ThreadLaunchInput, commandId: CommandId) => + const readReceipt = ( + input: Pick, + commandId: CommandId, + ) => receipts .getByCommandId(commandId) .pipe(Effect.mapError(mapError(input, "read-receipt", input.threadId))); + const getAcceptedLaunch: ThreadLaunchService["Service"]["getAcceptedLaunch"] = Effect.fn( + "ThreadLaunchService.getAcceptedLaunch", + )(function* (input) { + const receipt = yield* readReceipt(input, input.commandId); + if (Option.isNone(receipt)) return Option.none(); + if ( + receipt.value.status !== "accepted" || + receipt.value.commandType !== "thread.create" || + (input.threadId !== undefined && input.threadId !== receipt.value.threadId) + ) { + return yield* mapError(input, "read-receipt")("This command cannot be replayed as a launch."); + } + const shell = yield* threads + .getThreadShell(receipt.value.threadId) + .pipe(Effect.mapError(mapError(input, "create-thread", receipt.value.threadId))); + if (shell === null || shell.projectId !== input.projectId) { + return yield* mapError( + input, + "resolve-project", + receipt.value.threadId, + )("The accepted launch does not belong to this project."); + } + return Option.some(shell); + }); + const validateReusableThread = Effect.fn("ThreadLaunchService.validateReusableThread")(function* ( input: ThreadLaunchInput, threadId: ThreadId, @@ -940,7 +977,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) { @@ -973,7 +1009,20 @@ const make = Effect.gen(function* () { createdBy: input.createdBy, creationSource: input.creationSource, }) - .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))); + .pipe( + // An agent's launch may land on a thread a replay, or a + // concurrent launch with the same key, created with broader modes + // than it asks for. 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)), + ); const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); @@ -990,9 +1039,8 @@ const make = Effect.gen(function* () { const projection = yield* threads .getThreadProjection(threadId) .pipe(Effect.mapError(mapError(input, "create-thread", threadId))); - const runIsPreparing = - runId !== null && - projection.runs.some((run) => run.id === runId && run.status === "preparing"); + const acceptedRun = projection.runs.find((run) => run.id === runId); + const runIsPreparing = acceptedRun?.status === "preparing"; const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing; const boundWorktreePath = projection.thread.worktreePath; const preparationStrategy: ThreadLaunchWorkspaceStrategy = @@ -1019,11 +1067,20 @@ const make = Effect.gen(function* () { Effect.mapError(mapError(input, "update-thread", threadId)), ); if (preparationStillRequired) { - yield* schedulePreparation( - { ...input, workspaceStrategy: preparationStrategy }, - threadId, - runId, - ); + if (acceptedRun?.workspacePreparation !== undefined) { + yield* scheduleRunPreparation( + { commandId: input.commandId, threadId, runId: acceptedRun.id }, + projection, + acceptedRun, + acceptedRun.workspacePreparation, + ); + } else { + yield* schedulePreparation( + { ...input, workspaceStrategy: preparationStrategy }, + threadId, + runId, + ); + } } else { yield* releasePreparation(input.commandId); } @@ -1112,7 +1169,13 @@ const make = Effect.gen(function* () { ); }; - return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun }); + return ThreadLaunchService.of({ + launch, + getAcceptedLaunch, + checkWorktreeBase, + retryPreparation, + prepareRun, + }); }); export const layer = Layer.effect(ThreadLaunchService, make); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 543b3e2f89fe..ea0de29b814e 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -43,6 +43,7 @@ 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 Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -1099,24 +1100,66 @@ const make = Effect.gen(function* () { const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = Effect.fn( "ThreadManagementService.stopDelegatedTasks", )(function* (input) { - const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); + const receipt = yield* receipts + .getByCommandId(input.commandId) + .pipe( + Effect.mapError( + (cause) => + new Orchestrator.OrchestratorProjectionError({ threadId: input.threadId, cause }), + ), + ); + return yield* stopDelegatedTasksThrough( + input, + Option.isSome(receipt) ? receipt.value.resultSequence : undefined, + ); + }); + + const stopDelegatedTasksThrough: ( + input: Parameters[0], + throughSequence: number | undefined, + ) => ReturnType = Effect.fn( + "ThreadManagementService.stopDelegatedTasksThrough", + )(function* (input, throughSequence) { + // A retry must not stop tasks delegated after the original Stop, including + // when an earlier walk stopped some children and failed on another. Follow-up + // tasks reuse a child thread, so the set walks its subtree only once. + const childThreadIds = new Set( + throughSequence === undefined + ? (yield* orchestrator.getThreadRecords(input.threadId, ["subagents"])).subagents.flatMap( + (task) => + task.origin === "app_owned" && task.childThreadId !== null + ? [task.childThreadId] + : [], + ) + : yield* orchestrator + .readStoredEvents({ + threadId: input.threadId, + throughSequence, + eventType: "subagent.updated", + }) + .pipe( + Stream.filterMap((stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.origin === "app_owned" && + stored.event.payload.childThreadId !== null + ? Result.succeed(stored.event.payload.childThreadId) + : Result.failVoid, + ), + Stream.runCollect, + ), + ); const failures: Array< Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError > = []; - const stoppedChildThreadIds = new Set(); - for (const task of subagents) { - if (task.origin !== "app_owned" || task.childThreadId === null) continue; - const threadId = task.childThreadId; - // Follow-up tasks reuse a child thread; walk its subtree only once. - if (stoppedChildThreadIds.has(threadId)) continue; - stoppedChildThreadIds.add(threadId); + for (const threadId of childThreadIds) { yield* dispatch({ type: "thread.stop", commandId: CommandId.make(`${input.commandId}:stop:${threadId}`), threadId, + ...(throughSequence === undefined ? {} : { throughSequence }), ...(input.reason === undefined ? {} : { reason: input.reason }), }).pipe( - Effect.andThen(stopDelegatedTasks({ ...input, threadId })), + Effect.andThen(stopDelegatedTasksThrough({ ...input, threadId }, throughSequence)), Effect.catch((error) => Effect.logWarning("Unable to stop a delegated task", { parentThreadId: input.threadId, diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index c8b490008763..81d92a72bc96 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1,8 +1,13 @@ import { assert, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { CommandId, ContextTransferId, EventId, + EnvironmentId, MessageId, NodeId, type OrchestrationV2DomainEvent, @@ -25,13 +30,23 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; import * as EventStore from "./EventStore.ts"; +import * as EventSink from "./EventSink.ts"; import * as TestClock from "effect/testing/TestClock"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as OrchestratorMcpService from "../mcp/OrchestratorMcpService.ts"; +import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as ThreadLaunch from "./ThreadLaunchService.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import { DispatchModeLimit } from "./DispatchModeLimit.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as EffectWorker from "./EffectWorker.ts"; @@ -51,24 +66,42 @@ const adapter = { planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("Runs here never reach a provider"), } as ProviderAdapter.ProviderAdapterV2["Service"]; -const layerDatabase = SqlitePersistence.layerMemory; -const layerTest = ThreadManagementService.layer.pipe( - Layer.provideMerge( - Layer.mergeAll( - layerDatabase, - ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), - EventStore.layer.pipe(Layer.provide(layerDatabase)), - EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), - ProviderReplayHarness.layerWithRegistry( - { name: "thread-stop" }, - ProviderAdapterRegistry.layerFromAdapters([adapter]), - { databaseLayer: layerDatabase, runEffectWorker: false }, +const fileDatabase = (dbPath: string) => + SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer)); +const makeLayerTest = ( + layerDatabase: ReturnType = SqlitePersistence.layerMemory, +) => + ThreadManagementService.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + EventStore.layer.pipe(Layer.provide(layerDatabase)), + EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "thread-stop" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), ), ), - ), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), -); + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); +const layerTest = makeLayerTest(); + +const layerMcp = (layerDatabase: ReturnType) => + OrchestratorMcpService.layer.pipe( + Layer.provideMerge(makeLayerTest(layerDatabase)), + Layer.provide(NodeCrypto.layer), + Layer.provide(ProviderAdapterRegistry.layerFromAdapters([adapter])), + Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + ); const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2)); @@ -533,7 +566,7 @@ it.effect("Stop still stops the thread when a delegated child cannot be read", ( it.effect("Stop ends watches, holds queues, and stops the delegated tasks under the thread", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; + const sink = yield* EventSink.EventSinkV2; const threads = yield* ThreadManagementService.ThreadManagementService; const sql = yield* SqlClient.SqlClient; const parentThreadId = ThreadId.make("thread:stop-parent"); @@ -543,16 +576,21 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(childThreadId, 2)))); const grandchildThreadId = yield* delegate(childThreadId, "grandchild task"); yield* send(childThreadId, "child follow-up", "queue_after_active"); + yield* send(childThreadId, "child queued after follow-up", "queue_after_active"); const now = yield* DateTime.now; const firstRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; - yield* projections.apply({ - id: EventId.make("event:stop-parent:first-run-completed"), - type: "run.updated", - threadId: parentThreadId, - runId: firstRun.id, - occurredAt: now, - payload: { ...firstRun, status: "completed", completedAt: now }, + yield* sink.write({ + events: [ + { + id: EventId.make("event:stop-parent:first-run-completed"), + type: "run.updated", + threadId: parentThreadId, + runId: firstRun.id, + occurredAt: now, + payload: { ...firstRun, status: "completed", completedAt: now }, + }, + ], }); yield* send(parentThreadId, "second", "start_immediately"); const secondRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs.at(-1)!; @@ -580,15 +618,44 @@ it.effect("Stop ends watches, holds queues, and stops the delegated tasks under "delegated-tasks.stop", ); + const childRun = (yield* orchestrator.getThreadProjection(childThreadId)).runs[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make("event:stop-child:first-run-completed"), + type: "run.updated", + threadId: childThreadId, + runId: childRun.id, + occurredAt: now, + payload: { ...childRun, status: "completed", completedAt: now }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume-child-before-stop-walk"), + threadId: childThreadId, + }); + assert.deepEqual((yield* threadState(childThreadId)).runs, ["completed", "starting", "queued"]); + yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); assert.deepEqual(yield* threadState(childThreadId), { - runs: ["interrupted", "queued:held"], + runs: ["completed", "interrupted", "queued:held"], watched: [], }); assert.deepEqual((yield* threadState(grandchildThreadId)).runs, ["interrupted"]); yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); - assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "queued:held"]); + assert.deepEqual((yield* threadState(childThreadId)).runs, [ + "completed", + "interrupted", + "queued:held", + ]); + + yield* send(parentThreadId, "new work", "start_immediately"); + const newChild = yield* delegate(parentThreadId, "new task after Stop"); + yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId: stopCommandId }); + assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]); }).pipe(Effect.provide(layerTest)), ); @@ -1116,40 +1183,364 @@ it.effect.each([ }).pipe(Effect.provide(layerTest)), ); -it.effect("a delegated task that cannot be stopped fails the walk after its siblings stop", () => +it.effect("a partial delegated Stop retries only the original children that failed", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; const threads = yield* ThreadManagementService.ThreadManagementService; const parentThreadId = ThreadId.make("thread:stop-partial"); yield* createWatchingThread(parentThreadId, 6); yield* send(parentThreadId, "work", "start_immediately"); - const childThreadId = yield* delegate(parentThreadId, "stoppable task"); - const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; + const rootRefused = yield* threads + .dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-root-above-limit"), + threadId: parentThreadId, + }) + .pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: "approval-required", + interactionMode: "default", + }), + Effect.exit, + ); + assert.isTrue(Exit.isFailure(rootRefused)); + assert.deepEqual((yield* threadState(parentThreadId)).runs, ["starting"]); + const blockedChild = yield* delegate(parentThreadId, "default task"); + const advancedChild = yield* delegate(parentThreadId, "default task that advances"); + const stoppedChild = yield* delegate(parentThreadId, "plan task"); + const blockedGrandchild = yield* delegate(stoppedChild, "broader grandchild"); + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("approval-child"), + threadId: stoppedChild, + runtimeMode: "approval-required", + }); + yield* orchestrator.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("plan-child"), + threadId: stoppedChild, + interactionMode: "plan", + }); + const commandId = CommandId.make("stop-partial"); + yield* orchestrator.dispatch({ type: "thread.stop", commandId, threadId: parentThreadId }); + const walked = yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId }).pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: "approval-required", + interactionMode: "plan", + }), + Effect.exit, + ); + assert.isTrue(Exit.isFailure(walked)); + assert.deepEqual((yield* threadState(stoppedChild)).runs, ["interrupted"]); + assert.deepEqual((yield* threadState(blockedChild)).runs, ["starting"]); + assert.deepEqual((yield* threadState(blockedGrandchild)).runs, ["starting"]); + + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("independent-child-stop"), + threadId: advancedChild, + }); + yield* send(advancedChild, "new work before failed Stop retry", "start_immediately"); + yield* send(stoppedChild, "new child work", "start_immediately"); + const newGrandchild = yield* delegate(stoppedChild, "new grandchild"); + yield* send(parentThreadId, "new parent work", "start_immediately"); + const newChild = yield* delegate(parentThreadId, "new child"); + yield* threads.stopDelegatedTasks({ threadId: parentThreadId, commandId }); + assert.deepEqual((yield* threadState(blockedChild)).runs, ["interrupted"]); + assert.deepEqual((yield* threadState(blockedGrandchild)).runs, ["interrupted"]); + assert.deepEqual((yield* threadState(stoppedChild)).runs, ["interrupted", "starting"]); + assert.deepEqual((yield* threadState(advancedChild)).runs, ["interrupted", "starting"]); + assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]); + assert.deepEqual((yield* threadState(newGrandchild)).runs, ["starting"]); + }).pipe(Effect.provide(layerTest)), +); + +it.effect.each([ + { + name: "an active run past a queue cancelled since", + afterStop: ["queue", "cancel"], + expected: { runs: ["interrupted", "cancelled"], watched: [] }, + }, + { + name: "a finished run past a queue cancelled since", + afterStop: ["queue", "cancel", "complete"], + expected: { runs: ["completed", "cancelled"], watched: [] }, + }, + { + name: "a run queued before Stop that started since", + beforeStop: ["queue"], + afterStop: ["complete", "resume"], + expected: { runs: ["completed", "interrupted"], watched: [] }, + }, + { + name: "a run queued before Stop that started after newer work finished", + beforeStop: ["queue"], + afterStop: ["complete", "start", "complete", "resume"], + expected: { runs: ["completed", "completed", "interrupted"], watched: [] }, + }, + { + // Partial retries keep queue/watch changes thread-wide while the original run is current. + name: "the original run and the queue and watch added since", + afterStop: ["queue", "watch"], + expected: { runs: ["interrupted", "queued:held"], watched: [] }, + }, + { + name: "nothing when only newer work started", + afterStop: ["complete", "start"], + expected: { runs: ["completed", "starting"], watched: [40] }, + }, + { + name: "nothing when newer work finished past a queue cancelled since", + afterStop: ["complete", "start", "queue", "cancel", "complete"], + expected: { runs: ["completed", "completed", "cancelled"], watched: [40] }, + }, +] as const)("a Stop retry reaches $name", ({ beforeStop = [], afterStop, expected }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make( + `thread:stop-retry-bound:${afterStop.join(",")}:${beforeStop.join(",")}`, + ); + let step = 0; + const apply = (action: (typeof beforeStop)[number] | (typeof afterStop)[number]) => + Effect.gen(function* () { + step++; + if (action === "queue") + return yield* send(threadId, `queued ${step}`, "queue_after_active"); + if (action === "start") return yield* send(threadId, `new ${step}`, "start_immediately"); + if (action === "watch") return yield* watch(threadId, 41); + const runs = (yield* orchestrator.getThreadProjection(threadId)).runs; + if (action === "resume") + return yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`resume:${step}`), + threadId, + }); + if (action === "cancel") + return yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make(`cancel:${step}`), + threadId, + runId: runs.findLast((run) => run.status === "queued")!.id, + }); + const now = yield* DateTime.now; + const run = runs.findLast((candidate) => candidate.status === "starting")!; + yield* projections.apply({ + id: EventId.make(`event:stop-retry-bound:${step}`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", startedAt: now, completedAt: now }, + }); + }); + yield* createWatchingThread(threadId, 40); + yield* send(threadId, "original", "start_immediately"); + for (const action of beforeStop) yield* apply(action); + // The original Stop's receipt bound; its walk failed before reaching this thread. + const throughSequence = yield* orchestrator.getThreadEventSequence(threadId); + for (const action of afterStop) yield* apply(action); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("retry-stop"), + threadId, + throughSequence, + }); + assert.deepEqual(yield* threadState(threadId), { + runs: [...expected.runs], + watched: [...expected.watched], + }); + }).pipe(Effect.provide(layerTest)), +); + +it.effect.each([ + { later: "ordinary", phase: "stop" }, + { later: "newer active run", phase: "stop" }, + { later: "newer completed run", phase: "stop" }, + { later: "newer active run", phase: "settle" }, + { later: "newer completed run", phase: "settle" }, +] as const)("$phase interrupts bounded background work with $later", ({ later, phase }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:stop-background-cancelled-queue"); + yield* createWatchingThread(threadId, 42); + yield* send(threadId, "work", "start_immediately"); + yield* send(threadId, "queued", "queue_after_active"); + const [run, queued] = (yield* orchestrator.getThreadProjection(threadId)).runs; + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel-before-background"), + threadId, + runId: queued!.id, + }); + const now = yield* DateTime.now; + const providerTurnId = ProviderTurnId.make("provider-turn:stop-background"); yield* projections.apply({ - id: EventId.make("event:stop-partial:missing-task"), - type: "subagent.updated", - threadId: parentThreadId, - runId: task.runId!, - nodeId: NodeId.make("node:stop-partial:missing"), - providerInstanceId: instanceId, - occurredAt: yield* DateTime.now, + id: EventId.make("event:stop-background:turn"), + type: "provider-turn.updated", + threadId, + occurredAt: now, payload: { - ...task, - id: NodeId.make("node:stop-partial:missing"), - childThreadId: ThreadId.make("thread:stop-partial:missing"), - startedAt: DateTime.subtract(task.startedAt!, { hours: 1 }), + id: providerTurnId, + providerThreadId: run!.providerThreadId!, + nodeId: run!.rootNodeId!, + runAttemptId: run!.activeAttemptId!, + nativeTurnRef: null, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, }, }); + const backgroundId = TurnItemId.make("item:stop-background"); + yield* projections.apply({ + id: EventId.make("event:stop-background:item"), + type: "turn-item.updated", + threadId, + runId: run!.id, + occurredAt: now, + payload: { + id: backgroundId, + threadId, + runId: run!.id, + nodeId: run!.rootNodeId!, + providerThreadId: run!.providerThreadId!, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: "Background command", + startedAt: now, + completedAt: null, + updatedAt: now, + type: "command_execution", + input: "test background work", + }, + }); + yield* projections.apply({ + id: EventId.make("event:stop-background:completed"), + type: "run.updated", + threadId, + runId: run!.id, + occurredAt: now, + payload: { ...run!, status: "completed", startedAt: now, completedAt: now }, + }); - const walked = yield* Effect.exit( - threads.stopDelegatedTasks({ - threadId: parentThreadId, - commandId: CommandId.make("stop-partial"), - }), + const throughSequence = yield* orchestrator.getThreadEventSequence(threadId); + const newerBackgroundId = TurnItemId.make("item:stop-background:newer"); + if (later !== "ordinary") { + yield* send(threadId, "newer work", "start_immediately"); + const newerProjection = yield* orchestrator.getThreadProjection(threadId); + const newer = newerProjection.runs.at(-1)!; + const newerProviderThread = newerProjection.providerThreads.find( + (thread) => thread.id === newer.providerThreadId, + )!; + yield* projections.apply({ + id: EventId.make("event:stop-background:newer-roster"), + type: "provider-thread.updated", + threadId, + runId: newer.id, + occurredAt: now, + payload: { + ...newerProviderThread, + lastRunOrdinal: newer.ordinal, + pendingBackgroundTasks: [{ taskId: "newer-roster-task", kind: "command" }], + updatedAt: now, + }, + }); + yield* projections.apply({ + id: EventId.make("event:stop-background:newer-item"), + type: "turn-item.updated", + threadId, + runId: newer.id, + occurredAt: now, + payload: { + id: newerBackgroundId, + threadId, + runId: newer.id, + nodeId: newer.rootNodeId!, + providerThreadId: newer.providerThreadId!, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: "Newer background command", + startedAt: now, + completedAt: null, + updatedAt: now, + type: "command_execution", + input: "newer work", + }, + }); + yield* send(threadId, "newer queued work", "queue_after_active"); + if (later === "newer completed run") + yield* projections.apply({ + id: EventId.make("event:stop-background:newer-completed"), + type: "run.updated", + threadId, + runId: newer.id, + occurredAt: now, + payload: { ...newer, status: "completed", startedAt: now, completedAt: now }, + }); + yield* watch(threadId, 43); + } + const before = yield* threadState(threadId); + const beforeProviderWork = (yield* orchestrator.getThreadProjection( + threadId, + )).providerThreads.map((thread) => ({ + id: thread.id, + pendingBackgroundTasks: thread.pendingBackgroundTasks, + })); + if (phase === "settle") + yield* orchestrator.dispatch({ + type: "thread.background-work.settle", + commandId: CommandId.make("settle-background"), + threadId, + providerThreadId: run!.providerThreadId!, + providerTurnId, + }); + else + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-background"), + threadId, + ...(later === "ordinary" ? {} : { throughSequence }), + }); + const stopped = yield* orchestrator.getThreadProjection(threadId); + assert.notInclude( + ["pending", "running", "waiting"], + stopped.turnItems.find((item) => item.id === backgroundId)?.status, ); - assert.isTrue(Exit.isFailure(walked)); - assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted"]); + if (phase === "stop") + assert.deepEqual( + stopped.turnItems + .filter((item) => item.type === "run_interrupt_request") + .map((item) => item.runId), + [run!.id], + ); + if (later === "ordinary") { + assert.isTrue(Exit.isFailure(yield* Effect.exit(watch(threadId, 43)))); + assert.deepEqual(yield* threadState(threadId), { + runs: ["completed", "cancelled"], + watched: [], + }); + } else { + assert.deepEqual(yield* threadState(threadId), before); + assert.equal( + stopped.turnItems.find((item) => item.id === newerBackgroundId)?.status, + "running", + ); + assert.deepEqual( + stopped.providerThreads.map((thread) => ({ + id: thread.id, + pendingBackgroundTasks: thread.pendingBackgroundTasks, + })), + beforeProviderWork, + ); + } }).pipe(Effect.provide(layerTest)), ); @@ -1471,3 +1862,119 @@ it.effect.each(["missing session", "completed", "failed", "cancelled"] as const) ); }).pipe(Effect.provide(layerTest)), ); + +it.effect("an MCP Stop replays its original result after the database and services reopen", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-stop-replay-" }); + const database = fileDatabase(path.join(dir, "statev2.sqlite")); + const scope: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("stop-replay"), + requestNamespace: "client:stop-replay", + capabilities: new Set(["orchestration"]), + issuedAt: 1, + thread: undefined, + client: { sessionId: "stop-replay", label: "Stop replay", access: "full-access" }, + }; + const threadId = ThreadId.make("thread:mcp-stop-replay"); + const stop = { threadId, stop: true, clientRequestId: "stop" }; + const first = yield* Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const idle = ThreadId.make("thread:mcp-stop-idle"); + yield* createWatchingThread(idle, 102); + yield* send(idle, "finished work", "start_immediately"); + yield* send(idle, "held queue", "queue_after_active"); + const idleProjection = yield* (yield* Orchestrator.OrchestratorV2).getThreadProjection(idle); + const idleRun = idleProjection.runs[0]!; + const now = yield* DateTime.now; + yield* (yield* EventSink.EventSinkV2).write({ + events: [ + { + id: EventId.make("event:mcp-stop-idle:completed"), + type: "run.updated", + threadId: idle, + runId: idleRun.id, + occurredAt: now, + payload: { ...idleRun, status: "completed", completedAt: now }, + }, + { + id: EventId.make("event:mcp-stop-idle:held"), + type: "run.updated", + threadId: idle, + runId: idleProjection.runs[1]!.id, + occurredAt: now, + payload: { ...idleProjection.runs[1]!, queueHeld: true }, + }, + ], + }); + assert.deepEqual( + yield* mcp.interruptThread(scope, { + ...stop, + threadId: idle, + clientRequestId: "idle-stop", + }), + { + threadId: idle, + runId: null, + status: "no_active_run", + }, + ); + yield* createWatchingThread(threadId, 101); + const sent = yield* mcp.sendToThread(scope, { + threadId, + message: "first", + clientRequestId: "send", + }); + const childThreadId = yield* delegate(threadId, "original child"); + yield* delegate(childThreadId, "original grandchild"); + const stopped = yield* mcp.interruptThread(scope, stop); + assert.equal(stopped.runId, sent.runId); + yield* send(childThreadId, "child work after parent stopped", "start_immediately"); + const store = yield* EventStore.EventStoreV2; + const childSequence = yield* store.latestSequence({ threadId: childThreadId }); + const noopStop = { ...stop, clientRequestId: "already-stopped-parent" }; + const noopStopped = yield* mcp.interruptThread(scope, noopStop); + assert.deepEqual(noopStopped, { threadId, runId: null, status: "no_active_run" }); + assert.deepEqual((yield* threadState(childThreadId)).runs, ["interrupted", "interrupted"]); + const sql = yield* SqlClient.SqlClient; + const [noopReceipt] = yield* sql<{ command_id: string; result_sequence: number }>` + SELECT command_id, result_sequence FROM orchestration_command_receipts + WHERE aggregate_id = ${threadId} AND command_type = 'thread.stop' + ORDER BY result_sequence DESC LIMIT 1 + `; + assert.isAtLeast(noopReceipt!.result_sequence, childSequence); + assert.deepEqual( + yield* store + .readByCommandId({ commandId: CommandId.make(noopReceipt!.command_id) }) + .pipe(Stream.runCollect), + [], + ); + return { sent, stopped, childThreadId, noopStop, noopStopped }; + }).pipe(Effect.provide(layerMcp(database).pipe(Layer.fresh))); + + yield* Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const replay = yield* mcp.sendToThread(scope, { + threadId, + message: "first", + clientRequestId: "send", + }); + assert.equal(replay.runId, first.sent.runId); + assert.equal((yield* orchestrator.getThreadProjection(threadId)).runs.length, 1); + yield* send(threadId, "after restart", "start_immediately"); + const newChild = yield* delegate(threadId, "child after restart"); + yield* send(first.childThreadId, "child work after restart", "start_immediately"); + assert.deepEqual(yield* mcp.interruptThread(scope, first.noopStop), first.noopStopped); + assert.deepEqual((yield* threadState(first.childThreadId)).runs, [ + "interrupted", + "interrupted", + "starting", + ]); + assert.deepEqual(yield* mcp.interruptThread(scope, stop), first.stopped); + assert.deepEqual((yield* threadState(threadId)).runs, ["interrupted", "starting"]); + assert.deepEqual((yield* threadState(newChild)).runs, ["starting"]); + }).pipe(Effect.provide(layerMcp(database).pipe(Layer.fresh))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts new file mode 100644 index 000000000000..80ae4bd22462 --- /dev/null +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -0,0 +1,617 @@ +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + OrchestratorMcpEnvironmentTarget, + type OrchestrationV2Run, + type SourceControlCloneRepositoryInput, + type ModelSelection, + 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 Option from "effect/Option"; +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 { AttachmentToolkit } from "../mcp/toolkits/attachment/tools.ts"; +import { DeviceToolkit } from "../mcp/toolkits/device/tools.ts"; +import { EnvironmentToolkit } from "../mcp/toolkits/environment/tools.ts"; +import { HtmlToolkit } from "../mcp/toolkits/html/tools.ts"; +import { OrchestratorToolkit } from "../mcp/toolkits/orchestrator/tools.ts"; +import { PreviewToolkit } from "../mcp/toolkits/preview/tools.ts"; +import { PreviewControlsToolkit } from "../mcp/toolkits/previewControls/tools.ts"; +import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; +import { PullRequestsToolkit } from "../mcp/toolkits/pullRequests/tools.ts"; +import { ThreadToolkit } from "../mcp/toolkits/thread/tools.ts"; +import { WorktreeToolkit } from "../mcp/toolkits/worktree/tools.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; + readonly selections: ReadonlyArray; +} +const nothingSeen: Seen = { + sends: [], + waits: [], + launches: [], + projects: [], + clones: [], + stops: [], + selections: [], +}; + +/** + * 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, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + options: [{ id: "reasoning", value: "high" }], + }, + }; + 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, + selections: + command.type === "thread.model-selection.set" + ? [...current.selections, command.modelSelection] + : current.selections, + stops: [ + ...current.stops, + `${command.type} ${"threadId" in command ? command.threadId : ""}`, + ], + })).pipe( + // The stop interrupts the thread's running run. + Effect.as({ + sequence: 0, + storedEvents: [ + { + sequence: 1, + commandId: command.commandId, + event: { + type: "turn-item.updated", + runId: boxRun, + payload: { type: "run_interrupt_request", providerTurnId: "turn:box" }, + }, + }, + ], + } 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)({ + getAcceptedLaunch: () => Effect.succeed(Option.none()), + 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), + ), + }); + +const boxProjectRecord = (input: { + readonly projectId: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +}) => ({ + 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, +}); + +/** B clones and registers whatever it is asked to, and records it. */ +const boxProjects = (seen: Ref.Ref) => + Layer.merge( + Layer.mock(ProjectService.ProjectService)({ + // A launch from outside a thread there starts from the project's defaults. + getById: (projectId) => + Effect.succeed( + projectId === boxProject + ? Option.some( + boxProjectRecord({ + projectId, + title: "box", + workspaceRoot: "/workspace/box-project", + }), + ) + : Option.none(), + ), + create: (input) => + Ref.update(seen, (current) => ({ + ...current, + projects: [...current.projects, input], + })).pipe(Effect.as(boxProjectRecord(input))), + }), + 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(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.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. + yield* a.links.recordOutcome(box.environmentId, { error: "Earlier transport failure." }); + 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); + expect(yield* a.links.get(box.environmentId)).toMatchObject({ + _tag: "Some", + value: { lastError: null }, + }); + + // 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("reaches a linked environment only through the messaging and delegation tools", () => { + const toolkits = [ + OrchestratorToolkit, + ProjectToolkit, + ThreadToolkit, + PullRequestsToolkit, + WorktreeToolkit, + EnvironmentToolkit, + AttachmentToolkit, + DeviceToolkit, + HtmlToolkit, + PreviewToolkit, + PreviewControlsToolkit, + ]; + const forwarded = toolkits + .flatMap((toolkit) => Object.values(toolkit.tools)) + .filter((tool) => { + const parameters: { readonly fields?: Record } = tool.parametersSchema; + return parameters.fields?.environmentId === OrchestratorMcpEnvironmentTarget; + }) + .map((tool) => tool.name) + .sort(); + expect(forwarded).toEqual([ + "orchestrator_capabilities", + "t3_project_list", + "t3_project_read", + "t3_thread_interrupt", + "t3_thread_launch", + "t3_thread_list", + "t3_thread_read", + "t3_thread_send", + "t3_thread_wait", + ]); +}); + +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_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 000000000000..de13d7555081 --- /dev/null +++ b/apps/server/src/peer/PeerForwarding.ts @@ -0,0 +1,193 @@ +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 jsonParameters = Schema.toCodecJson(parameters as Schema.Encoder); + const encoded = (yield* Schema.encodeUnknownEffect(jsonParameters)(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: Schema.toCodecJson(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 new file mode 100644 index 000000000000..aa80b4f82bc2 --- /dev/null +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -0,0 +1,430 @@ +import { NodeHttpServer } from "@effect/platform-node"; +import { type ExecutionEnvironmentDescriptor, OrchestratorMcpFailure } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +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 * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as McpToolAccess from "../mcp/McpToolAccess.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import { + descriptorOf, + layerLinkingEnvironment, + servePeer, + type ServedPeer, +} from "./PeerLinks.testkit.ts"; +import * as PeerMcpClient from "./PeerMcpClient.ts"; + +/** 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", + }), +); + +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." }), + ), + ), +}); + +const serveB = (descriptor: ExecutionEnvironmentDescriptor) => + servePeer(descriptor, McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers)); + +/** Environment A, which links to B. */ +const makeA = ( + descriptor: ExecutionEnvironmentDescriptor, + beforeSecretRemove?: Effect.Effect, +) => + layerLinkingEnvironment(descriptor, beforeSecretRemove).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"); +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: ServedPeer) => b.linkedSessions; + +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. + yield* a.links.recordOutcome(box.environmentId, { error: "Earlier transport failure." }); + const notFound = yield* probe(a, undefined, "refuse").pipe(Effect.flip); + expect(notFound).toMatchObject({ code: "thread_not_found", message: "No such thread." }); + expect(yield* a.links.get(box.environmentId)).toMatchObject({ + _tag: "Some", + value: { lastError: null }, + }); + + const mismatch = yield* a.peer + .call({ + environmentId: box.environmentId, + tool: "probe", + arguments: {}, + limits: { runtimeMode: "auto", interactionMode: "default" }, + success: Schema.Struct({ incompatible: Schema.String }), + }) + .pipe(Effect.flip); + expect(mismatch.message).toContain("did not match this version"); + expect(yield* a.links.get(box.environmentId)).toMatchObject({ + _tag: "Some", + value: { lastError: null }, + }); + }), + ).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)), +); + +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"); + expect(yield* a.links.get(box.environmentId)).toMatchObject({ + _tag: "Some", + value: { lastError: revoked.message }, + }); + + 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)), +); + +it.effect("reads a tool's answer from an event stream carrying other messages first", () => + 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" }); + const answered = yield* probe(a); + + yield* Ref.set(b.sse, true); + expect(yield* probe(a)).toEqual(answered); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect( + "keeps a link and its token when forgetting it fails, and never forgets a newer link", + () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const removal = yield* Ref.make<"fail" | "pause" | null>(null); + const removalEntered = yield* Deferred.make(); + const allowRemoval = yield* Deferred.make(); + const a = yield* makeA( + laptop, + Ref.get(removal).pipe( + Effect.flatMap((action) => + action === "fail" + ? Effect.fail( + new ServerSecretStore.SecretStoreRemoveError({ + resource: "test credential", + cause: new Error("removal failed"), + }), + ) + : action === "pause" + ? Deferred.succeed(removalEntered, undefined).pipe( + Effect.andThen(Deferred.await(allowRemoval)), + ) + : Effect.void, + ), + ), + ); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + const linked = yield* a.links.get(box.environmentId); + + yield* Ref.set(removal, "fail"); + expect((yield* a.links.unlink(box.environmentId).pipe(Effect.flip)).reason).toBe("storage"); + expect(yield* a.links.get(box.environmentId)).toEqual(linked); + expect(yield* probe(a)).toEqual({ caller: "T3 Code · Laptop", modes: "auto/default" }); + + // Linking again while an unlink is forgetting the old token keeps the new link. + yield* Ref.set(removal, "pause"); + const unlinking = yield* a.links.unlink(box.environmentId).pipe(Effect.forkScoped); + yield* Deferred.await(removalEntered); + yield* Ref.set(removal, null); + const repairing = yield* b.auth.issuePairingCredential(); + const relinked = yield* a.links.link({ + url: b.url, + pairingCode: repairing.credential, + access: "approval-required", + }); + yield* Deferred.succeed(allowRemoval, undefined); + expect(yield* Fiber.join(unlinking)).toBe(false); + expect(yield* a.links.get(box.environmentId)).toEqual(Option.some(relinked)); + expect(yield* probe(a)).toEqual({ + caller: "T3 Code · Laptop", + modes: "approval-required/default", + }); + expect(yield* a.links.unlink(box.environmentId)).toBe(true); + expect(yield* a.links.get(box.environmentId)).toEqual(Option.none()); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("never repeats an address's credentials or a peer's own text in a link error", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + for (const url of [ + "file://username:password@example.test/path?token=private", + "http://username:password@192.168.1.20:3773/path?token=private", + ]) { + const invalid = yield* a.links + .link({ url, pairingCode: "unused", access: "auto" }) + .pipe(Effect.flip); + expect(invalid.message).not.toMatch(/username|password|private|example\.test/); + } + const peerText = "PRIVATE-PEER-RESPONSE\n".repeat(200); + for (const [path, body, message] of [ + [ + "/oauth/mcp/register", + { error: "invalid_client_metadata", error_description: peerText }, + "The peer refused to register this environment. Check its connection settings.", + ], + [ + "/oauth/mcp/decision", + { _tag: "AuthMcpApprovalError", message: peerText }, + "The peer refused link approval. Check the pairing code and try again.", + ], + [ + "/oauth/mcp/token", + { error: "invalid_grant", error_description: peerText }, + "The peer refused the sign-in token. Create a new pairing code and try again.", + ], + [ + "/oauth/mcp/decision", + { redirectTo: "http://localhost/?error_description=" + encodeURIComponent(peerText) }, + "The peer did not approve the link. Check the pairing code and try again.", + ], + ] as const) { + yield* Ref.set( + b.replies, + new Map([[path, { status: "redirectTo" in body ? 200 : 400, body }]]), + ); + const code = yield* b.auth.issuePairingCredential(); + const failed = yield* a.links + .link({ url: b.url, pairingCode: code.credential, access: "auto" }) + .pipe(Effect.flip); + expect(failed.reason).toBe("pairing_rejected"); + expect(failed.message).toBe(message); + } + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts new file mode 100644 index 000000000000..7110eafcc928 --- /dev/null +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -0,0 +1,221 @@ +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 HttpServerResponse from "effect/http/HttpServerResponse"; +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); + // Answers tool calls as an event stream with other messages ahead of the answer. + const sse = yield* Ref.make(false); + // Canned answers by path, standing in for a peer's own refusals. + const replies = yield* Ref.make>( + new Map(), + ); + 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]); + } + const reply = (yield* Ref.get(replies)).get(request.url); + if (reply !== undefined) + return HttpServerResponse.jsonUnsafe(reply.body, { status: reply.status }); + const response = yield* httpEffect; + if ( + !(yield* Ref.get(sse)) || + request.url !== "/mcp" || + response.status !== 200 || + response.body._tag !== "Uint8Array" + ) + return response; + const answer = new TextDecoder().decode(response.body.body); + const split = answer.indexOf(",") + 1; + return HttpServerResponse.text( + [ + 'data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":1}}', + "", + 'data: {"jsonrpc":"2.0","id":999,"error":{"code":-1,"message":"other request"}}', + "", + `data: ${answer.slice(0, split)}`, + `data: ${answer.slice(split)}`, + "", + "", + ].join("\r\n"), + { contentType: "text/event-stream" }, + ); + }), + { 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)({ + 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, + sse, + replies, + /** 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, + beforeSecretRemove: Effect.Effect = Effect.void, +) => + PeerMcpClient.layer.pipe( + Layer.provideMerge(PeerLinks.layer), + Layer.provide( + Layer.mergeAll( + Sqlite.layerMemory, + Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.ServerSecretStore, (secrets) => + ServerSecretStore.ServerSecretStore.of({ + ...secrets, + remove: (name) => beforeSecretRemove.pipe(Effect.andThen(secrets.remove(name))), + }), + ), + ).pipe(Layer.provide(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/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts new file mode 100644 index 000000000000..ade53f16e1e8 --- /dev/null +++ b/apps/server/src/peer/PeerLinks.ts @@ -0,0 +1,490 @@ +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 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: "The peer address must use http or https.", + }), + ); + } + if ( + parsed.protocol === "http:" && + !isLocalLoopbackHost(parsed.hostname) && + !isTailnetHost(parsed.hostname) + ) { + return Effect.fail( + new PeerLinkError({ + reason: "unreachable", + message: + "The peer address uses 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, + ); + + const clientFor = (origin: string) => + HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + + /** 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. Check its connection settings.", + cause: error, + }), + ), + ), + ); + 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: "The peer refused link approval. Check the pairing code and try again.", + cause: error, + }), + ), + }), + ); + 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: "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 token. Create a new pairing code and try again.", + cause: error, + }), + ), + ), + ); + }, + (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.succeed(Option.none()), + onSome: (row) => Effect.map(toLink(row), Option.some), + }), + ), + ); + + const unlink: PeerLinks["Service"]["unlink"] = Effect.fn("PeerLinks.unlink")( + function* (environmentId) { + const row = yield* readRow(environmentId); + if (Option.isNone(row)) return false; + yield* secrets.remove(row.value.secret_name).pipe(Effect.catch(failStorage)); + const deleted = yield* sql<{ secret_name: string }>` + DELETE FROM peer_environment_links + WHERE environment_id = ${environmentId} AND secret_name = ${row.value.secret_name} + RETURNING 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 000000000000..7971c34016f7 --- /dev/null +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -0,0 +1,273 @@ +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({ + id: Schema.Number, + 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 ToolCallResponseJson = Schema.fromJsonString(ToolCallResponse); +const decodeToolCallResponse = Schema.decodeUnknownEffect(ToolCallResponseJson); +const decodeToolCallResponseOption = Schema.decodeUnknownOption(ToolCallResponseJson); +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 the server-sent event answering + * `requestId`. A peer may stream notifications ahead of the answer. + */ +const responseJson = (contentType: string | undefined, text: string, requestId: number) => { + if (contentType?.includes("text/event-stream") !== true) return text; + for (const event of text.replace(/\r\n?/g, "\n").split("\n\n")) { + const data = event + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).replace(/^ /, "")) + .join("\n"); + const decoded = decodeToolCallResponseOption(data); + if ( + decoded._tag === "Some" && + decoded.value.id === requestId && + (decoded.value.result !== undefined || decoded.value.error !== undefined) + ) { + return data; + } + } + return ""; +}; + +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.tapError((error) => + error.reason === "unreachable" || error.reason === "expired" + ? links.recordOutcome(input.environmentId, { error: error.message }) + : Effect.void, + ), + 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 response = yield* Effect.gen(function* () { + 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}.`, + }); + } + return { contentType: response.headers["content-type"], text: yield* response.text }; + }).pipe( + Effect.tapError((error) => + links.recordOutcome(input.environmentId, { error: error.message }), + ), + Effect.tap(() => links.recordOutcome(input.environmentId, { error: null })), + Effect.catchTags({ + HttpClientError: () => + Effect.fail( + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "The linked environment stopped answering.", + }), + ), + }), + ); + const body = yield* decodeToolCallResponse( + responseJson(response.contentType, response.text, 1), + ).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.decodeUnknownEffect(success)(text).pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: + "The linked environment's answer did not match this version. Update both environments.", + }), + ), + ); + }); + + 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 5979eee6c18d..7ec9ec7169b4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -70,6 +70,7 @@ import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.t import Migration0065 from "./Migrations/065_CodexUsageNativeTurnIndex.ts"; import Migration0066 from "./Migrations/066_RepairClientSettlementTimestamps.ts"; import Migration0067 from "./Migrations/067_WebhookDedupe.ts"; +import Migration0068 from "./Migrations/068_PeerLinks.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -140,6 +141,7 @@ const migrationEntries = [ [65, "CodexUsageNativeTurnIndex", Migration0065], [66, "RepairClientSettlementTimestamps", Migration0066], [67, "WebhookDedupe", Migration0067], + [68, "PeerLinks", Migration0068], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/068_PeerLinks.ts b/apps/server/src/persistence/Migrations/068_PeerLinks.ts new file mode 100644 index 000000000000..5551ccdd07a2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/068_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 990d450cde6b..edaca42042f2 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -47,6 +47,7 @@ describe("V2 preview upgrade", () => { [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], [67, "WebhookDedupe"], + [68, "PeerLinks"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -137,6 +138,7 @@ describe("V2 preview upgrade", () => { [65, "CodexUsageNativeTurnIndex"], [66, "RepairClientSettlementTimestamps"], [67, "WebhookDedupe"], + [68, "PeerLinks"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 48d701103c07..373a96a88717 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -65,6 +65,9 @@ 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"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -722,6 +725,13 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(layerPullRequestService), Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), Layer.provide(PreviewBrowser.layer), + // Links to other environments, shared by Settings (WebSocket) and agents (/mcp). + 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/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index a9a028ec94bd..a2268402fa4a 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -46,6 +46,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"; @@ -315,6 +316,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 1ba4ff99ff64..d4de6dcb1227 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,6 +1,7 @@ import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; 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"; @@ -1285,6 +1286,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; @@ -2381,6 +2383,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]: @@ -3142,6 +3149,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", @@ -3233,6 +3241,7 @@ export const layer = Layer.unwrap( reviewGuides, ), ), + Layer.provide(Layer.succeed(PeerLinks.PeerLinks, peerLinks)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/docs/community-patches/native-messaging/BRANCH-UPDATES.md b/docs/community-patches/native-messaging/BRANCH-UPDATES.md new file mode 100644 index 000000000000..5fbdbbb357f6 --- /dev/null +++ b/docs/community-patches/native-messaging/BRANCH-UPDATES.md @@ -0,0 +1,29 @@ +# Review fixes after preview.1 + +These changes are on the review branch at source commit [`6a42c2359072b859c4cc57d1ac3e70f851f8d744`](https://github.com/zainmitchell/t3code/commit/6a42c2359072b859c4cc57d1ac3e70f851f8d744). [Fork review PR #1](https://github.com/zainmitchell/t3code/pull/1) remains the review record. The original preview.1 tag, ZIP, raw patch and checksum file are unchanged. Downloading preview.1 does not include these later fixes. Build the branch to try the corrected source while review continues. + +## What changed + +The review branch records connection outcomes before interpreting a peer tool result. A valid tool refusal or an incompatible tool-result schema still reaches the caller as an error, but clears an old connection error. HTTP, session, unreachable-route and expired-link failures still record a link problem. Local unknown-link failures do not become connection outcomes. + +The regression first reproduced a mode refusal appearing as the link's `lastError`. The corrected peer tests cover that refusal, `thread_not_found`, stale-error clearing, decode mismatch and a revoked credential remaining visible as a link failure. + +The installation guide now gives explicit `environment link` and `environment list` commands with the same `--base-dir` as `serve`. Built CLI help confirmed the options. That check did not perform a new remote pairing. + +## Stop-boundary and idle-launch follow-up + +A retried delegated Stop now selects runs recorded before the original Stop boundary, including a queued run that started later. It reaches their known background work even when a newer run exists. Interruption and settlement preserve newer runs, queued work, watches, background items and provider task lists. The settlement callback follows the same ownership bound. Work without a recorded run association is not inferred to belong to an older Stop. + +An accepted idle thread receiving its first message now validates a requested worktree base before starting preparation. A missing base returns an actionable error while the accepted thread remains idle. Ordinary message replays keep the stored preparation and skip mutable base checks. An idle replay without a message also remains unchanged. + +The regression checks reproduced the Stop and first-message defects before correction. All 59 focused Stop and project-handler tests passed after correction. The final changed-file gate passed all 70 tests in four executed files, with six test paths selected, plus formatting, lint, knip, GraphQL checks and typechecking of two affected packages. Counts overlap earlier coverage. + +The current messaging and tick source/test diffs apply in either order to the official base and produce the same tree. This was checked in disposable Git indexes. Combined runtime acceptance remains unverified. + +## Earlier verification + +The changed-file gate passed formatting, lint, knip, GraphQL checks, typechecking of two affected packages, and all 14 peer tests in two executed files. Four test paths were selected. This count overlaps the original preview evidence. + +The earlier preview's broader Windows fixture failures and runtime limitations remain recorded in CHECKS.md and RUNTIME-VERIFICATION.json. A passing follow-up gate is not a full-suite, full-client, real-provider, physical-remote-route, other-OS, existing-state-upgrade or production acceptance claim. No deployment or upstream merge follows from these checks. CodeRabbit's previous review returned findings; assessment and fixes do not establish approval of the new head. + +Implementation and validation of these fixes: GPT-6.1 Sol through Codex in T3 Code. Automated findings: CodeRabbit. The original Claude Opus 5.5 and inherited upstream authorship remain credited in README.md and UPSTREAM.md. diff --git a/docs/community-patches/native-messaging/CHECKS.md b/docs/community-patches/native-messaging/CHECKS.md new file mode 100644 index 000000000000..c0ebb7801193 --- /dev/null +++ b/docs/community-patches/native-messaging/CHECKS.md @@ -0,0 +1,15 @@ +# Publication checks + +The repository's changed-file check was run against the exact v0.1.2 base on Windows x64, Node 26.12.1, Vite+ 1.3.0: + +```sh +vp run check:changed 044f07257c9684ae4543bd1e1ebf9c394e9e3830 +``` + +Its Windows runner first needed a local Node launcher adapter: spawning the pnpm `vp.cmd` wrapper directly as `vp` returned ENOENT. The adapter resolved that one invocation to Node and the installed Vite+ entry point. It is verification tooling, not a feature-source change. + +The running check passed lint (warnings), knip, generated GraphQL checks, and typechecks for the selected 29 packages. It ran 30 test files: 536 tests passed and three failed. Two failures in OrchestratorMcpToolkit.integration.test.ts convert a file URL pathname into a malformed Windows drive path. One ServerEnvironment.test.ts fixture cannot create its symlink under the current Windows permissions. Those fixture operations are present in the official v0.1.2 code; this preview does not change them or enable machine-wide privileges. The aggregate changed-file test gate remains nonzero on this host. + +One peer test needed formatter whitespace and a trailing comma. The preview patch includes that presentation-only cleanup; production code matches the earlier focused/runtime package. Format and lint then passed on all delivered source/docs, and the 14 peer tests passed after formatting. Their count overlaps earlier focused tests. + +Earlier focused and fresh-home runtime evidence remains separately identified in VERIFICATION.json and RUNTIME-VERIFICATION.json. No full-suite, physical network, other-platform, or client UI acceptance is claimed. The download is a community source preview with known validation limits. diff --git a/docs/community-patches/native-messaging/INSTALL.md b/docs/community-patches/native-messaging/INSTALL.md new file mode 100644 index 000000000000..e91d18fd8971 --- /dev/null +++ b/docs/community-patches/native-messaging/INSTALL.md @@ -0,0 +1,67 @@ +# Build and try the Native messaging source preview + +> [!WARNING] +> **Superseded on 2026-10-28: do not use this guide for new installs.** Use official T3 Code [nightly v0.1.4-nightly.20261028.3824](https://github.com/pingdotgg/t3code/releases/tag/v0.1.4-nightly.20261028.3824) or any later nightly, which includes linked environments. In the official nightly, turn on **Linked environments** in **Settings → Labs** on both machines (on mobile, **Settings → Labs** lists each connected machine). Then, on the machine you will work from, open **Settings → Connections → Linked environments** and choose **Link**. See the [official remote access guide](https://github.com/pingdotgg/t3code/blob/eb751fa0730d6a51d56547cb7fcaf4822a7e0e3b/docs/user/remote-access.md#link-another-environment). +> +> Do not apply this patch, or its branch, on top of an official nightly. Upgrading or migrating a T3 home that ran this preview to an official nightly has not been verified, and we have no tested migration path. This preview added its own peer-link migration to the v0.1.2 history. The steps below are kept as a historical record of how the v0.1.2 preview was built. + +The current branch includes [review fixes after preview.1](BRANCH-UPDATES.md). The preview.1 download remains frozen and does not include those fixes. Its MANIFEST.json describes that original patch. The branch build below was the way to try the corrected source; it is now historical. + +This preview targets official T3 Code v0.1.2, commit `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Windows backend setup was exercised with Node 26.12.1 and Vite+ 1.3.0. It provides source code, not a desktop installer or automatic app update. Use disposable state for the first trial. + +## Build the already-patched branch + +Install Node and Vite+ using the [upstream development setup](https://github.com/pingdotgg/t3code/blob/044f07257c9684ae4543bd1e1ebf9c394e9e3830/docs/operations/development.md#first-checkout). The recorded exercise used the versions named above. Clone the [feature branch](https://github.com/zainmitchell/t3code/tree/community/native-messaging-v0.1.2), then install its pinned dependencies and build the server: + +```sh +git clone --branch community/native-messaging-v0.1.2 --single-branch https://github.com/zainmitchell/t3code.git t3-native-messaging-example +cd t3-native-messaging-example +vp i --frozen-lockfile +vp run --filter t3 build:bundle +``` + +The branch already contains the patch. This path needs no additional `git apply` step. + +## Apply only the patch to an existing clean source checkout + +Alternatively, download the source-patch ZIP from the [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1), check its SHA-256 against the Release's checksum file, and extract it outside the checkout. In a clean official source checkout: + +```sh +git checkout --detach v0.1.2 +git rev-parse HEAD +git apply --check /path/to/t3-v0.1.2-native-messaging.patch +git apply /path/to/t3-v0.1.2-native-messaging.patch +vp i --frozen-lockfile +vp run --filter t3 build:bundle +``` + +The HEAD output before application must be `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. Replace `/path/to` with the extracted package directory. MANIFEST.json binds the delivered patch and resulting source-file hashes. The full MIT notice must accompany copies or substantial portions. + +## Start with a new T3 home + +Choose an unused loopback port and new disposable directories. From the source checkout: + +```sh +node apps/server/dist/bin.mjs serve --host 127.0.0.1 --port 47831 --base-dir /path/to/new-example-home --no-browser /path/to/example-project +``` + +On Windows, replace both paths with absolute paths. The built CLI uses `--base-dir`; the development runner's `--home-dir` is a different interface. Runtime state lives below `/userdata`. The headless server prints its readiness and pairing details. Those details are credentials and stay private. The source retains upstream package-version metadata; this preview is identified by its source base and patch checksum, not an official application release stamp. + +The recorded exercise used fresh homes, synthetic projects, disabled providers, authenticated application APIs, and scheduler subscriptions. Exact manual UI navigation, a desktop installer, production-state upgrades, real provider actions, physical remote routes, and other operating systems remain unverified. See RUNTIME-VERIFICATION.json and VERIFICATION.json. + +## Use messaging and linked environments + +The launch and Stop improvements also work locally. Supply a stable `clientRequestId` for one top-level launch and reuse it when recovering that same unanswered request. Follow the returned thread and run to determine actual preparation and execution success. `t3_thread_interrupt` with `stop: true` requests the broader Stop operation; acceptance does not prove every arbitrary process ended. + +For another environment, run the built CLI's `environment link --help` and obtain a pairing code from the destination. Store the link in the same home used by the server above: + +```sh +node apps/server/dist/bin.mjs environment link https://peer.example --pairing-code DESTINATION_PAIRING_CODE --access approval-required --base-dir /path/to/new-example-home +node apps/server/dist/bin.mjs environment list --base-dir /path/to/new-example-home +``` + +Replace the destination URL and pairing-code placeholder. Use the same absolute `--base-dir` for `serve`, `environment link`, and `environment list`. Without that flag or a matching `T3CODE_HOME`, the CLI can store the link in a different home from the running server. + +The agent discovers links with `t3_environment_links` and uses `environmentId` only on supported native tools. Both servers need compatible code. Keep pairing details private and use owner-trusted environments. The destination can revoke its connection. + +The patch adds peer-link migration 68 after the official stable history. A custom fork that already used that number requires integration. Before any existing-state upgrade, stop the server and retain a complete backup. Reversing source code does not restore an earlier database. This preview does not include the later link-origin ownership, remote delegation, or handoff layers described in UPSTREAM.md. diff --git a/docs/community-patches/native-messaging/LICENSE b/docs/community-patches/native-messaging/LICENSE new file mode 100644 index 000000000000..55ee675bb1de --- /dev/null +++ b/docs/community-patches/native-messaging/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 T3 Tools Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/community-patches/native-messaging/MANIFEST.json b/docs/community-patches/native-messaging/MANIFEST.json new file mode 100644 index 000000000000..f578a765ef34 --- /dev/null +++ b/docs/community-patches/native-messaging/MANIFEST.json @@ -0,0 +1,209 @@ +{ + "feature": "native-messaging", + "target": "T3 Code v0.1.2", + "baseCommit": "044f07257c9684ae4543bd1e1ebf9c394e9e3830", + "patch": "t3-v0.1.2-native-messaging.patch", + "patchSha256": "3556c7e4b0ffa39cf131d507396c8d7263558d1d4b8bcb5d7241911a934f7771", + "files": [ + { + "path": "apps/server/src/auth/RpcAuthorization.ts", + "sha256": "e78cb6d1cb81f3bdc756b78dad47e4b2048a7a4eb027c704082f3364d5e72056" + }, + { + "path": "apps/server/src/binCli.ts", + "sha256": "c549d86f3fd7f8332317bbb36ef3c8a79439d809dc72ea7564543b01e4e69b46" + }, + { + "path": "apps/server/src/cli/environment.ts", + "sha256": "172e89ef2f88b35429d93069e775a867aa1ef9b84e53b70c5a0357540225c167" + }, + { + "path": "apps/server/src/environment/ServerEnvironment.ts", + "sha256": "22dccb94768fe1db4f163c319eec6ab37595fb25188d9c99aed6baae79bba20e" + }, + { + "path": "apps/server/src/mcp/McpHttpServer.test.ts", + "sha256": "7eecf3a4d17f5f4d6d2387b564cf0ea3a6d1a9cc16531965fd0385c349564ae4" + }, + { + "path": "apps/server/src/mcp/McpHttpServer.ts", + "sha256": "b0dabeb950e83d16ec6cfd255c5823887d52860afb8c6e06547222349160affb" + }, + { + "path": "apps/server/src/mcp/McpInvocationContext.ts", + "sha256": "2e8e6ac8a94ded58f680068795939a186f9895982cc18593fc0c9d4a909ace11" + }, + { + "path": "apps/server/src/mcp/McpToolAccess.test.ts", + "sha256": "fb9c9cfe1e0acbf63ad8ec4a15d26f5eac89b3072c1553ba46c1d593cf7684ec" + }, + { + "path": "apps/server/src/mcp/McpToolAccess.ts", + "sha256": "674f1a36a6692e6554b921c1ce3eb162f391f312a572b3644652149bd9c495e8" + }, + { + "path": "apps/server/src/mcp/OrchestratorMcpService.ts", + "sha256": "eff6fff44650f4f6b0fa3e15c386fd8050dfca0f74a15d93afb95a230dfcd322" + }, + { + "path": "apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts", + "sha256": "29f3530126b5abc502ce1263c5ca368522af2ce561302fde57f42e8029b3b690" + }, + { + "path": "apps/server/src/mcp/threadAccess.ts", + "sha256": "53dfdaedcfe21f1b26a8920a45118ecea1a6bbc85c6cb188cd514ed5c15eb5e3" + }, + { + "path": "apps/server/src/mcp/toolkits/core.test.ts", + "sha256": "8ef7e6d79e73a65c15e9b74f477c5dd7bc796d60d4dd7005a33a49acdf7950c0" + }, + { + "path": "apps/server/src/mcp/toolkits/orchestrator/handlers.ts", + "sha256": "feb50dadcf2e622d00ce0e7149b0efde6f4a53713c8a8c2799165f7216c138e7" + }, + { + "path": "apps/server/src/mcp/toolkits/orchestrator/tools.ts", + "sha256": "b8ba1c87fcefc7dd53f794bb1fb9fe2f673e2c00e19c035cf5619a86e8c2a6d0" + }, + { + "path": "apps/server/src/mcp/toolkits/project/handlers.test.ts", + "sha256": "45e23026016bcca7841b95f83811b622df11cb29d8a40ed024f1bcfb52e681b4" + }, + { + "path": "apps/server/src/mcp/toolkits/project/handlers.ts", + "sha256": "1b49a0de2b5ac66c6d291409f88299aa74527483d1c2c0faa1baf7e60f5245bf" + }, + { + "path": "apps/server/src/mcp/toolkits/project/tools.ts", + "sha256": "bb3ff81c497f41a0712152558abb1f5f4941533bd7a8b5ec6d9e7a69d25e406e" + }, + { + "path": "apps/server/src/observability/RpcInstrumentation.ts", + "sha256": "9c33cf6e9f10342e2024c29e03413dbdf61108511b86743fe428ac6013a70142" + }, + { + "path": "apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts", + "sha256": "ef86666e632ff589726f10815ca508e2def0ca5e15d4fb8184300e3b8d856821" + }, + { + "path": "apps/server/src/orchestration-v2/EventSink.ts", + "sha256": "8269187be612e9de9855abac3a41844e446dca6b28a240b5c55100ceb81bff36" + }, + { + "path": "apps/server/src/orchestration-v2/Orchestrator.ts", + "sha256": "c71b08648dd1ceb907d958fed12834d179fc6efa12c5584013ca606ec611b7f1" + }, + { + "path": "apps/server/src/orchestration-v2/ThreadLaunchService.test.ts", + "sha256": "ff4decbfdf3c29994a484c38f52e36d500c54a4f83f3dcfaf3072379df0ef0cc" + }, + { + "path": "apps/server/src/orchestration-v2/ThreadLaunchService.ts", + "sha256": "481b2806dc7be11caa497c4103e1979910247c6dcca460cc55eca0da270ab25c" + }, + { + "path": "apps/server/src/orchestration-v2/ThreadManagementService.ts", + "sha256": "bffae4c868071fc8ef867e602377a799cb32435dfc4264974b58713f922694cd" + }, + { + "path": "apps/server/src/orchestration-v2/ThreadStop.test.ts", + "sha256": "65053c430f20f0510b6fc47b52dc492bb7e1375ac4ebbaabc8d39d6e9812ab30" + }, + { + "path": "apps/server/src/peer/PeerForwarding.test.ts", + "sha256": "7cbd913e0b456a7775aa1055f67a0493735cf6ee21a87a25df3d412bc73acebe" + }, + { + "path": "apps/server/src/peer/PeerForwarding.ts", + "sha256": "7df568fb8d004789fe2238a7d08f43035238ddd359bfa58db510a8b11d5ea887" + }, + { + "path": "apps/server/src/peer/PeerLinks.test.ts", + "sha256": "dd1db16508c34b80b2c902881aad52220218e16287c091d7b56842ae2d8e5b4c" + }, + { + "path": "apps/server/src/peer/PeerLinks.testkit.ts", + "sha256": "b508650556fcaae33d091641d24e04803c3b1d2370b0e4d7de5a5cea9dd63248" + }, + { + "path": "apps/server/src/peer/PeerLinks.ts", + "sha256": "8ea2e467a65ab7be37d6cf8b6a7b7ac7e1c113821a22af0608ec89379af0a784" + }, + { + "path": "apps/server/src/peer/PeerMcpClient.ts", + "sha256": "8ea28a7cb960ca78dac2739387e77a27767966668116c5b7dbdc0c061c1366f5" + }, + { + "path": "apps/server/src/persistence/Migrations.ts", + "sha256": "582b186d05f419d890438ab30675189bb1838d0a60831c1936069c5c2ccc531a" + }, + { + "path": "apps/server/src/persistence/Migrations/068_PeerLinks.ts", + "sha256": "142095a7f4f0452c2cd495b412d7ac48552a705c066ec3122836a64b5be72a62" + }, + { + "path": "apps/server/src/persistence/reconcileV2PreviewMigration.test.ts", + "sha256": "b59632ffc23913deab2bf10b2e7616765870833ec426b515272b34023d68647f" + }, + { + "path": "apps/server/src/server.ts", + "sha256": "5e95f17e2114e86a728146f25183521adddb1fcbab6c85b3490b7065ffee9651" + }, + { + "path": "apps/server/src/ws.test.ts", + "sha256": "a3c0a9e623c87153c5068993d778bc772693ef65121b0fd9f0e469cd80800e07" + }, + { + "path": "apps/server/src/ws.ts", + "sha256": "78cd7cd75b7ac340e6431abbde2c47ea7bbc5e780da2493f26537118b57de05a" + }, + { + "path": "packages/client-runtime/src/t3ToolSummary.ts", + "sha256": "9a8078166863153b873856975e8f3adb3cf59b4e3d6e7ba79beef98b1c842ca4" + }, + { + "path": "packages/contracts/src/environment.ts", + "sha256": "9011f75b91ba51614606202703f726d10414e6af7ea1e55d287aee306c52f0e8" + }, + { + "path": "packages/contracts/src/index.ts", + "sha256": "4463c76f97f288781e4d1afb243a4de70b54c307bf70277ee291a3b3cddb319d" + }, + { + "path": "packages/contracts/src/orchestrationV2.ts", + "sha256": "2a21c9fa0dd99336d9761adfc29b8dc7d52396a489a67ffca1de6d9dec2a163d" + }, + { + "path": "packages/contracts/src/orchestratorMcp.ts", + "sha256": "b660da3811d8bf1c2cbdda7c69cda68ce7509b21491eabea7570490465e2e918" + }, + { + "path": "packages/contracts/src/peerLink.ts", + "sha256": "e5ab7f1f2046655adb9320fa366e0ff4b6c59419fe0988bec858488c1e5c46f6" + }, + { + "path": "packages/contracts/src/rpc.test.ts", + "sha256": "7868ff2223d2a3d53d1c8d684974ff03021d676bcc10236954ea2199e40ba81a" + }, + { + "path": "packages/contracts/src/rpc.ts", + "sha256": "48712970ee2bb77daf03028d91eb0d16f7b02a79bf37af69197d0b3e85957acc" + }, + { + "path": "packages/provider-core/src/server/orchestrationInstructions.ts", + "sha256": "4894921fcc3663173446c4899bc29a42e14d5d93d680ac8193f7e63967d795e4" + }, + { + "path": "packages/shared/src/t3McpToolPresentation.ts", + "sha256": "925ba48c5d1039cdba3719f80dde0754169af83d146ef4f0cd15880465eb57d0" + } + ], + "license": "MIT; retain included upstream copyright and permission notice", + "format": "Unified source diff; no contributor history or author metadata included", + "privacyScreen": "Patch and complete changed-file contents screened; no findings", + "previewCleanup": "Whitespace-only formatting of one peer test; production code is identical to the previously verified package", + "originalPackagePatchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb", + "artifactScope": "Frozen preview.1 archive and raw patch; these hashes do not describe later branch files.", + "reviewFollowupSourceCommit": "6a42c2359072b859c4cc57d1ac3e70f851f8d744", + "reviewFollowup": "BRANCH-UPDATES.md" +} diff --git a/docs/community-patches/native-messaging/README.md b/docs/community-patches/native-messaging/README.md new file mode 100644 index 000000000000..320bec601b8b --- /dev/null +++ b/docs/community-patches/native-messaging/README.md @@ -0,0 +1,128 @@ +# Native agent messaging for T3 Code v0.1.2 + +> [!IMPORTANT] +> **Superseded on 2026-10-28. Do not install this patch for new setups.** Linked environments are now part of official T3 Code. Use [nightly v0.1.4-nightly.20261028.3824](https://github.com/pingdotgg/t3code/releases/tag/v0.1.4-nightly.20261028.3824) or any later nightly instead. The original feature PRs this preview bundled, [#16653](https://github.com/pingdotgg/t3code/pull/16653) mode limits, [#16654](https://github.com/pingdotgg/t3code/pull/16654) launch keys, [#16655](https://github.com/pingdotgg/t3code/pull/16655) links and [#16684](https://github.com/pingdotgg/t3code/pull/16684) forwarding, merged upstream on 2026-10-27, along with [#16695](https://github.com/pingdotgg/t3code/pull/16695) link confinement, [#16698](https://github.com/pingdotgg/t3code/pull/16698) Connections UI, [#16719](https://github.com/pingdotgg/t3code/pull/16719) remote delegation and [#17130](https://github.com/pingdotgg/t3code/pull/17130) machine mentions with the Labs beta. +> +> In the official nightly, turn on **Linked environments** in **Settings → Labs** on both machines (on mobile, **Settings → Labs** lists each connected machine). Then, on the machine you will work from, open **Settings → Connections → Linked environments** and choose **Link**. The [official remote access guide](https://github.com/pingdotgg/t3code/blob/eb751fa0730d6a51d56547cb7fcaf4822a7e0e3b/docs/user/remote-access.md#link-another-environment) covers pairing codes, access levels and revoking a link; the [peer-link notes](https://github.com/pingdotgg/t3code/blob/eb751fa0730d6a51d56547cb7fcaf4822a7e0e3b/docs/internals/remote.md#peer-links) describe how the beta gates both sides. +> +> Do not apply this patch, or its branch, on top of an official nightly. Upgrading or migrating a T3 home that ran this preview to an official nightly has not been verified, and we have no tested migration path. This preview added its own peer-link migration to the v0.1.2 history. +> +> The rest of this page is the original preview description, kept for history, attribution and evidence. Its PR states are dated snapshots. + +The review branch now includes [fixes after the frozen preview.1 download](BRANCH-UPDATES.md), including their verification and remaining limits. The original archive and raw patch are unchanged. + +This community source preview extended T3's existing native thread tools with recoverable launches, full Stop/retry behavior, forwarded permission ceilings, and selected tools for explicitly linked environments. It is a reduced stable integration of existing T3 work and selected fixes, with the PR relationships and verification limits below. + +## Why this exists + +A controller can lose a launch response after T3 accepted it. Blindly retrying may start duplicate workers and worktrees. A permission-limited agent can also need another environment's tools without inheriting that environment's broader credential permissions. Cancellation needs to cover queued and delegated work, while a retry of an old Stop must not cancel newer work. + +The goal is predictable control through visible T3 conversations. Provider-specific session names and manual relays do not provide the same T3 thread/run address. This is useful to a human supervising several agents and to a software factory controller, without making either controller the owner of every provider's internal protocol. + +## When to use it + +- Implementation and review run in separate T3 threads or providers, and need concrete follow-ups. +- A controller must recover an unanswered launch and follow the already accepted work. +- Cancellation needs full Stop semantics rather than only an interrupt of the current turn. +- A workflow needs an explicitly authorized environment's projects, models, or threads. This remote path is optional; local coordination remains useful on one machine. + +Local thread control already exists upstream. [#8433](https://github.com/pingdotgg/t3code/discussions/8433) records that work; [#16186](https://github.com/pingdotgg/t3code/discussions/16186) covers launch retry keys. [#6935](https://github.com/pingdotgg/t3code/discussions/6935) and [#16230](https://github.com/pingdotgg/t3code/discussions/16230) cover related cross-computer/provider-specific use cases. This preview shares a stable integration, not a competing claim to originate those capabilities. + +## How it changes T3 + +**Recover a launch.** A top-level launch can carry `clientRequestId`. Accepted receipts can recover the original thread before mutable base/default-model checks reinterpret it as new work. A new operation needs a new key. The key belongs to the invocation namespace, derived from the provider session or authenticated MCP client session; it is not a global identity guaranteed across replacement sessions. Retain the returned thread ID and follow preparation/run history. A returned ID alone does not prove the worker ran successfully. + +**Carry the caller's limits.** Authenticated MCP requests propagate runtime and interaction ceilings through `T3-Mode-Limit`. A peer credential does not authorize a caller to widen its modes. Destination authorization and the link's access still apply. These checks govern T3 operations, not arbitrary host programs. + +**Address another environment.** The server owns the configured link, OAuth pairing, stored credentials, MCP client, and forwarding. Supported tools can discover projects/models, launch ordinary threads, read/message them, wait for a run, and interrupt it at the destination. Forwarded retry identities are caller-scoped. Waits use bounded requests and remain attached to the selected run. Destination-local thread links are omitted from results because they would open in the wrong environment. + +**Stop the intended work.** The broader native Stop operation requests interruption, holds queued messages, ends PR watches, and requests cancellation of app-owned delegated tasks. Replaying the accepted Stop stays bounded to its recorded task history. It does not prove every arbitrary external descendant process has ended. + +For a software factory, this supplies worker addresses and control. It does not select issues, reserve capacity, accept results, integrate code, or provide the factory lifecycle. A remote top-level launch does not become a delegated child. Sending a message can start a model turn at the receiver; messaging is not free model execution or the separate model-free tick feature. + +## Required PR layers and bundled dependencies + +T3 already provides local launch/read/send tools, including queue, steer, and restart. These additions extend request reliability, full Stop behavior, and linked-environment targeting. The four feature layers below form the upstream linked-environment route; each support PR targets the corresponding original feature branch. + +| Required layer and purpose | Original feature | Support/integration work | Stable preview | +| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------ | +| Caller mode ceilings prevent a forwarding credential from widening an agent's permissions. | [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Included. | +| Launch request keys recover accepted work after a lost response. | [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [#22446](https://github.com/pingdotgg/t3code/pull/22446) | Included, with the selected snapshot's replay/validation behavior. | +| Peer links provide authenticated pairing, credential storage, MCP transport, and migration 68. | [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [#22447](https://github.com/pingdotgg/t3code/pull/22447) | Included, with selected response/cleanup fixes. | +| Native tools target the linked environment; integration includes Stop, validation, and codec work. | [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [#22448](https://github.com/pingdotgg/t3code/pull/22448) | Included in the reduced tool set described here. | + +All eight were open and unmerged when checked on 2026-10-25. The feature order is mode ceilings, launch request keys, peer links, then forwarding. These are paired feature/support routes, not eight patches to apply sequentially. Support heads can advance independently; their latest heads are not all a single verified linear stack. + +For the exact official v0.1.2 base, the download already bundles its selected changes in one 48-file patch. It includes contracts, RPC/MCP authorization, launch and Stop persistence, handlers, WebSocket/service wiring, CLI, peer storage/client/forwarder, migration registration, tool/provider guidance, and tests. **Do not apply the linked PRs on top of the stable download.** No native tick or factory patch, new package manifest, or lockfile change is required. The manifest identifies the complete source delta; it is not a claim to contain every later fix in current PR heads. + +The package reuses T3's existing orchestration, thread/project services, authentication, secret storage, provider adapters, contracts, and Effect runtime. Those dependencies come from the pinned base and lockfile. Forwarding requires compatible code and an authorized link at both servers. Local launch/Stop improvements do not require another computer. + +## PR history and later layers + +The older replacement route was closed without merging when the original feature stack was retained. The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) records that decision. Closed here is contribution history, not a diagnosis that the feature was broken. + +| Closed replacement | Capability | Current original / support route | +| -------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#21670](https://github.com/pingdotgg/t3code/pull/21670) | Forwarded mode ceilings | [#16653](https://github.com/pingdotgg/t3code/pull/16653) / [#22445](https://github.com/pingdotgg/t3code/pull/22445) | +| [#21671](https://github.com/pingdotgg/t3code/pull/21671) | Launch replay | [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446) | +| [#21672](https://github.com/pingdotgg/t3code/pull/21672) | Peer links | [#16655](https://github.com/pingdotgg/t3code/pull/16655) / [#22447](https://github.com/pingdotgg/t3code/pull/22447) | +| [#21673](https://github.com/pingdotgg/t3code/pull/21673) | Native-tool forwarding | [#16684](https://github.com/pingdotgg/t3code/pull/16684) / [#22448](https://github.com/pingdotgg/t3code/pull/22448) | +| [#21459](https://github.com/pingdotgg/t3code/pull/21459) | Earlier standalone launch-receipt replacement | Historical launch work; current route [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446). | + +The closed PRs are not additional prerequisites to apply again. Their own test evidence and implementation details apply to their named revisions. For example, #21459 documents key-encoding/length and replay work; this reduced preview must not inherit all of that PR's behavior or evidence by citation alone. + +The larger upstream cross-environment effort also has these **related later layers, outside this package**: + +| Open related PR | Purpose | Relationship to this preview | +| -------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------- | +| [#16695](https://github.com/pingdotgg/t3code/pull/16695) | Keep work started through a link within that link. | Later ownership/lifetime layer; not supplied by this reduced patch. | +| [#16698](https://github.com/pingdotgg/t3code/pull/16698) | Linked-environment controls in web/mobile Connections. | UI layer; this preview uses its bundled CLI/service path. | +| [#16719](https://github.com/pingdotgg/t3code/pull/16719) | Remote `delegate_task`. | Automatic delegated-child lineage/completion is not included here. | +| [#16731](https://github.com/pingdotgg/t3code/pull/16731) | Import conversation history. | Not included. | +| [#16734](https://github.com/pingdotgg/t3code/pull/16734) | Package/apply a thread's Git work. | Not included. | +| [#16751](https://github.com/pingdotgg/t3code/pull/16751) | Hand a thread off to another environment. | Not included. | +| [#16758](https://github.com/pingdotgg/t3code/pull/16758) | Web/mobile Continue-on controls. | Not included. | + +These links preserve relevant context without turning later capabilities into prerequisites for the stable ZIP. Original design/implementation credit remains with the upstream authors. #21459 separately credits GPT-6.1 Sol implementation, GPT-6 Astra tests, and Claude Opus 5.5/GPT-6 Sol reviews. Those are historical upstream credits, not new reviews of this archive. + +## What works, what is pending, and how to review it + +The [PR status map](REVIEW-STATUS.md) separates each head, merge state, CI execution, reported local checks, and older runtime evidence. Open/unmerged, conflicts, a contributor-trust label, and a functional failure are different facts. A green labeling/CodeRabbit rollup is not proof that build/test CI ran. Current support-PR comments report local checks at their named heads; several CI runs require action and executed zero jobs. Historical two-machine results retain their exact tested revisions. None of that automatically validates this separate stable source package. + +For this package, Windows preparation passed 196 focused tests and 31 supplementary tests, relevant typechecks/static checks, and server bundling. The 14 peer tests repeated after formatting overlap those totals. A disposable fresh-home backend exercise passed startup, peer migration, authenticated project API and application RPC with all providers disabled and zero thread/session projections. See [focused evidence](VERIFICATION.json), [runtime evidence](RUNTIME-VERIFICATION.json), and [publication checks](CHECKS.md). + +The later selected check passed 536 tests and static checks but failed three Windows fixture operations already present in v0.1.2: two malformed fixture paths and one symlink-permission case. Its aggregate test gate is not green. The release includes formatting-only cleanup in one peer test; production code matches the earlier exercise. + +Physical routes/T3 Connect, real provider turns, every client/device, other operating systems, desktop installation, and existing-state upgrades remain unverified for this stable preview. Pending uploads cannot be forwarded as attachments. Only the supported tool set accepts environment targeting. Use owner-trusted environments: later link-origin ownership and per-project grant configuration are not supplied here. Historical key edge-case results from other PR heads are not stable-package acceptance claims. + +## Source, installation, and public history + +For new installs, use the official nightly named at the top of this page. The [applied source branch](https://github.com/zainmitchell/t3code/tree/community/native-messaging-v0.1.2) and the [preview Release](https://github.com/zainmitchell/t3code/releases/tag/community-native-messaging-v0.1.2-preview.1) remain available as historical records. The release includes the patch, manifest, MIT notice, guides, evidence and checksums; GitHub's tag archive contains full patched source. [INSTALL.md](INSTALL.md) explains both paths. + +The exact base is official T3 Code v0.1.2, `044f07257c9684ae4543bd1e1ebf9c394e9e3830`. The exercised toolchain was Windows x64, Node 26.12.1 and Vite+ 1.3.0. It needs a source build, not a prompt/skill import, and supplies no desktop installer. PeerLinks adds migration 68 after the released stable history. Custom histories need deliberate integration. Try disposable state first; reversing a patch does not restore an older database. + +| Public milestone | Record | Scope | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| Initial documentation | [3a590cdb](https://github.com/zainmitchell/t3code/commit/3a590cdbcb09f759709e694a59affd8febe13966) | Documentation only. | +| Stable source preview | [1f21af5c](https://github.com/zainmitchell/t3code/commit/1f21af5ca057b36a6f0fed38df84bb278cc3cfd8) | Source and preview download identity. | +| PR map, history, evidence and model attribution | [fb4df062](https://github.com/zainmitchell/t3code/commit/fb4df062d92e644cff7cecac436b22c20fc6e10a) | Documentation; source patch unchanged. | +| Current explanation/history consolidation | This guide and its Git history. | Documentation; frozen release bytes remain unchanged. | + +Useful feedback is a concrete coordination failure, its target revision, or a missing integration dependency. Upstream direction/scope and maintainer acceptance remain separate from fork testing. + +## Maintenance, upstream intent, and bug reports + +This is a temporary community patch fork for free, self-hosted open-source use. The owner maintains the patches and tracks bugs in [the fork's Issues](https://github.com/zainmitchell/t3code/issues) until T3 ships the capability or a better replacement. The intent is to retire these fork-specific changes as upstream meets the need. Upstream now provides linked environments, so this messaging patch is retired and receives no further fixes. + +We welcome upstream adoption through maintainer-approved direction and focused contributions. The stable bundle is a distribution unit, not a demand to merge that entire bundle as one upstream PR. This work carries no upstream endorsement, approval, or merge commitment. Reports should identify the source base, patch/version, reproduction, expected and observed behavior, and suitably redacted evidence. + +The [MIT notice](LICENSE) must accompany copies or substantial portions. Retain applicable dependency notices when distributing a built application. No paid hosting or application installer is provided by this preview. + +## AI models and roles + +| Model | Work credited here | Harness | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | +| Claude Opus 5.5 | Fork implementation/integration work; original T3 source retains its upstream authorship. | T3 Code's Claude provider. | +| GPT-6.1 Sol | Source extraction, limited cleanup, source/evidence and documentation review, focused verification, examples, and publication preparation. Native tick also received a documentation review with this model. | Codex in T3 Code. | + +These credits do not claim a new independent audit of every upstream revision. Historical upstream model credits remain attached to their own PRs. CodeRabbit review of this fork's source has not yet been established; a review request, a bot status, and a completed review are different states. diff --git a/docs/community-patches/native-messaging/REVIEW-STATUS.md b/docs/community-patches/native-messaging/REVIEW-STATUS.md new file mode 100644 index 000000000000..147500206337 --- /dev/null +++ b/docs/community-patches/native-messaging/REVIEW-STATUS.md @@ -0,0 +1,35 @@ +# PR status and revision-specific evidence + +PR metadata snapshot: **2026-10-25T20:07:06.624342+00:00**. CI-run lookup: **2026-10-25T20:18:39.720240+00:00**. Follow each checks/CI link for later changes. This page records a snapshot, not continuous monitoring. + +| PR | Head checked | Merge status | Build/test CI observed | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------- | +| [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [`d868b6296`](https://github.com/pingdotgg/t3code/commit/d868b6296c8a24bbc150ebb4d96d264316206099) | Open, unmerged; merge conflicts | [Build/test jobs present and passing](https://github.com/pingdotgg/t3code/pull/16653/checks) | +| [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [`071e66164`](https://github.com/pingdotgg/t3code/commit/071e6616495613844a1ff062446e41b4fc586d22) | Open, unmerged; clean merge calculation | [Build/test jobs present and passing](https://github.com/pingdotgg/t3code/pull/16654/checks) | +| [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [`197930788`](https://github.com/pingdotgg/t3code/commit/197930788c33a086de927374a30e02da4c6336c4) | Open, unmerged; merge conflicts | [Build/test jobs present and passing](https://github.com/pingdotgg/t3code/pull/16655/checks) | +| [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [`641ee467a`](https://github.com/pingdotgg/t3code/commit/641ee467aa5ceb9126f7c52e2b8b002bf258e0a4) | Open, unmerged; BLOCKED | [No build/test jobs in retrieved rollup](https://github.com/pingdotgg/t3code/pull/16684/checks) | +| [#22445](https://github.com/pingdotgg/t3code/pull/22445) | [`c6f7adcec`](https://github.com/pingdotgg/t3code/commit/c6f7adcecf5dd1e6e4b9e8beab2bc3c364b4249c) | Open, unmerged; UNSTABLE | [No build/test jobs in retrieved rollup](https://github.com/pingdotgg/t3code/pull/22445/checks) | +| [#22446](https://github.com/pingdotgg/t3code/pull/22446) | [`6d84a1e53`](https://github.com/pingdotgg/t3code/commit/6d84a1e536c1418ea0ff459806e4bd08a5a0b606) | Open, unmerged; UNSTABLE | [CI: action_required](https://github.com/pingdotgg/t3code/actions/runs/39736203342); 0 jobs | +| [#22447](https://github.com/pingdotgg/t3code/pull/22447) | [`fc5325405`](https://github.com/pingdotgg/t3code/commit/fc53254058e72f5f262176b6475a75c09033803c) | Open, unmerged; UNSTABLE | [CI: action_required](https://github.com/pingdotgg/t3code/actions/runs/39736209630); 0 jobs | +| [#22448](https://github.com/pingdotgg/t3code/pull/22448) | [`c49288085`](https://github.com/pingdotgg/t3code/commit/c49288085b726e96c0d0002936977dc671590dff) | Open, unmerged; UNSTABLE | [CI: action_required](https://github.com/pingdotgg/t3code/actions/runs/39736215022); 0 jobs | + +All eight PRs were open and unmerged in this snapshot. GitHub's green rollup on the four support PRs includes labeling/CodeRabbit status; it does not establish that build/test CI ran. Their CI workflow results above must be read separately. `action_required` with zero jobs is not an executed test failure. `UNSTABLE` and `BLOCKED` are GitHub merge-state values, not diagnoses of a functional defect. Some original-PR jobs were skipped. A `vouch:unvouched` label concerns contributor trust under upstream policy, not whether a feature works. + +## What evidence covers the current support heads? + +These are reported results from the linked PR records, not new tests performed by the stable-package publication task: + +| Support PR | Revision-specific evidence and limits | +| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Its description identifies current `c6f7adce` as the automatic merge; reports 110 passing checks and two Windows fixture failures also reproduced on pristine main, plus typecheck/static checks. | +| [#22446](https://github.com/pingdotgg/t3code/pull/22446#issuecomment-6334626735) | Current `6d84a1e53`: reports 114 focused tests, typecheck and static checks. The retained existing-worktree membership safeguard is an explicit maintainer review point; no new two-machine claim. | +| [#22447](https://github.com/pingdotgg/t3code/pull/22447#issuecomment-6334627047) | Current `fc5325405`: reports 138 focused tests, typecheck/static checks, and the two relevant replay regressions checked on the combined candidate. No new two-machine claim. | +| [#22448](https://github.com/pingdotgg/t3code/pull/22448#issuecomment-6334627496) | Current `c49288085`: reports 264 server tests, four contracts tests, typechecks/static checks, and independent checks of two overlapping launch regressions. Historical two-machine results do not establish runtime acceptance of this head. | + +The [top PR description](https://github.com/pingdotgg/t3code/pull/22448) scopes its eight two-machine scenarios to `820e789c3d08b51612a1f244bd829561c8808bbc`. A [later Nightly integration report](https://github.com/pingdotgg/t3code/pull/22448#issuecomment-6324259910) names a different tested integration revision and limits. Preserve those revision boundaries. + +The stable v0.1.2 preview is another source target. Its own manifest, checksums, focused tests, three disclosed Windows fixture failures, and disposable backend exercise are the evidence for that download. Neither its results nor historical runtime results automatically validate the latest upstream PR heads. + +## How to judge a suspected failure + +Use the exact head and a reproducible failed behavior or executed check to identify a defect. An open or closed PR, missing CI execution, a conflict, or a green administrative status alone cannot establish whether the feature works. Maintainer approval and merge acceptance remain separate from functional verification. diff --git a/docs/community-patches/native-messaging/RUNTIME-VERIFICATION.json b/docs/community-patches/native-messaging/RUNTIME-VERIFICATION.json new file mode 100644 index 000000000000..871a0b36da41 --- /dev/null +++ b/docs/community-patches/native-messaging/RUNTIME-VERIFICATION.json @@ -0,0 +1,30 @@ +{ + "sourceBase": "044f07257c9684ae4543bd1e1ebf9c394e9e3830", + "patchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb", + "host": "Windows x64", + "node": "26.12.1", + "feature": "native-messaging", + "observed": { + "freshHomeStartup": "passed", + "peerLinkMigration": "present", + "authenticatedProjectApi": "passed", + "authenticatedClientRpc": "passed", + "runtimeSourceMapBinding": "critical changed services match source", + "threadAndSessionProjections": 0 + }, + "enabledProviders": 0, + "proofServersStopped": true, + "notVerified": [ + "Manual task-creation UI steps", + "Desktop installation or production-state upgrade", + "Real provider turns or worker-directed actions", + "External CI integration", + "Physical host-to-host routes or T3 Connect", + "Other operating systems", + "Full client/device acceptance", + "MCP OAuth client acceptance" + ], + "scope": "Windows disposable backend proof; no live production installation", + "distributedPreviewPatchSha256": "3556c7e4b0ffa39cf131d507396c8d7263558d1d4b8bcb5d7241911a934f7771", + "previewDifference": "Whitespace-only formatting in PeerForwarding.test.ts; original behavioral evidence is for the patchSha256 already recorded above" +} diff --git a/docs/community-patches/native-messaging/UPSTREAM.md b/docs/community-patches/native-messaging/UPSTREAM.md new file mode 100644 index 000000000000..0a7a5431d733 --- /dev/null +++ b/docs/community-patches/native-messaging/UPSTREAM.md @@ -0,0 +1,46 @@ +# Dependencies and history + +## Required PR layers and bundled dependencies + +T3 already provides local launch/read/send tools, including queue, steer, and restart. These additions extend request reliability, full Stop behavior, and linked-environment targeting. The four feature layers below form the upstream linked-environment route; each support PR targets the corresponding original feature branch. + +| Required layer and purpose | Original feature | Support/integration work | Stable preview | +| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------ | +| Caller mode ceilings prevent a forwarding credential from widening an agent's permissions. | [#16653](https://github.com/pingdotgg/t3code/pull/16653) | [#22445](https://github.com/pingdotgg/t3code/pull/22445) | Included. | +| Launch request keys recover accepted work after a lost response. | [#16654](https://github.com/pingdotgg/t3code/pull/16654) | [#22446](https://github.com/pingdotgg/t3code/pull/22446) | Included, with the selected snapshot's replay/validation behavior. | +| Peer links provide authenticated pairing, credential storage, MCP transport, and migration 68. | [#16655](https://github.com/pingdotgg/t3code/pull/16655) | [#22447](https://github.com/pingdotgg/t3code/pull/22447) | Included, with selected response/cleanup fixes. | +| Native tools target the linked environment; integration includes Stop, validation, and codec work. | [#16684](https://github.com/pingdotgg/t3code/pull/16684) | [#22448](https://github.com/pingdotgg/t3code/pull/22448) | Included in the reduced tool set described here. | + +All eight were open and unmerged when checked on 2026-10-25. The feature order is mode ceilings, launch request keys, peer links, then forwarding. These are paired feature/support routes, not eight patches to apply sequentially. Support heads can advance independently; their latest heads are not all a single verified linear stack. + +For the exact official v0.1.2 base, the download already bundles its selected changes in one 48-file patch. It includes contracts, RPC/MCP authorization, launch and Stop persistence, handlers, WebSocket/service wiring, CLI, peer storage/client/forwarder, migration registration, tool/provider guidance, and tests. **Do not apply the linked PRs on top of the stable download.** No native tick or factory patch, new package manifest, or lockfile change is required. The manifest identifies the complete source delta; it is not a claim to contain every later fix in current PR heads. + +The package reuses T3's existing orchestration, thread/project services, authentication, secret storage, provider adapters, contracts, and Effect runtime. Those dependencies come from the pinned base and lockfile. Forwarding requires compatible code and an authorized link at both servers. Local launch/Stop improvements do not require another computer. + +## PR history and later layers + +The older replacement route was closed without merging when the original feature stack was retained. The [closure explanation](https://github.com/pingdotgg/t3code/pull/21673#issuecomment-6301888964) records that decision. Closed here is contribution history, not a diagnosis that the feature was broken. + +| Closed replacement | Capability | Current original / support route | +| -------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#21670](https://github.com/pingdotgg/t3code/pull/21670) | Forwarded mode ceilings | [#16653](https://github.com/pingdotgg/t3code/pull/16653) / [#22445](https://github.com/pingdotgg/t3code/pull/22445) | +| [#21671](https://github.com/pingdotgg/t3code/pull/21671) | Launch replay | [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446) | +| [#21672](https://github.com/pingdotgg/t3code/pull/21672) | Peer links | [#16655](https://github.com/pingdotgg/t3code/pull/16655) / [#22447](https://github.com/pingdotgg/t3code/pull/22447) | +| [#21673](https://github.com/pingdotgg/t3code/pull/21673) | Native-tool forwarding | [#16684](https://github.com/pingdotgg/t3code/pull/16684) / [#22448](https://github.com/pingdotgg/t3code/pull/22448) | +| [#21459](https://github.com/pingdotgg/t3code/pull/21459) | Earlier standalone launch-receipt replacement | Historical launch work; current route [#16654](https://github.com/pingdotgg/t3code/pull/16654) / [#22446](https://github.com/pingdotgg/t3code/pull/22446). | + +The closed PRs are not additional prerequisites to apply again. Their own test evidence and implementation details apply to their named revisions. For example, #21459 documents key-encoding/length and replay work; this reduced preview must not inherit all of that PR's behavior or evidence by citation alone. + +The larger upstream cross-environment effort also has these **related later layers, outside this package**: + +| Open related PR | Purpose | Relationship to this preview | +| -------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------- | +| [#16695](https://github.com/pingdotgg/t3code/pull/16695) | Keep work started through a link within that link. | Later ownership/lifetime layer; not supplied by this reduced patch. | +| [#16698](https://github.com/pingdotgg/t3code/pull/16698) | Linked-environment controls in web/mobile Connections. | UI layer; this preview uses its bundled CLI/service path. | +| [#16719](https://github.com/pingdotgg/t3code/pull/16719) | Remote `delegate_task`. | Automatic delegated-child lineage/completion is not included here. | +| [#16731](https://github.com/pingdotgg/t3code/pull/16731) | Import conversation history. | Not included. | +| [#16734](https://github.com/pingdotgg/t3code/pull/16734) | Package/apply a thread's Git work. | Not included. | +| [#16751](https://github.com/pingdotgg/t3code/pull/16751) | Hand a thread off to another environment. | Not included. | +| [#16758](https://github.com/pingdotgg/t3code/pull/16758) | Web/mobile Continue-on controls. | Not included. | + +These links preserve relevant context without turning later capabilities into prerequisites for the stable ZIP. Original design/implementation credit remains with the upstream authors. #21459 separately credits GPT-6.1 Sol implementation, GPT-6 Astra tests, and Claude Opus 5.5/GPT-6 Sol reviews. Those are historical upstream credits, not new reviews of this archive. diff --git a/docs/community-patches/native-messaging/VERIFICATION.json b/docs/community-patches/native-messaging/VERIFICATION.json new file mode 100644 index 000000000000..192dabb97ad8 --- /dev/null +++ b/docs/community-patches/native-messaging/VERIFICATION.json @@ -0,0 +1,47 @@ +{ + "target": "Official T3 Code v0.1.2 source", + "baseCommit": "044f07257c9684ae4543bd1e1ebf9c394e9e3830", + "patchSha256": "7e4564c13a86f167eac91ec1fe3604b6eee83a029c5d2e4b7caed2f3deec4dbb", + "host": "Windows x64", + "node": "26.12.1", + "focused": { + "tests": 196, + "files": 8, + "exitCode": 0 + }, + "supplemental": { + "tests": 31, + "files": 5, + "exitCode": 0 + }, + "postCleanupPeerRecheck": { + "tests": 14, + "files": 2, + "exitCode": 0 + }, + "typecheck": "passed", + "format": "passed", + "lint": "passed with warnings", + "unusedCodeCheck": "passed", + "serverBundle": "passed", + "applyChecks": "passed alone and in either order with the other package; identical combined tree", + "combinedFocused": { + "tests": 242, + "files": 12, + "exitCode": 0 + }, + "combinedTypecheck": "passed", + "combinedServerBundle": "passed", + "exampleSmoke": null, + "notVerified": [ + "Full repository test suite", + "Physical cross-machine routes or real T3 Connect", + "Real provider/model execution", + "Browser/desktop/mobile runtime behavior", + "Other operating systems", + "Installation against existing production state" + ], + "distribution": "Source patch only. No application binary, private evidence, account identity, or fork history.", + "distributedPreviewPatchSha256": "3556c7e4b0ffa39cf131d507396c8d7263558d1d4b8bcb5d7241911a934f7771", + "previewDifference": "Whitespace-only formatting in PeerForwarding.test.ts; original behavioral evidence is for the patchSha256 already recorded above" +} diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index df655c1d6c21..966a823b5f27 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -333,6 +333,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/environment.ts b/packages/contracts/src/environment.ts index 18d09c304a83..a90ed977323a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -152,6 +152,14 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ "server"`) and streams them over `/api/preview-stream`. Clients without a local browser runtime open server tabs here. */ 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 7f38a38dbbae..d9680e959287 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -39,6 +39,7 @@ export * from "./orchestrationProject.ts"; export * from "./orchestrationV2.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/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 881a316f59ae..a6b542609c05 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3062,6 +3062,17 @@ const OrchestrationV2InternalCommand = Schema.Union([ }), ), }), + /** + * Internal so no client can bound a Stop retry to earlier history; client + * Stops use the public `OrchestrationV2Command` variant. + */ + Schema.Struct({ + type: Schema.Literal("thread.stop"), + commandId: CommandId, + threadId: ThreadId, + reason: Schema.optional(Schema.String), + throughSequence: Schema.optional(NonNegativeInt), + }), /** * Internal so no client can mark a request saved without the value being * stored. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index c962c3975c17..c3a3103a18bb 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, @@ -46,7 +48,7 @@ const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120 const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({ description: "Optional concise display title.", }); -const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( +export const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check( Schema.isMaxLength(256), ).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." }); @@ -305,7 +307,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: + "A linked environment to do this in; t3_environment_links lists them. Omit for this environment. Ids (projects, threads, runs) then belong to that environment.", + }), +); + export const OrchestratorMcpThreadListInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, projectId: OrchestratorMcpProjectTarget, statuses: Schema.optional( Schema.Array(OrchestratorMcpThreadStatus).check(Schema.isMaxLength(10)), @@ -378,6 +393,7 @@ const clampedPositiveInt = (maximum: number) => ); export const OrchestratorMcpThreadReadInput = Schema.Struct({ + environmentId: OrchestratorMcpEnvironmentTarget, threadId: ThreadId, itemId: Schema.optional(TurnItemId), textOffset: Schema.optional(NonNegativeInt), @@ -472,6 +488,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"])), @@ -490,6 +507,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), @@ -505,9 +523,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; @@ -524,6 +549,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: @@ -561,6 +587,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), diff --git a/packages/contracts/src/peerLink.ts b/packages/contracts/src/peerLink.ts new file mode 100644 index 000000000000..022b1b9b9f8c --- /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.test.ts b/packages/contracts/src/rpc.test.ts index 718cd3e50f84..ced58ca80690 100644 --- a/packages/contracts/src/rpc.test.ts +++ b/packages/contracts/src/rpc.test.ts @@ -35,6 +35,28 @@ describe("WebSocket RPC contracts", () => { ).toBe(true); }); + it("drops a client-supplied retry bound from a dispatched Stop", () => { + const dispatchCommand = WsRpcGroup.requests.get(ORCHESTRATION_V2_WS_METHODS.dispatchCommand); + if (dispatchCommand === undefined) throw new Error("dispatchCommand is not registered"); + const decode = Schema.decodeUnknownSync(dispatchCommand.payloadSchema); + + const decoded = decode({ + type: "thread.stop", + commandId: "stop-1", + threadId: "thread-1", + reason: "User stopped the thread.", + throughSequence: 0, + }); + + expect(decoded).not.toHaveProperty("throughSequence"); + expect(decoded).toStrictEqual({ + type: "thread.stop", + commandId: "stop-1", + threadId: "thread-1", + reason: "User stopped the thread.", + }); + }); + it("lets the server pick a new worktree's folder", () => { const createWorktree = WsRpcGroup.requests.get(WS_METHODS.vcsCreateWorktree); if (createWorktree === undefined) throw new Error("vcs.createWorktree is not registered"); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d7a11234c446..2973a5486237 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -14,6 +14,14 @@ import { McpAppToolInfoInput, McpAppUpdateModelContextInput, } from "./mcpApps.ts"; +import { + PeerLink, + PeerLinkCreateInput, + PeerLinkError, + PeerLinkRemoveInput, + PeerLinkRemoveResult, + PeerLinkSummary, +} from "./peerLink.ts"; import { ChatGptReconnectProfileInput, ChatGptReconnectProfile, @@ -482,6 +490,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", @@ -755,6 +766,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, @@ -1909,6 +1938,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetDatabaseStorageRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsPeerLinksListRpc, + WsPeerLinksLinkRpc, + WsPeerLinksUnlinkRpc, WsServerDiscoverSourceControlRpc, WsServerSearchAcpRegistryRpc, WsServerPrepareAcpRegistryAgentRpc, diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 88053dee9b54..796751c56248 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -14,8 +14,9 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`, `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. +- \`t3_environment_links\` lists the user's other T3 Code environments that this one is linked to, such as another machine. To work there, pass that \`environmentId\` to any T3 tool that takes one, such as \`t3_thread_launch\`, \`t3_thread_send\`, \`t3_thread_wait\`, \`t3_thread_read\`, or \`t3_project_list\`; project, thread, run, and model ids then belong to that environment, so pass \`threadId\` explicitly. Work there runs within your own modes and the link's access. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. -- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.`, +- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. Name a thread in a linked environment by title instead: its link would open here.`, `### Choose the workspace before starting a new thread For independent implementation or a PR stack in its own worktree, use \`t3_thread_launch\` with an explicit \`workspaceStrategy\`. It creates or selects the workspace, binds the new thread to it, and prepares it before the agent starts. Put the task in \`message\`, not \`prompt\`: @@ -28,7 +29,7 @@ For stacked work, set \`baseRef\` to the intended parent branch and \`startFromO \`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. -\`t3_thread_launch\` has no idempotency key. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance. If a launch fails or its response is lost, inspect \`t3_thread_list\` before retrying, since a thread may already exist.`, +Pass a \`clientRequestId\` to \`t3_thread_launch\` and reuse it when retrying: the same key returns the thread the first call created. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance.`, `Tool names may include a harness-normalized MCP prefix, such as \`mcp__t3_code__delegate_task\`; the semantics are the same. Some harnesses attach optional MCP servers lazily: if an initial tool-catalog scan does not show T3 tools, do not conclude that cross-provider delegation is unavailable. Make one bounded direct attempt using the known T3 tool name on the next tool step. In Codex code mode, for example, call \`tools.mcp__t3_code__orchestrator_capabilities({})\` before reporting that the capability is absent. Keep polling/wait loops bounded, do not duplicate active work, and use stable \`clientRequestId\` values when retrying tools that accept them. ACP fallback: some ACP agents accept the injected MCP server but fail to expose its tools. When the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call the same tools through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset when T3 runs as a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`. This is the supported T3 transport fallback, not an ordinary shell-based substitute for delegation. diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 21f8be3941f9..975361f36b1c 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -50,6 +50,7 @@ export type T3McpToolSummaryAction = | "project-delete" | "project-clone" | "environment-read" + | "environment-links" | "environment-update" | "attachment-prepare" | "attachment-discard" @@ -309,6 +310,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",