Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/server/src/peer/PeerForwarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,3 +686,32 @@ 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 refused = yield* 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(refused);
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([]);
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
37 changes: 31 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 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 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,26 @@ 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.
*/
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;
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 +165,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 +272,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
11 changes: 10 additions & 1 deletion apps/server/src/peer/RemoteDelegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,14 +921,23 @@ 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 },
runtimeMode: "full-access",
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)),
);
Expand Down
Loading