Skip to content

Commit 4005759

Browse files
feat(server): link to another environment (#16655)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 888e444 commit 4005759

18 files changed

Lines changed: 1524 additions & 0 deletions

File tree

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
authScopeRequiredResponse,
66
AssetCreateUrlInput,
77
AuthAccessReadScope,
8+
AuthAccessWriteScope,
89
ServerSettingsPatch,
910
ProviderInstanceMutation,
1011
requiredScopesForServerSettingsPatch,
@@ -86,6 +87,9 @@ export const RPC_REQUIRED_SCOPES = {
8687
[WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope,
8788
[WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope,
8889
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
90+
[WS_METHODS.peerLinksList]: AuthAccessReadScope,
91+
[WS_METHODS.peerLinksLink]: AuthAccessWriteScope,
92+
[WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope,
8993
[WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
9094
[WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,
9195
[WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope,

‎apps/server/src/binCli.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as NetService from "@t3tools/shared/Net";
1010
import packageJson from "../package.json" with { type: "json" };
1111
import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts";
1212
import { authCommand } from "./cli/auth.ts";
13+
import { environmentCommand } from "./cli/environment.ts";
1314
import { appCommand } from "./cli/app.ts";
1415
import { browserCommand } from "./cli/browser.ts";
1516
import { connectCommand } from "./cli/connect.ts";
@@ -75,6 +76,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
7576
appCommand,
7677
pairCommand,
7778
authCommand,
79+
environmentCommand,
7880
browserCommand,
7981
projectCommand,
8082
threadCommand,

‎apps/server/src/cli/environment.ts‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { AuthMcpClientAccess, EnvironmentId, type PeerLinkSummary } from "@t3tools/contracts";
2+
import * as Console from "effect/Console";
3+
import * as DateTime from "effect/DateTime";
4+
import * as Effect from "effect/Effect";
5+
import * as Layer from "effect/Layer";
6+
import * as References from "effect/References";
7+
import * as Schema from "effect/Schema";
8+
import * as Argument from "effect/cli/Argument";
9+
import * as Command from "effect/cli/Command";
10+
import * as Flag from "effect/cli/Flag";
11+
import * as GlobalFlag from "effect/cli/GlobalFlag";
12+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
13+
14+
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
15+
import * as ServerConfig from "../config.ts";
16+
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
17+
import * as PeerLinks from "../peer/PeerLinks.ts";
18+
import * as SqlitePersistence from "../persistence/Sqlite.ts";
19+
import { authLocationFlags, type CliAuthLocationFlags, resolveCliAuthConfig } from "./config.ts";
20+
21+
/**
22+
* Links live in this environment's database and secret store, so these
23+
* commands work whether or not its server is running.
24+
*/
25+
const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* <A, E>(
26+
flags: CliAuthLocationFlags,
27+
run: (links: PeerLinks.PeerLinks["Service"]) => Effect.Effect<A, E>,
28+
options?: { readonly quietLogs?: boolean },
29+
) {
30+
const logLevel = yield* GlobalFlag.LogLevel;
31+
const config = yield* resolveCliAuthConfig(flags, logLevel);
32+
return yield* PeerLinks.PeerLinks.pipe(
33+
Effect.flatMap(run),
34+
Effect.provide(
35+
PeerLinks.layer.pipe(
36+
Layer.provide(
37+
Layer.mergeAll(
38+
ServerSecretStore.layer,
39+
SqlitePersistence.layerConfig,
40+
FetchHttpClient.layer,
41+
),
42+
),
43+
Layer.provide(ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer))),
44+
Layer.provide(ServerConfig.layer(config)),
45+
Layer.provide(
46+
Layer.succeed(References.MinimumLogLevel, options?.quietLogs ? "Error" : config.logLevel),
47+
),
48+
),
49+
),
50+
);
51+
});
52+
53+
const jsonFlag = Flag.Boolean("json").pipe(
54+
Flag.withDescription("Emit JSON instead of human-readable output."),
55+
Flag.withDefault(false),
56+
);
57+
58+
const encodeLinks = Schema.encodeEffect(
59+
Schema.fromJsonString(Schema.Array(Schema.toEncoded(Schema.Unknown))),
60+
);
61+
62+
const formatLinks = (links: ReadonlyArray<PeerLinkSummary>) =>
63+
links.length === 0
64+
? "No linked environments.\n"
65+
: `${links
66+
.map(
67+
(link) =>
68+
`${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}`}`,
69+
)
70+
.join("\n\n")}\n`;
71+
72+
const environmentLinkCommand = Command.make("link", {
73+
...authLocationFlags,
74+
url: Argument.String("url").pipe(
75+
Argument.withDescription(
76+
"Where the other environment answers: its T3 Connect, Tailscale, LAN or loopback address.",
77+
),
78+
),
79+
alternateUrl: Flag.String("url").pipe(
80+
Flag.withDescription("Another address for the same environment, tried after the first."),
81+
Flag.atLeast(0),
82+
),
83+
pairingCode: Flag.String("pairing-code").pipe(
84+
Flag.withDescription("A pairing code from the other environment (`t3 pair` there)."),
85+
),
86+
access: Flag.Literals("access", AuthMcpClientAccess.literals).pipe(
87+
Flag.withDescription(
88+
"What this environment's agents may do there: read-only, or the broadest runtime mode.",
89+
),
90+
Flag.withDefault("approval-required" as const),
91+
),
92+
}).pipe(
93+
Command.withDescription(
94+
"Link another T3 Code environment, so agents here can work there. It shows up there under Settings → Connections, where it can be revoked.",
95+
),
96+
Command.withHandler((flags) =>
97+
runWithPeerLinks(flags, (links) =>
98+
Effect.gen(function* () {
99+
const linked = yield* links.link({
100+
url: flags.url,
101+
alternateUrls: flags.alternateUrl,
102+
pairingCode: flags.pairingCode,
103+
access: flags.access,
104+
});
105+
yield* Console.log(
106+
`Linked ${linked.label} (${linked.environmentId}) with ${linked.access} access, until ${DateTime.formatIso(linked.expiresAt)}.\n`,
107+
);
108+
}),
109+
),
110+
),
111+
);
112+
113+
const environmentListCommand = Command.make("list", { ...authLocationFlags, json: jsonFlag }).pipe(
114+
Command.withDescription("List linked environments and whether each answers now."),
115+
Command.withHandler((flags) =>
116+
runWithPeerLinks(
117+
flags,
118+
(links) =>
119+
Effect.gen(function* () {
120+
const listed = yield* links.list;
121+
yield* Console.log(flags.json ? `${yield* encodeLinks(listed)}\n` : formatLinks(listed));
122+
}),
123+
{ quietLogs: flags.json },
124+
),
125+
),
126+
);
127+
128+
const environmentUnlinkCommand = Command.make("unlink", {
129+
...authLocationFlags,
130+
environmentId: Argument.String("environment-id").pipe(
131+
Argument.withDescription("The linked environment's id, from `t3 environment list`."),
132+
),
133+
}).pipe(
134+
Command.withDescription(
135+
"Forget a linked environment here. Revoke it in that environment's Connections to end its session there too.",
136+
),
137+
Command.withHandler((flags) =>
138+
runWithPeerLinks(flags, (links) =>
139+
Effect.gen(function* () {
140+
const removed = yield* links.unlink(EnvironmentId.make(flags.environmentId));
141+
yield* Console.log(
142+
removed
143+
? `Forgot ${flags.environmentId}. Revoke it in that environment's Connections to end its session there.\n`
144+
: `No linked environment ${flags.environmentId}.\n`,
145+
);
146+
}),
147+
),
148+
),
149+
);
150+
151+
export const environmentCommand = Command.make("environment").pipe(
152+
Command.withDescription("Link other T3 Code environments, so agents here can work there."),
153+
Command.withSubcommands([
154+
environmentLinkCommand,
155+
environmentListCommand,
156+
environmentUnlinkCommand,
157+
]),
158+
);

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ export const make = Effect.gen(function* () {
265265
: {}),
266266
...(desktopAppUpdate ? { desktopAppUpdate: true } : {}),
267267
serverBrowser: true,
268+
peerLinks: true,
269+
mcpModeLimitHeader: true,
268270
},
269271
};
270272

‎apps/server/src/observability/RpcInstrumentation.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ const RPC_AGGREGATES = {
5959
[WS_METHODS.serverGetStorageCleanupReport]: "server",
6060
[WS_METHODS.serverGetDatabaseStorage]: "server",
6161
[WS_METHODS.serverGetSettings]: "server",
62+
[WS_METHODS.peerLinksList]: "peerLinks",
63+
[WS_METHODS.peerLinksLink]: "peerLinks",
64+
[WS_METHODS.peerLinksUnlink]: "peerLinks",
6265
[WS_METHODS.serverUpdateSettings]: "server",
6366
[WS_METHODS.serverSearchAcpRegistry]: "server",
6467
[WS_METHODS.serverPrepareAcpRegistryAgent]: "server",

0 commit comments

Comments
 (0)