diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 50be40682066b..7871f12caceea 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -14,9 +14,11 @@ import { } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import { McpSchema, McpServer } from "effect/ai"; import * as ServerConfig from "../config.ts"; @@ -686,3 +688,36 @@ it.effect("refuses what a linked environment cannot take before anything leaves" }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("refuses a launch there above the link's access, naming the link", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const linkSession = yield* Ref.make(""); + const b = yield* serveBox(seen, linkSession); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId); + + // A full-access agent here asks for more than the link allows there. + const launchAboveLink = a.call("thread:laptop-full", "t3_thread_launch", { + environmentId: box.environmentId, + projectId: boxProject, + title: "Run the suite there", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + message: "Run the suite.", + runtimeMode: "full-access", + }); + const { message } = failureOf(yield* launchAboveLink); + expect(message).toContain('"Box"'); + expect(message).toContain("auto"); + expect(message).toContain("full-access"); + expect(message).not.toContain("parent mode"); + expect((yield* Ref.get(seen)).launches).toEqual([]); + + // Once the link has expired, that is what the agent needs to hear. + yield* TestClock.adjust(Duration.days(31)); + expect(failureOf(yield* launchAboveLink).message).toContain("expired"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index b8391e285c18f..09b937dd64033 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -8,6 +8,8 @@ import { OrchestratorMcpThreadReadResult, OrchestratorMcpThreadWaitInput, OrchestratorMcpThreadWaitResult, + type PeerLinkError, + RuntimeMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -15,9 +17,11 @@ 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 Option from "effect/Option"; import * as Schema from "effect/Schema"; import type * as Tool from "effect/ai/Tool"; +import { runtimeModeRank } from "../mcp/delegatedTaskModes.ts"; import { assertNotLinked } from "../mcp/linkOrigin.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { readCaller, unavailable } from "../mcp/threadAccess.ts"; @@ -87,6 +91,11 @@ export const linkedEnvironmentsOff = new OrchestratorMcpFailure({ "Linked environments is off on this machine, so no tool can act on another machine. Leave out environmentId to work here; the user can turn it on in Settings → Labs.", }); +const isRuntimeMode = Schema.is(RuntimeMode); + +const linkStorageFailure = (error: PeerLinkError) => + new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }); + /** 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; @@ -122,6 +131,28 @@ const make = Effect.gen(function* () { Effect.orDie, ); + /** + * The linked environment refuses a mode above the link's access too, but as + * "broader than parent mode", which misleads when the parent here is broader. + * A read-only link refuses the launch there with its own capability error, + * and an expired one is left to say it expired. + */ + const assertWithinLinkAccess = Effect.fn("PeerForwarding.assertWithinLinkAccess")(function* ( + environmentId: EnvironmentId, + requested: unknown, + ) { + if (!isRuntimeMode(requested)) return; + const link = yield* links.get(environmentId).pipe(Effect.mapError(linkStorageFailure)); + if (Option.isNone(link)) return; + if (DateTime.isLessThanOrEqualTo(link.value.expiresAt, yield* DateTime.now)) return; + const { label, access } = link.value; + if (access === "read-only" || runtimeModeRank(requested) <= runtimeModeRank(access)) return; + return yield* new OrchestratorMcpFailure({ + code: "runtime_mode_escalation_denied", + message: `Linked environment "${label}" was linked with ${access} access, so work there cannot run in ${requested}. Omit runtimeMode to run it at a mode up to ${access}, or link it again with broader access.`, + }); + }); + /** Tool and contract schemas are plain data: they encode and decode without services. */ const forward = Effect.fn("PeerForwarding.forward")(function* ( scope: Scope, @@ -136,6 +167,7 @@ const make = Effect.gen(function* () { params, ).pipe(Effect.mapError(unavailable))) as Record; const { environmentId: _target, ...args } = encoded; + yield* assertWithinLinkAccess(environmentId, args.runtimeMode); // 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({ @@ -242,12 +274,7 @@ const make = Effect.gen(function* () { function* (scope) { yield* callerLimits(scope); yield* requireEnabled; - const listed = yield* links.list.pipe( - Effect.mapError( - (error) => - new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), - ), - ); + const listed = yield* links.list.pipe(Effect.mapError(linkStorageFailure)); return { environmentId: scope.environmentId, links: listed.map((link) => ({ diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index a344f63c523ab..8ec36b7c3ead2 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -921,7 +921,9 @@ it.effect("a task named only by environment inherits the parent's model, within interactionMode: "default", }); - // Asking for more than the link allows is still refused there. + // Asking for more than the link allows is refused, naming the link as + // the limit rather than the parent, and nothing launches there. + yield* Ref.set(thread.launchedWith, null); const broader = yield* a.call("delegate_task", { task: "Run the suite on the box.", target: { environmentId: box.environmentId }, @@ -929,6 +931,13 @@ it.effect("a task named only by environment inherits the parent's model, within clientRequestId: "broader", }); expect(broader.isError).toBe(true); + const [refusal] = broader.content; + const { message } = JSON.parse(refusal?.type === "text" ? refusal.text : "{}"); + expect(message).toContain('"Box"'); + expect(message).toContain("auto-accept-edits"); + expect(message).toContain("full-access"); + expect(message).not.toContain("parent mode"); + expect(yield* Ref.get(thread.launchedWith)).toBeNull(); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), );