Skip to content
Merged
35 changes: 35 additions & 0 deletions apps/server/src/peer/PeerForwarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -691,3 +693,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<Seen>(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)),
);
39 changes: 33 additions & 6 deletions apps/server/src/peer/PeerForwarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ import {
OrchestratorMcpThreadReadResult,
OrchestratorMcpThreadWaitInput,
OrchestratorMcpThreadWaitResult,
type PeerLinkError,
RuntimeMode,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Digest from "effect/crypto/Digest";
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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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* <A>(
scope: Scope,
Expand All @@ -136,6 +167,7 @@ const make = Effect.gen(function* () {
params,
).pipe(Effect.mapError(unavailable))) as Record<string, unknown>;
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({
Expand Down Expand Up @@ -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) => ({
Expand Down
Loading