Skip to content

Commit 8b14660

Browse files
feat(server,web,mobile): an agent sets up a link to another environment (#17129)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 594f1ce commit 8b14660

62 files changed

Lines changed: 4642 additions & 122 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/mobile/src/features/threads/LinkRequestCard.tsx‎

Lines changed: 514 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/mobile/src/features/threads/ThreadFeed.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ThreadContextDivider } from "./thread-context-divider";
1818
import { ThreadHandoffDivider, ThreadHandoffRow } from "./thread-handoff-row";
1919
import { GuestBubbleTint, GuestMessageName } from "./guestMessageStyle";
2020
import { ThreadGuestPendingMessage, useThreadGuestShare } from "./ThreadGuestShareBar";
21+
import { LinkRequestCard } from "./LinkRequestCard";
2122
import { SecretRequestCard } from "./SecretRequestCard";
2223
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
2324
import {
@@ -192,6 +193,7 @@ import {
192193
threadFeedRunIsUnsettled,
193194
isContextCompactionActivityGroup,
194195
isContextHandoffActivityGroup,
196+
isLinkRequestActivityGroup,
195197
isSecretRequestActivityGroup,
196198
type ThreadFeedEntry,
197199
type ThreadFeedLatestRun,
@@ -1419,6 +1421,16 @@ function renderFeedEntry(
14191421
);
14201422
}
14211423

1424+
if (entry.type === "activity-group" && isLinkRequestActivityGroup(entry)) {
1425+
return (
1426+
<LinkRequestCard
1427+
environmentId={props.environmentId}
1428+
projectedItem={entry.activities[0]!.projectedItem}
1429+
iconColor={iconSubtleColor}
1430+
/>
1431+
);
1432+
}
1433+
14221434
if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
14231435
const label = entry.activities[0]!.summary;
14241436
const item = entry.activities[0]!.projectedItem.item;

‎apps/mobile/src/lib/filePreview.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ vi.mock("../features/threads/thread-context-divider", () => ({}));
271271
vi.mock("../features/threads/thread-handoff-row", () => ({}));
272272
vi.mock("../features/threads/ThreadGuestShareBar", () => ({ useThreadGuestShare: () => null }));
273273
vi.mock("../features/threads/SecretRequestCard", () => ({}));
274+
vi.mock("../features/threads/LinkRequestCard", () => ({ LinkRequestCard: () => null }));
274275
vi.mock("../features/threads/worktree-setup-card", () => ({}));
275276
vi.mock("../features/threads/CopyTextButton", () => ({ CopyTextButton: () => null }));
276277
vi.mock("../features/threads/thread-work-log", () => ({ THREAD_DISCLOSURE_TRANSITION_MS: 100 }));

‎apps/mobile/src/lib/threadActivity.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,12 @@ export function isSecretRequestActivityGroup(entry: ThreadFeedActivityGroup): bo
408408
);
409409
}
410410

411+
export function isLinkRequestActivityGroup(entry: ThreadFeedActivityGroup): boolean {
412+
return (
413+
entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "link_request"
414+
);
415+
}
416+
411417
function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
412418
return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
413419
}
@@ -516,7 +522,8 @@ function itemIsProminent(item: OrchestrationV2TurnItem): boolean {
516522
item.type === "thread_created" ||
517523
item.type === "system_notice" ||
518524
item.type === "proposed_plan" ||
519-
item.type === "secret_request"
525+
item.type === "secret_request" ||
526+
item.type === "link_request"
520527
);
521528
}
522529

@@ -638,6 +645,8 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
638645
return "zap";
639646
case "secret_request":
640647
return "lock";
648+
case "link_request":
649+
return "globe";
641650
}
642651
}
643652

@@ -697,6 +706,8 @@ function itemSummary(
697706
return "Thread created";
698707
case "secret_request":
699708
return item.label;
709+
case "link_request":
710+
return `Link ${item.label ?? item.url ?? "an environment"}`;
700711
case "dynamic_tool": {
701712
const classified = classifyToolActivity({
702713
itemType: "dynamic_tool_call",
@@ -755,6 +766,7 @@ function itemPreview(item: OrchestrationV2TurnItem): string | null {
755766
case "thread_created":
756767
return item.targetThreadId;
757768
case "secret_request":
769+
case "link_request":
758770
return item.reason || null;
759771
case "subagent":
760772
return item.result ?? item.progress ?? item.prompt;

‎apps/mobile/src/state/peerLinks.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,48 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import { linkableMachines } from "@t3tools/client-runtime/link-request";
13
import { createPeerLinkEnvironmentAtoms } from "@t3tools/client-runtime/state/peerLinks";
4+
import type { EnvironmentId } from "@t3tools/contracts";
5+
import { useEffect, useEffectEvent, useMemo } from "react";
26

37
import { connectionAtomRuntime } from "../connection/runtime";
8+
import { useEnvironments } from "./environments";
9+
import { relayEnvironmentDiscovery } from "./relay";
10+
import { useEnvironmentQuery } from "./query";
11+
import { useAtomCommand } from "./use-atom-command";
412

513
export const peerLinkEnvironment = createPeerLinkEnvironmentAtoms(connectionAtomRuntime);
14+
15+
/**
16+
* The user's other machines, for picking one to link `environmentId` to,
17+
* including those only on their T3 Connect account. Reads that environment's
18+
* links only while `enabled`, since listing them asks every linked machine
19+
* whether it answers.
20+
*/
21+
export function useLinkableMachines(environmentId: EnvironmentId, enabled: boolean) {
22+
const { environments } = useEnvironments();
23+
const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom);
24+
const refreshDiscovery = useAtomCommand(relayEnvironmentDiscovery.refresh, {
25+
reportFailure: false,
26+
});
27+
// Connect hosts are listed only once something asked for them, so ask on open.
28+
const discoverIfUnasked = useEffectEvent(() => {
29+
if (discovery.environments.size === 0 && !discovery.refreshing) void refreshDiscovery();
30+
});
31+
// oxlint-disable-next-line t3code/react-effect-budget -- discovery follows the picker's visibility, which a card's mount or a dialog's open sets
32+
useEffect(() => {
33+
if (enabled) discoverIfUnasked();
34+
}, [enabled]);
35+
const links = useEnvironmentQuery(
36+
enabled ? peerLinkEnvironment.list({ environmentId, input: {} }) : null,
37+
);
38+
return useMemo(
39+
() =>
40+
linkableMachines({
41+
environments,
42+
threadEnvironmentId: environmentId,
43+
links: links.data?.links ?? [],
44+
connect: discovery.environments,
45+
}),
46+
[discovery.environments, environmentId, environments, links.data],
47+
);
48+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createPeerPairingCodeCommand } from "@t3tools/client-runtime/state/peerPairingCode";
2+
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3+
import type { AuthMcpClientAccess, EnvironmentId } from "@t3tools/contracts";
4+
import * as Result from "effect/Result";
5+
6+
import { connectionAtomRuntime } from "../connection/runtime";
7+
import { appAtomRegistry } from "./atom-registry";
8+
9+
const mintPairingCode = createPeerPairingCodeCommand(connectionAtomRuntime);
10+
11+
/**
12+
* A fresh pairing code minted on `environmentId` with this client's session
13+
* there, for linking with `access`. Fails with `PeerPairingCodeError` when the
14+
* session may not create one.
15+
*/
16+
export async function mintPairingCodeOn(
17+
environmentId: EnvironmentId,
18+
access: AuthMcpClientAccess,
19+
): Promise<Result.Result<string, unknown>> {
20+
const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: access });
21+
return result._tag === "Success"
22+
? Result.succeed(result.value)
23+
: Result.fail(squashAtomCommandFailure(result));
24+
}

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

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@ import {
22
AuthAccessTokenType,
33
AuthAccessWriteScope,
44
AuthOrchestrationOperateScope,
5-
AuthOrchestrationReadScope,
6-
AuthSettingsWriteScope,
7-
AuthSourceControlWriteScope,
85
AuthStandardClientScopes,
96
type AuthAccessTokenResult,
107
type AuthBrowserSessionResult,
@@ -24,6 +21,7 @@ import {
2421
type AuthWebSocketTicketUse,
2522
DpopFailureReason,
2623
type DpopFailureReason as DpopFailureReasonType,
24+
mcpClientScopes,
2725
} from "@t3tools/contracts";
2826
import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
2927
import * as Context from "effect/Context";
@@ -74,20 +72,6 @@ export interface IssuedBearerSession {
7472
const MCP_CLIENT_SUBJECT = "mcp-client";
7573
const MCP_CLIENT_SESSION_TTL = Duration.days(30);
7674

77-
export const mcpClientScopes = (
78-
access: AuthMcpClientAccess,
79-
): ReadonlyArray<AuthEnvironmentScope> =>
80-
access === "read-only"
81-
? [AuthOrchestrationReadScope]
82-
: access === "full-access"
83-
? [
84-
AuthOrchestrationReadScope,
85-
AuthOrchestrationOperateScope,
86-
AuthSettingsWriteScope,
87-
AuthSourceControlWriteScope,
88-
]
89-
: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
90-
9175
export interface McpClientSession {
9276
readonly sessionId: AuthSessionId;
9377
readonly label: string;

‎apps/server/src/auth/McpOAuth.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import * as NodeServices from "@effect/platform-node/NodeServices";
22
import {
3+
AuthMcpClientAccess,
34
AuthStandardClientScopes,
45
type AuthCreatePairingCredentialInput,
56
EnvironmentHttpApi,
7+
mcpClientScopes,
68
} from "@t3tools/contracts";
79
import { expect, it } from "@effect/vitest";
810
import * as Context from "effect/Context";
@@ -480,6 +482,23 @@ it.live(
480482
),
481483
);
482484

485+
it.live("a code minted with an access level's scopes approves that access", () =>
486+
withRoutes((handler, auth) =>
487+
Effect.gen(function* () {
488+
const params = authorizeParams(yield* registeredClientId(handler));
489+
for (const access of AuthMcpClientAccess.literals) {
490+
const code = yield* auth.issuePairingCredential({ scopes: [...mcpClientScopes(access)] });
491+
const approval = yield* decide(handler, params, {
492+
_tag: "pairing-code",
493+
access,
494+
code: code.credential,
495+
});
496+
expect(approval.status, access).toBe(200);
497+
}
498+
}),
499+
),
500+
);
501+
483502
it.live("one-click approves only access the browser session holds the scopes for", () =>
484503
withRoutes((handler, auth) =>
485504
Effect.gen(function* () {

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
AuthOrchestrationOperateScope,
1515
AuthOrchestrationReadScope,
1616
MCP_PEER_LINK_SOFTWARE_ID,
17+
mcpClientScopes,
1718
} from "@t3tools/contracts";
1819
import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
1920
import * as Clock from "effect/Clock";
@@ -436,9 +437,7 @@ const make = Effect.gen(function* () {
436437
if (
437438
session === undefined ||
438439
!timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) ||
439-
!EnvironmentAuth.mcpClientScopes(decision.access).every((scope) =>
440-
session.scopes.includes(scope),
441-
)
440+
!mcpClientScopes(decision.access).every((scope) => session.scopes.includes(scope))
442441
) {
443442
return yield* new McpOAuthPageError({
444443
description: "Your session cannot approve this request. Enter a pairing code instead.",
@@ -523,7 +522,7 @@ const make = Effect.gen(function* () {
523522
access_token: issued.token,
524523
token_type: "Bearer",
525524
expires_in: Math.max(0, Math.floor((issued.expiresAt.epochMilliseconds - now) / 1000)),
526-
scope: encodeOAuthScope(EnvironmentAuth.mcpClientScopes(pending.access)),
525+
scope: encodeOAuthScope(mcpClientScopes(pending.access)),
527526
};
528527
},
529528
);

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import {
33
type AuthMcpAuthorizationRequest,
44
AuthMcpClientAccess,
55
EnvironmentHttpApi,
6+
mcpClientScopes,
67
} from "@t3tools/contracts";
78
import * as Effect from "effect/Effect";
89
import * as Result from "effect/Result";
910
import { HttpServerRequest, HttpServerResponse } from "effect/http";
1011
import * as HttpEffect from "effect/http/HttpEffect";
1112
import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
1213

13-
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
1414
import * as McpOAuth from "./McpOAuth.ts";
1515
import { renderErrorPage } from "./mcpOAuthHtml.ts";
1616

@@ -121,9 +121,7 @@ export const layer = HttpApiBuilder.group(
121121
: {
122122
csrfToken: session.csrfToken,
123123
oneClickAccess: AuthMcpClientAccess.literals.filter((access) =>
124-
EnvironmentAuth.mcpClientScopes(access).every((scope) =>
125-
session.scopes.includes(scope),
126-
),
124+
mcpClientScopes(access).every((scope) => session.scopes.includes(scope)),
127125
),
128126
}),
129127
};

0 commit comments

Comments
 (0)