1import * as DocumentText from "../provider/DocumentText.ts";
2import { NodeHttpServer } from "@effect/platform-node";
3import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import {
6 EnvironmentId,
7 type OrchestrationV2Run,
8 type OrchestrationV2ThreadShell,
9 type SourceControlCloneRepositoryInput,
10 ProjectId,
11 ProviderInstanceId,
12 RunId,
13 ThreadId,
14} from "@t3tools/contracts";
15import { expect, it } from "@effect/vitest";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Ref from "effect/Ref";
21import { McpSchema, McpServer } from "effect/ai";
22
23import * as ServerConfig from "../config.ts";
24import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts";
25import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
26import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts";
27import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
28import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts";
29import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
30import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
31import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
32import * as ProjectService from "../project/ProjectService.ts";
33import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
34import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
35import * as SecretRequests from "../secrets/SecretRequests.ts";
36import * as ServerSettings from "../serverSettings.ts";
37import * as RemoteDelegation from "./RemoteDelegation.ts";
38import * as McpHttpServer from "../mcp/McpHttpServer.ts";
39import * as McpInvocationContext from "../mcp/McpInvocationContext.ts";
40import { liveThreadShell } from "../mcp/McpToolAccess.testkit.ts";
41import * as PeerForwarding from "./PeerForwarding.ts";
42import * as PeerLinks from "./PeerLinks.ts";
43import * as PeerLinkRequests from "./PeerLinkRequests.ts";
44import { descriptorOf, layerLinkingEnvironment, linkTo, servePeer } from "./PeerLinks.testkit.ts";
45
46const laptop = descriptorOf("environment-laptop", "Laptop");
47const box = descriptorOf("environment-box", "Box");
48const boxProject = ProjectId.make("project:box");
49const boxThread = ThreadId.make("thread:box-auto");
50const boxRun = RunId.make("run:box-auto");
51
52/** What B's thread service was asked to do. */
53interface Seen {
54 readonly sends: ReadonlyArray<ThreadManagement.ThreadManagementSendInput>;
55 readonly waits: ReadonlyArray<ThreadManagement.ThreadManagementWaitInput>;
56 readonly launches: ReadonlyArray<ThreadLaunch.ThreadLaunchInput>;
57 readonly projects: ReadonlyArray<ProjectService.ProjectCreateInput>;
58 readonly clones: ReadonlyArray<SourceControlCloneRepositoryInput>;
59 /** Commands dispatched and delegated-task stops, as `<type> <threadId>`. */
60 readonly stops: ReadonlyArray<string>;
61}
62const nothingSeen: Seen = {
63 sends: [],
64 waits: [],
65 launches: [],
66 projects: [],
67 clones: [],
68 stops: [],
69};
70
71/** The box user's own thread, which no link started. */
72const usersThread = ThreadId.make("thread:box-users-own");
73
74/**
75 * B's threads: one `auto` thread in `boxProject` that the laptop's link
76 * started, whose run finishes on the third wait, and the box user's own.
77 * Every send and wait is recorded.
78 */
79const boxThreads = (seen: Ref.Ref<Seen>, linkSession: Ref.Ref<string>) => {
80 const base = { ...liveThreadShell(boxThread, { runtimeMode: "auto" }), projectId: boxProject };
81 const linked = Ref.get(linkSession).pipe(
82 Effect.map((sessionId) => ({ ...base, linkOrigin: { sessionId, label: "T3 Code · Laptop" } })),
83 );
84 const users = { ...liveThreadShell(usersThread, { runtimeMode: "auto" }), projectId: boxProject };
85 const run = (status: OrchestrationV2Run["status"]) =>
86 ({ id: boxRun, status }) as unknown as OrchestrationV2Run;
87 const shellOf = (threadId: ThreadId): Effect.Effect<OrchestrationV2ThreadShell | null> =>
88 threadId === boxThread ? linked : Effect.succeed(threadId === usersThread ? users : null);
89 const projectionOf = (threadId: ThreadId) =>
90 shellOf(threadId).pipe(
91 Effect.map((thread) => ({ thread, runs: [run("running")], runtimeRequests: [] }) as never),
92 );
93 return Layer.mock(ThreadManagement.ThreadManagementService)({
94 getThreadShell: shellOf,
95 getProjectThreadRecords: (input) => projectionOf(input.threadId),
96 getThreadRecords: (threadId) => projectionOf(threadId),
97 listProjectThreads: () => linked.pipe(Effect.map((shell) => [shell] as never)),
98 sendToThread: (input) =>
99 Ref.update(seen, (current) => ({ ...current, sends: [...current.sends, input] })).pipe(
100 Effect.as({ run: run("running"), delivery: "started" } as never),
101 ),
102 dispatch: (command) =>
103 Ref.update(seen, (current) => ({
104 ...current,
105 stops: [
106 ...current.stops,
107 `${command.type} ${"threadId" in command ? command.threadId : ""}`,
108 ],
109 })).pipe(
110 // The stop asks the provider to interrupt the thread's running turn.
111 Effect.as({
112 sequence: 0,
113 storedEvents: [
114 {
115 sequence: 1,
116 commandId: command.commandId,
117 event: {
118 type: "turn-item.updated",
119 runId: boxRun,
120 payload: {
121 type: "run_interrupt_request",
122 providerTurnId: "provider-turn:box-auto",
123 },
124 },
125 },
126 ],
127 } as never),
128 ),
129 stopDelegatedTasks: (input) =>
130 Ref.update(seen, (current) => ({
131 ...current,
132 stops: [...current.stops, `delegated-tasks.stop ${input.threadId}`],
133 })),
134 waitForThread: (input) =>
135 Ref.updateAndGet(seen, (current) => ({ ...current, waits: [...current.waits, input] })).pipe(
136 Effect.map(({ waits }) => ({
137 threadId: input.threadId,
138 run: run(waits.length < 3 ? "running" : "completed"),
139 timedOut: waits.length < 3,
140 })),
141 ),
142 });
143};
144
145/** B launches whatever it is asked to, and records it. */
146const boxLaunches = (seen: Ref.Ref<Seen>) =>
147 Layer.mock(ThreadLaunch.ThreadLaunchService)({
148 launch: (input) =>
149 Ref.update(seen, (current) => ({ ...current, launches: [...current.launches, input] })).pipe(
150 Effect.as({
151 threadId: input.threadId,
152 projection: {
153 thread: {
154 id: input.threadId,
155 projectId: input.projectId,
156 modelSelection: input.modelSelection,
157 title: input.title,
158 },
159 runs: [],
160 },
161 resumed: false,
162 } as unknown as ThreadLaunch.ThreadLaunchResult),
163 ),
164 });
165
166/** B clones and registers whatever it is asked to, and records it. */
167const boxProjects = (seen: Ref.Ref<Seen>) =>
168 Layer.merge(
169 Layer.mock(ProjectService.ProjectService)({
170 getById: (id) =>
171 Effect.succeed(
172 id === boxProject
173 ? Option.some({
174 id,
175 title: "t3code",
176 workspaceRoot: "/box/t3code",
177 defaultModelSelection: null,
178 scripts: [],
179 createdAt: "2026-10-18T00:00:00.000Z",
180 updatedAt: "2026-10-18T00:00:00.000Z",
181 deletedAt: null,
182 })
183 : Option.none(),
184 ),
185 create: (input) =>
186 Ref.update(seen, (current) => ({
187 ...current,
188 projects: [...current.projects, input],
189 })).pipe(
190 Effect.as({
191 id: input.projectId,
192 title: input.title,
193 workspaceRoot: input.workspaceRoot,
194 defaultModelSelection: null,
195 scripts: [],
196 createdAt: "2026-10-18T00:00:00.000Z",
197 updatedAt: "2026-10-18T00:00:00.000Z",
198 deletedAt: null,
199 }),
200 ),
201 }),
202 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
203 cloneRepository: (input) =>
204 Ref.update(seen, (current) => ({ ...current, clones: [...current.clones, input] })).pipe(
205 Effect.as({
206 cwd: input.destinationPath,
207 remoteUrl: "https://github.com/pingdotgg/t3code.git",
208 repository: null,
209 }),
210 ),
211 }),
212 );
213
214/**
215 * The orchestrator and project toolkits on B's real `/mcp`, behind its real
216 * OAuth. `linkSession` is filled in once A links, so B's fixture thread reads
217 * as one that link started.
218 */
219const serveBox = (seen: Ref.Ref<Seen>, linkSession: Ref.Ref<string>) =>
220 servePeer(
221 box,
222 Layer.mergeAll(
223 McpHttpServer.layerOrchestratorToolkit,
224 McpHttpServer.layerProjectRegistration,
225 McpHttpServer.layerThreadToolkit,
226 ).pipe(
227 Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))),
228 Layer.provide(NodeCrypto.layer),
229 Layer.provide(boxThreads(seen, linkSession)),
230 Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
231 Layer.provide(boxLaunches(seen)),
232 Layer.provide(
233 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
234 ),
235 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
236 Layer.provide(boxProjects(seen)),
237 Layer.provide(
238 ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-box-" }).pipe(
239 Layer.provide(NodeServices.layer),
240 ),
241 ),
242 Layer.provide(NodeServices.layer),
243 Layer.provide(
244 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
245 ),
246 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
247 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
248 Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})),
249 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
250 Layer.provide(ServerSettings.layerTest()),
251 Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
252 Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})),
253 Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
254 Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
255 ),
256 );
257
258const mcpClient = McpSchema.McpServerClient.of({
259 clientId: 1,
260 protocolVersion: "2025-06-18",
261 clientCapabilities: {},
262 clientInfo: { name: "peer-forwarding-test", version: "1" },
263 initializePayload: {
264 protocolVersion: "2025-06-18",
265 capabilities: {},
266 clientInfo: { name: "peer-forwarding-test", version: "1" },
267 },
268 getClient: Effect.die("unused"),
269});
270
271/**
272 * Environment A: its own orchestrator toolkit with forwarding, and a thread
273 * caller whose modes the test picks. Its local threads never matter here.
274 */
275const makeLaptop = Effect.gen(function* () {
276 const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build);
277 const layerHere = Layer.mergeAll(
278 McpHttpServer.layerOrchestratorToolkit,
279 McpHttpServer.layerProjectRegistration,
280 McpHttpServer.layerThreadToolkit,
281 ).pipe(
282 Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))),
283 Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
284 Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
285 Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})),
286 Layer.provide(
287 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
288 ),
289 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
290 Layer.provide(Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({})),
291 Layer.provide(
292 ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-forwarding-laptop-" }).pipe(
293 Layer.provide(NodeServices.layer),
294 ),
295 ),
296 Layer.provide(NodeServices.layer),
297 Layer.provide(PeerForwarding.layer),
298 Layer.provide(Layer.succeedContext(linking)),
299 Layer.provide(NodeCrypto.layer),
300 Layer.provideMerge(
301 Layer.mock(ThreadManagement.ThreadManagementService)({
302 getThreadShell: (threadId) =>
303 Effect.succeed(
304 threadId === ThreadId.make("thread:laptop-from-desk")
305 ? {
306 ...liveThreadShell(threadId, { runtimeMode: "full-access" }),
307 // Work another environment's link started on this laptop.
308 linkOrigin: { sessionId: "session:desk", label: "T3 Code · Desk" },
309 }
310 : liveThreadShell(
311 threadId,
312 threadId === ThreadId.make("thread:laptop-plan")
313 ? { runtimeMode: "full-access", interactionMode: "plan" }
314 : { runtimeMode: "full-access" },
315 ),
316 ),
317 getProjectThreadRecords: ({ threadId }) =>
318 Effect.succeed({
319 thread: liveThreadShell(threadId, { runtimeMode: "full-access" }),
320 } as never),
321 }),
322 ),
323 Layer.provide(
324 Layer.mergeAll(
325 Layer.mock(ProviderRegistry.ProviderRegistry)({}),
326 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),
327 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
328 Layer.mock(ProjectService.ProjectService)({}),
329 Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({}),
330 Layer.mock(SecretRequests.SecretRequests)({}),
331 ServerSettings.layerTest(),
332 // No thread here delegated to the box, so a send there is no follow-up.
333 Layer.mock(RemoteDelegation.RemoteDelegation)({
334 followUp: (_scope, _environmentId, _request, sent) => Effect.succeed(sent),
335 }),
336 Layer.mock(PeerLinkRequests.PeerLinkRequests)({}),
337 ),
338 ),
339 Layer.fresh,
340 );
341 const here = yield* Layer.build(layerHere);
342 const server = Context.get(here, McpServer.McpServer);
343 const scopeOf = (
344 caller: string,
345 capabilities: ReadonlySet<McpInvocationContext.McpCapability> = new Set(["orchestration"]),
346 ): McpInvocationContext.McpInvocationScope => ({
347 environmentId: laptop.environmentId,
348 requestNamespace: `provider-session:${caller}`,
349 thread: {
350 threadId: ThreadId.make(caller),
351 providerSessionId: `provider-session:${caller}`,
352 providerInstanceId: ProviderInstanceId.make("codex"),
353 },
354 client: undefined,
355 capabilities,
356 issuedAt: 1,
357 });
358 const call = (
359 caller: string,
360 name: string,
361 args: Record<string, unknown>,
362 capabilities?: ReadonlySet<McpInvocationContext.McpCapability>,
363 ) =>
364 server
365 .callTool({ name, arguments: args })
366 .pipe(
367 Effect.provideService(
368 McpInvocationContext.McpInvocationContext,
369 scopeOf(caller, capabilities),
370 ),
371 Effect.provideService(McpSchema.McpServerClient, mcpClient),
372 );
373 return { links: Context.get(linking, PeerLinks.PeerLinks), call };
374});
375
376const failureOf = (result: McpSchema.CallToolResult) => {
377 const text = result.content[0];
378 return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined;
379};
380
381it.effect("an agent here works on a linked environment's threads within its own modes", () =>
382 Effect.scoped(
383 Effect.gen(function* () {
384 const seen = yield* Ref.make<Seen>(nothingSeen);
385 const linkSession = yield* Ref.make("");
386 const b = yield* serveBox(seen, linkSession);
387 const a = yield* makeLaptop;
388 yield* linkTo(a.links, b, "auto");
389 yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId);
390
391 const listed = yield* a.call("thread:laptop-full", "t3_environment_links", {});
392 expect(listed.structuredContent).toMatchObject({
393 environmentId: laptop.environmentId,
394 links: [
395 { environmentId: box.environmentId, label: "Box", status: "reachable", access: "auto" },
396 ],
397 });
398
399 const threads = yield* a.call("thread:laptop-full", "t3_thread_list", {
400 environmentId: box.environmentId,
401 projectId: boxProject,
402 });
403 expect(threads.isError).toBe(false);
404 expect(threads.structuredContent).toMatchObject({
405 projectId: boxProject,
406 threads: [{ threadId: boxThread, runtimeMode: "auto" }],
407 });
408
409 // A full-access agent here is held to the link's `auto` there, which
410 // the box's `auto` thread is within.
411 const sent = yield* a.call("thread:laptop-full", "t3_thread_send", {
412 environmentId: box.environmentId,
413 threadId: boxThread,
414 message: "Carry on there.",
415 clientRequestId: "send-1",
416 });
417 expect(sent.isError).toBe(false);
418 expect(sent.structuredContent).toMatchObject({ threadId: boxThread, delivery: "started" });
419 // The box records which thread here sent it.
420 expect((yield* Ref.get(seen)).sends[0]?.linkedSender).toEqual({
421 environmentId: laptop.environmentId,
422 threadId: "thread:laptop-full",
423 title: "thread:laptop-full",
424 });
425
426 // A plan-mode agent here may not steer that thread there: the box
427 // refuses it, because the call carries the agent's own modes.
428 const planned = yield* a.call("thread:laptop-plan", "t3_thread_send", {
429 environmentId: box.environmentId,
430 threadId: boxThread,
431 message: "Change course.",
432 });
433 expect(failureOf(planned)).toMatchObject({ code: "interaction_mode_escalation_denied" });
434 expect((yield* Ref.get(seen)).sends).toHaveLength(1);
435
436 // stop=true stops the thread there as its Stop button does: the whole
437 // thread and the tasks it delegated, not just the running turn.
438 const stopped = yield* a.call("thread:laptop-full", "t3_thread_interrupt", {
439 environmentId: box.environmentId,
440 threadId: boxThread,
441 stop: true,
442 clientRequestId: "stop-1",
443 });
444 expect(stopped.structuredContent).toMatchObject({
445 threadId: boxThread,
446 runId: boxRun,
447 status: "interrupt_requested",
448 });
449 expect((yield* Ref.get(seen)).stops).toEqual([
450 `thread.stop ${boxThread}`,
451 `delegated-tasks.stop ${boxThread}`,
452 ]);
453 }),
454 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
455);
456
457it.effect("keeps a forwarded wait short and its retries scoped to the caller", () =>
458 Effect.scoped(
459 Effect.gen(function* () {
460 const seen = yield* Ref.make<Seen>(nothingSeen);
461 const linkSession = yield* Ref.make("");
462 const b = yield* serveBox(seen, linkSession);
463 const a = yield* makeLaptop;
464 yield* linkTo(a.links, b, "auto");
465 yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId);
466
467 const waited = yield* a.call("thread:laptop-full", "t3_thread_wait", {
468 environmentId: box.environmentId,
469 threadId: boxThread,
470 timeoutMs: 600_000,
471 });
472 expect(waited.structuredContent).toEqual({
473 threadId: boxThread,
474 runId: boxRun,
475 status: "completed",
476 timedOut: false,
477 });
478 // Three pieces of at most 50 s each, the later ones pinned to the run
479 // the first one found.
480 expect(
481 (yield* Ref.get(seen)).waits.map(({ timeoutMs, runId }) => [timeoutMs, runId]),
482 ).toEqual([
483 [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, undefined],
484 [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun],
485 [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun],
486 ]);
487
488 const send = (caller: string, clientRequestId: string) =>
489 a.call(caller, "t3_thread_send", {
490 environmentId: box.environmentId,
491 threadId: boxThread,
492 message: "Once.",
493 clientRequestId,
494 });
495 yield* send("thread:laptop-full", "same-key");
496 yield* send("thread:laptop-full", "same-key");
497 yield* send("thread:laptop-other", "same-key");
498 const [first, retried, other] = (yield* Ref.get(seen)).sends.map((input) => input.commandId);
499 // The box sees every caller here as the one link, so the key is scoped
500 // to its caller before it leaves: a retry matches, another agent's same
501 // key does not.
502 expect(retried).toBe(first);
503 expect(other).not.toBe(first);
504 expect(first).not.toContain("same-key");
505 }),
506 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
507);
508
509it.effect("launches in a linked environment with the caller's modes, retry-safe", () =>
510 Effect.scoped(
511 Effect.gen(function* () {
512 const seen = yield* Ref.make<Seen>(nothingSeen);
513 const linkSession = yield* Ref.make("");
514 const b = yield* serveBox(seen, linkSession);
515 const a = yield* makeLaptop;
516 yield* linkTo(a.links, b, "full-access");
517 yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId);
518 const codex = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" };
519 const launch = (caller: string, args: Record<string, unknown> = {}) =>
520 a.call(caller, "t3_thread_launch", {
521 environmentId: box.environmentId,
522 projectId: boxProject,
523 title: "Run the suite there",
524 modelSelection: codex,
525 message: "Run the suite.",
526 clientRequestId: "launch-1",
527 ...args,
528 });
529
530 const first = yield* launch("thread:laptop-plan");
531 expect(first.isError).toBe(false);
532 expect(first.structuredContent).toMatchObject({
533 projectId: boxProject,
534 modelSelection: codex,
535 });
536 const retried = yield* launch("thread:laptop-plan");
537 const [launched, relaunched] = (yield* Ref.get(seen)).launches;
538 // The thread there runs with the caller's own modes here, below the
539 // link's full access, and a retry asks for the same thread.
540 expect(launched).toMatchObject({ runtimeMode: "full-access", interactionMode: "plan" });
541 expect(relaunched?.threadId).toBe(launched?.threadId);
542 expect(retried.structuredContent).toEqual(first.structuredContent);
543
544 // An escalation past the caller's own modes is refused here.
545 const escalated = yield* launch("thread:laptop-plan", { interactionMode: "default" });
546 expect(failureOf(escalated)).toMatchObject({ code: "interaction_mode_escalation_denied" });
547 // A pending upload lives here, so it cannot go along.
548 yield* Ref.set(b.bearers, []);
549 const withUpload = yield* launch("thread:laptop-full", {
550 clientRequestId: "launch-2",
551 attachments: [
552 { type: "image", id: "upload-1", name: "a.png", mimeType: "image/png", sizeBytes: 1 },
553 ],
554 });
555 expect(failureOf(withUpload)).toMatchObject({ code: "invalid_request" });
556 expect(yield* Ref.get(b.bearers)).toEqual([]);
557 expect((yield* Ref.get(seen)).launches).toHaveLength(2);
558 }),
559 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
560);
561
562it.effect("brings a repository to a linked environment, only through full access", () =>
563 Effect.scoped(
564 Effect.gen(function* () {
565 const seen = yield* Ref.make<Seen>(nothingSeen);
566 const b = yield* serveBox(seen, yield* Ref.make(""));
567 const a = yield* makeLaptop;
568 const clone = { repository: "pingdotgg/t3code", destinationPath: "/home/box/t3code" };
569 const register = { title: "t3code", workspaceRoot: "/home/box/t3code" };
570
571 yield* linkTo(a.links, b, "full-access");
572 const cloned = yield* a.call("thread:laptop-full", "t3_project_clone", {
573 environmentId: box.environmentId,
574 ...clone,
575 });
576 expect(cloned.structuredContent).toMatchObject({ cwd: clone.destinationPath });
577 const created = yield* a.call("thread:laptop-full", "t3_project_create", {
578 environmentId: box.environmentId,
579 ...register,
580 });
581 expect(created.structuredContent).toMatchObject(register);
582 expect((yield* Ref.get(seen)).clones).toEqual([clone]);
583 expect((yield* Ref.get(seen)).projects).toMatchObject([register]);
584
585 // A plan-mode agent here may not, and nothing leaves.
586 yield* Ref.set(b.bearers, []);
587 const planned = yield* a.call("thread:laptop-plan", "t3_project_create", {
588 environmentId: box.environmentId,
589 ...register,
590 });
591 expect(failureOf(planned)).toMatchObject({ code: "capability_denied" });
592 expect(yield* Ref.get(b.bearers)).toEqual([]);
593
594 // Nor may a full-access agent through a link with less: the box holds it.
595 yield* linkTo(a.links, b, "auto");
596 for (const [name, args] of [
597 ["t3_project_clone", clone],
598 ["t3_project_create", register],
599 ] as const) {
600 const refused = yield* a.call("thread:laptop-full", name, {
601 environmentId: box.environmentId,
602 ...args,
603 });
604 expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" });
605 }
606 expect((yield* Ref.get(seen)).clones).toHaveLength(1);
607 expect((yield* Ref.get(seen)).projects).toHaveLength(1);
608 }),
609 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
610);
611
612it.effect("runs any tool that takes a linked environment there", () =>
613 Effect.scoped(
614 Effect.gen(function* () {
615 const seen = yield* Ref.make<Seen>(nothingSeen);
616 const linkSession = yield* Ref.make("");
617 const b = yield* serveBox(seen, linkSession);
618 const a = yield* makeLaptop;
619 yield* linkTo(a.links, b, "auto");
620 yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId);
621
622 const there = yield* a.call("thread:laptop-full", "t3_thread_configure", {
623 environmentId: box.environmentId,
624 threadId: boxThread,
625 });
626 expect(there.structuredContent).toMatchObject({ threadId: boxThread, runtimeMode: "auto" });
627 const archived = yield* a.call("thread:laptop-full", "t3_thread_organize", {
628 environmentId: box.environmentId,
629 threadId: boxThread,
630 action: "archive",
631 });
632 expect(archived.isError).toBe(false);
633 expect((yield* Ref.get(seen)).stops).toEqual([`thread.archive ${boxThread}`]);
634
635 // This environment's own id keeps the call here.
636 const own = yield* a.call("thread:laptop-full", "t3_thread_configure", {
637 environmentId: laptop.environmentId,
638 });
639 expect(own.structuredContent).toMatchObject({
640 threadId: "thread:laptop-full",
641 runtimeMode: "full-access",
642 });
643 }),
644 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
645);
646
647it.effect("refuses what a linked environment cannot take before anything leaves", () =>
648 Effect.scoped(
649 Effect.gen(function* () {
650 const seen = yield* Ref.make<Seen>(nothingSeen);
651 const linkSession = yield* Ref.make("");
652 const b = yield* serveBox(seen, linkSession);
653 const a = yield* makeLaptop;
654 yield* linkTo(a.links, b, "auto");
655 yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId);
656 yield* Ref.set(b.bearers, []);
657
658 const unknown = yield* a.call("thread:laptop-full", "t3_thread_read", {
659 environmentId: EnvironmentId.make("environment-elsewhere"),
660 threadId: boxThread,
661 });
662 expect(failureOf(unknown)).toMatchObject({ code: "invalid_request" });
663 expect(yield* Ref.get(b.bearers)).toEqual([]);
664
665 // The box sees only the link's credential, so one that may not control
666 // threads here may not reach them there either.
667 const previewOnly = new Set<McpInvocationContext.McpCapability>(["preview"]);
668 for (const [name, args] of [
669 ["t3_environment_links", {}],
670 ["t3_project_list", { environmentId: box.environmentId }],
671 ["t3_thread_list", { environmentId: box.environmentId, projectId: boxProject }],
672 ["t3_thread_read", { environmentId: box.environmentId, threadId: boxThread }],
673 ["t3_thread_wait", { environmentId: box.environmentId, threadId: boxThread }],
674 [
675 "t3_thread_send",
676 { environmentId: box.environmentId, threadId: boxThread, message: "Carry on there." },
677 ],
678 [
679 "t3_thread_organize",
680 { environmentId: box.environmentId, threadId: boxThread, action: "archive" },
681 ],
682 [
683 "t3_thread_launch",
684 {
685 environmentId: box.environmentId,
686 projectId: boxProject,
687 title: "Start there",
688 modelSelection: { instanceId: "codex", model: "gpt-5" },
689 message: "Start there.",
690 },
691 ],
692 ] as const) {
693 const refused = yield* a.call("thread:laptop-full", name, args, previewOnly);
694 expect(failureOf(refused), name).toMatchObject({ code: "capability_denied" });
695 }
696 expect(yield* Ref.get(b.bearers)).toEqual([]);
697
698 // Work a desk's link started here never uses this laptop's links: the
699 // box would see the laptop, not the desk, so its fence could not hold.
700 for (const [name, args] of [
701 ["t3_thread_read", { environmentId: box.environmentId, threadId: boxThread }],
702 [
703 "t3_thread_send",
704 { environmentId: box.environmentId, threadId: boxThread, message: "From the desk." },
705 ],
706 [
707 "t3_thread_launch",
708 { environmentId: box.environmentId, projectId: boxProject, title: "x" },
709 ],
710 ] as const) {
711 const relayed = yield* a.call("thread:laptop-from-desk", name, args);
712 expect(failureOf(relayed), name).toMatchObject({ code: "capability_denied" });
713 }
714 expect(yield* Ref.get(b.bearers)).toEqual([]);
715 }),
716 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
717);