From 187e37dccc165c1014a61107ef9fa973666f28ec Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:01:05 -0700 Subject: [PATCH 01/10] feat(server,web,mobile): an agent sets up a link to another environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user asks an agent to "delegate this to my vps" without knowing any address, so t3_environment_link needs none. The agent passes a hint from the user's words, an environmentId when it knows the machine, or an address only when the user gave one. The tool records a card in the thread and returns pending at once, so no provider's tool-call timeout can close it. A machine already linked here returns linked at once, with no card. The card lets the user pick the machine from the ones their app knows (preselected by the hint; "Another address…" for one it does not), and the access (default supervised, or the agent's suggestion). The client answers with every address it knows for that machine and a one-use pairing code, minted there with the user's own session when it can, or pasted. The server links through the first address that answers as that machine, never sends the code to one that answers as another, and keeps only the matching addresses. A machine already linked is answered with "Use this link". The answer reaches the agent as a notification in its thread: linked (with the environment id, label and the access chosen), declined, or failed with the reason. The pairing code is never recorded. Stopping or archiving the thread closes a pending card; a retry with the same clientRequestId returns the card's current outcome. t3_environment_unlink undoes a link. Both tools need a full-access caller and are refused for linked work. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/threads/LinkRequestCard.tsx | 508 +++++++++++++ .../src/features/threads/ThreadFeed.tsx | 12 + apps/mobile/src/lib/threadActivity.ts | 14 +- apps/mobile/src/state/peerLinks.ts | 35 + apps/mobile/src/state/peerPairingCode.ts | 42 ++ apps/server/src/mcp/OrchestratorMcpService.ts | 4 + ...OrchestratorMcpToolkit.integration.test.ts | 4 + apps/server/src/mcp/linkOrigin.test.ts | 4 + apps/server/src/mcp/toolkits/core.test.ts | 12 + .../src/mcp/toolkits/orchestrator/handlers.ts | 25 + .../src/mcp/toolkits/orchestrator/tools.ts | 42 +- .../src/observability/RpcInstrumentation.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 170 +++++ .../src/orchestration-v2/ProjectionStore.ts | 7 +- .../ProviderRuntimeRecoveryService.test.ts | 83 +++ .../ProviderRuntimeRecoveryService.ts | 2 + apps/server/src/peer/PeerForwarding.test.ts | 4 + apps/server/src/peer/PeerForwarding.ts | 121 +-- apps/server/src/peer/PeerLinkRequests.test.ts | 705 ++++++++++++++++++ apps/server/src/peer/PeerLinkRequests.ts | 402 ++++++++++ apps/server/src/peer/PeerLinks.test.ts | 69 ++ apps/server/src/peer/PeerLinks.ts | 280 ++++--- apps/server/src/peer/RemoteDelegation.test.ts | 4 + apps/server/src/server.ts | 3 + apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 8 + .../components/auth/ConnectAgentSurface.tsx | 2 +- .../src/components/chat/LinkRequestCard.tsx | 514 +++++++++++++ .../components/chat/TimelineV2EventRow.tsx | 10 + .../components/settings/LinkAccessPicker.tsx | 63 ++ .../settings/LinkedEnvironmentsSettings.tsx | 28 +- apps/web/src/session-logic.ts | 2 + apps/web/src/state/peerLinks.ts | 30 +- apps/web/src/state/peerPairingCode.ts | 42 ++ docs/user/remote-access.md | 7 + packages/client-runtime/package.json | 8 + .../client-runtime/src/linkRequest.test.ts | 255 +++++++ packages/client-runtime/src/linkRequest.ts | 249 +++++++ .../src/state/commandPermissions.test.ts | 24 + .../src/state/peerPairingCode.ts | 75 ++ packages/client-runtime/src/state/server.ts | 10 + packages/client-runtime/src/t3ToolSummary.ts | 10 + .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/orchestrationV2.ts | 75 ++ packages/contracts/src/orchestratorMcp.ts | 75 ++ packages/contracts/src/peerLink.ts | 66 +- packages/contracts/src/rpc.ts | 9 + .../src/server/orchestrationInstructions.ts | 1 + packages/shared/src/t3McpToolPresentation.ts | 10 + 49 files changed, 3941 insertions(+), 189 deletions(-) create mode 100644 apps/mobile/src/features/threads/LinkRequestCard.tsx create mode 100644 apps/mobile/src/state/peerPairingCode.ts create mode 100644 apps/server/src/peer/PeerLinkRequests.test.ts create mode 100644 apps/server/src/peer/PeerLinkRequests.ts create mode 100644 apps/web/src/components/chat/LinkRequestCard.tsx create mode 100644 apps/web/src/components/settings/LinkAccessPicker.tsx create mode 100644 apps/web/src/state/peerPairingCode.ts create mode 100644 packages/client-runtime/src/linkRequest.test.ts create mode 100644 packages/client-runtime/src/linkRequest.ts create mode 100644 packages/client-runtime/src/state/peerPairingCode.ts diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx new file mode 100644 index 000000000000..5847e94a9e6f --- /dev/null +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -0,0 +1,508 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type LinkableMachine, + linkRequestAnswerInput, + linkRequestDefaultAccess, + linkRequestDisplay, + linkRequestFailureMessage, + linkRequestInitialMachine, + type LinkRequestItem, + linkRequestTargetName, + rankMachinesByHint, +} from "@t3tools/client-runtime/link-request"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthAccessWriteScope, + type AuthMcpClientAccess, + type EnvironmentId, + type OrchestrationV2ProjectedTurnItem, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useMemo, useRef, useState } from "react"; +import { Modal, Platform, Pressable, ScrollView, View, type ColorValue } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; +import { useLinkableMachines } from "../../state/peerLinks"; +import { mintPairingCodeOn } from "../../state/peerPairingCode"; +import { serverEnvironment } from "../../state/server"; +import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsChoiceRow } from "../settings/components/SettingsChoiceRow"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { Button } from "../../components/ui/Button"; +import { RUNTIME_MODE_CHOICES } from "./thread-settings-options"; + +/** The same five levels as web's access picker: read-only, then the runtime modes. */ +const ACCESS_CHOICES: ReadonlyArray<{ + readonly access: AuthMcpClientAccess; + readonly label: string; + readonly description: string; +}> = [ + { + access: "read-only", + label: "Read only", + description: "Read projects and threads. Cannot start, message or change anything.", + }, + ...RUNTIME_MODE_CHOICES.map((choice) => ({ ...choice, access: choice.mode })), +]; +const accessLabel = (access: AuthMcpClientAccess) => + ACCESS_CHOICES.find((choice) => choice.access === access)?.label ?? access; + +const LINK_SYMBOL: AppSymbolName = { ios: "link", android: "desktop_windows" }; + +/** The machine list's row for linking by an address typed in. */ +const ANOTHER_ADDRESS = "another-address"; + +/** Whether the agent left the machine for the user to pick. */ +const picksMachine = (item: LinkRequestItem) => + item.environmentId === undefined && item.url === undefined; + +/** + * Feed card for a link an agent asked the user to make. The user picks the + * access in a sheet; the pairing code lives only in that sheet's state and + * the RPC payload, and is never logged, alerted, or persisted. + */ +export function LinkRequestCard(props: { + readonly environmentId: EnvironmentId; + readonly projectedItem: OrchestrationV2ProjectedTurnItem; + readonly iconColor: ColorValue; +}) { + const { item, visibility } = props.projectedItem; + if (item.type !== "link_request") return null; + const display = linkRequestDisplay(item, visibility, accessLabel); + if (display.kind === "pending") { + return ; + } + const icon: AppSymbolName = + display.kind === "pending-elsewhere" + ? LINK_SYMBOL + : display.outcome === "linked" + ? "checkmark" + : display.outcome === "failed" + ? "xmark" + : "minus"; + return ( + + + + {display.label} + + + ); +} + +function PendingLinkRequest(props: { + readonly environmentId: EnvironmentId; + readonly item: LinkRequestItem; +}) { + const { item } = props; + const [open, setOpen] = useState(false); + const [declining, setDeclining] = useState(false); + const [error, setError] = useState(null); + const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + label: "decline link request", + reportFailure: false, + reportDefect: false, + }); + const canAnswer = useAtomValue( + serverEnvironment.answerLinkRequest.permissionAtom(props.environmentId), + ); + const target = linkRequestTargetName(item); + + const decline = async () => { + const input = linkRequestAnswerInput(item, { type: "decline" }); + if (input === null) return; + setDeclining(true); + setError(null); + const result = await answer({ environmentId: props.environmentId, input }).finally(() => + setDeclining(false), + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + } + }; + + return ( + + + + {picksMachine(item) + ? "Link a machine to this environment" + : `Link ${target} to this environment`} + + {item.reason.trim() ? ( + {item.reason} + ) : null} + {item.label !== undefined && item.url !== undefined ? ( + + {item.url} + + ) : null} + + {error !== null ? ( + + {error} + + ) : null} + {canAnswer ? ( + <> + + + + ) : ( +

+ Only a session that manages access here can answer this. +

+ )} + + + ); +} + +/** + * What the dialog links: a machine from the client's catalog, or an address. + * `urls` is empty when the client knows no direct address for the machine, + * so the user types one. + */ +type DialogTarget = + | { readonly kind: "none" } + | { + readonly kind: "machine"; + readonly environmentId: EnvironmentId; + readonly label: string; + readonly urls: ReadonlyArray; + readonly linked: boolean; + } + | { readonly kind: "address" }; + +function resolveTarget( + item: LinkRequestItem, + machines: ReadonlyArray, + choice: string | null, +): DialogTarget { + if (item.environmentId === undefined && item.url !== undefined) return { kind: "address" }; + const chosen = item.environmentId ?? choice; + if (chosen === null) return { kind: "none" }; + if (chosen === ANOTHER_ADDRESS) return { kind: "address" }; + const machine = machines.find((candidate) => candidate.environmentId === chosen); + return { + kind: "machine", + environmentId: machine?.environmentId ?? (chosen as EnvironmentId), + label: machine?.label ?? linkRequestTargetName(item), + urls: [...new Set([...(machine?.urls ?? []), ...(item.url === undefined ? [] : [item.url])])], + linked: machine?.linked ?? false, + }; +} + +/** The dialog's steps: where to link, then what agents may do there. */ +type LinkStep = "machine" | "access"; + +function LinkRequestDialog(props: { + readonly environmentId: EnvironmentId; + readonly item: LinkRequestItem; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + const { item } = props; + const formId = useId(); + const pickMachine = picksMachine(item); + const listed = useLinkableMachines(props.environmentId, props.open); + const machines = useMemo(() => rankMachinesByHint(listed, item.hint), [listed, item.hint]); + // Null until the user picks; until then the hint's best match is picked. + const [choice, setChoice] = useState(null); + const [address, setAddress] = useState(item.url ?? ""); + const [access, setAccess] = useState(() => linkRequestDefaultAccess(item)); + const [pairingCode, setPairingCode] = useState(""); + const [step, setStep] = useState("machine"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Machines whose minting already failed, so the dialog asks for a code instead. + const [mintFailed, setMintFailed] = useState>(new Set()); + // Enter then a click can both run before a re-render; this guard is synchronous. + const inFlight = useRef(false); + const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + label: "answer link request", + // The failure cause holds the request, and with it the code; keep it out of the console. + reportFailure: false, + reportDefect: false, + }); + const selected = choice ?? linkRequestInitialMachine(item, machines); + const target = resolveTarget(item, machines, selected); + const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item); + // This client can mint a code there when it is connected to that + // environment with a session that may create pairing links. + const targetId = target.kind === "machine" ? target.environmentId : null; + const connected = usePreparedConnection(targetId); + const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope); + const canMint = + targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId); + const needsAddress = + target.kind === "address" || (target.kind === "machine" && target.urls.length === 0); + // An address the agent gave is already filled in; only one the user must type is asked for. + const asksAddress = needsAddress && !(item.url !== undefined && item.environmentId === undefined); + const useExisting = target.kind === "machine" && target.linked; + // A named machine with an address it answers at has nothing to choose before access. + const steps: ReadonlyArray = + pickMachine || asksAddress ? ["machine", "access"] : ["access"]; + const current: LinkStep = steps.includes(step) ? step : "access"; + const machineReady = + target.kind !== "none" && (useExisting || !needsAddress || address.trim().length > 0); + const ready = machineReady && (useExisting || canMint || pairingCode.trim().length > 0); + + const reset = () => { + setChoice(null); + setAddress(item.url ?? ""); + setAccess(linkRequestDefaultAccess(item)); + setPairingCode(""); + setStep("machine"); + setError(null); + }; + + const submit = async () => { + if (inFlight.current || target.kind === "none") return; + inFlight.current = true; + setBusy(true); + setError(null); + try { + let input; + if (target.kind === "machine" && target.linked) { + input = linkRequestAnswerInput(item, { + type: "use-existing", + environmentId: target.environmentId, + }); + } else { + let code = pairingCode; + if (canMint && targetId !== null) { + try { + code = await mintPairingCodeOn(targetId); + } catch (cause) { + setMintFailed((failed) => new Set(failed).add(targetId)); + setError(linkRequestFailureMessage(cause)); + return; + } + } + input = linkRequestAnswerInput(item, { + type: "link", + target: + target.kind === "address" + ? { url: address } + : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, + access, + pairingCode: code, + }); + } + if (input === null) return; + const result = await answer({ environmentId: props.environmentId, input }); + if (result._tag === "Success") { + // The card switches to its answered row once the item updates. + setPairingCode(""); + props.onOpenChange(false); + return; + } + if (!isAtomCommandInterrupted(result)) { + setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + } + } finally { + inFlight.current = false; + setBusy(false); + } + }; + + // The machine step ends the dialog when the machine is linked here already. + const finishesHere = current === "access" || useExisting; + + return ( + { + // An answer that lands after a close would still link, and would close a reopened dialog. + if (!next && busy) return; + props.onOpenChange(next); + if (!next) reset(); + }} + > + + + {steps.length > 1 && !useExisting ? ( + busy || (index === 1 && !machineReady)} + onStepChange={(index) => { + const next = steps[index]; + if (next !== undefined) setStep(next); + }} + /> + ) : null} + + +
{ + event.preventDefault(); + if (finishesHere) void submit(); + else if (machineReady) setStep("access"); + }} + > + {current === "machine" ? ( + <> + {pickMachine ? ( + { + setChoice(next); + setError(null); + }} + /> + ) : null} + {asksAddress ? ( + + ) : null} + {useExisting ? ( +

+ This environment is already linked to {targetName}. The agent can use that link + as it is; change its access in Settings → Connections. +

+ ) : null} + + ) : ( + <> + {item.url !== undefined && !pickMachine ? ( +

{item.url}

+ ) : null} + {canMint ? null : ( + + )} + + + )} + {error !== null ? ( +

+ {error} +

+ ) : null} + +
+ + {current === "access" && steps.length > 1 ? ( + + ) : ( + + )} + + +
+
+ ); +} + +/** The user's other machines, best match for the agent's hint first, then an address. */ +function MachinePicker(props: { + readonly machines: ReadonlyArray; + readonly value: string | null; + readonly disabled: boolean; + readonly onChange: (value: string) => void; +}) { + const labelId = useId(); + return ( +
+ + Machine + + props.onChange(next as string)} + > + {props.machines.map((machine) => ( + + ))} + + +
+ ); +} diff --git a/apps/web/src/components/chat/TimelineV2EventRow.tsx b/apps/web/src/components/chat/TimelineV2EventRow.tsx index 650c930cd110..04d6effdd456 100644 --- a/apps/web/src/components/chat/TimelineV2EventRow.tsx +++ b/apps/web/src/components/chat/TimelineV2EventRow.tsx @@ -18,6 +18,7 @@ import { SubagentElapsed, V2LifecycleRow, } from "./V2LifecycleRow"; +import { LinkRequestCard } from "./LinkRequestCard"; import { SecretRequestCard } from "./SecretRequestCard"; import { TimelineRowCtx, type TimelineRow } from "./timelineRowContext"; import { v2EventPresentation, subagentGroupTiming } from "./TimelineV2EventRow.logic"; @@ -37,6 +38,15 @@ export function V2EventTimelineRow({ row }: { row: Extract ); } + if (item.type === "link_request") { + return ( + + ); + } if (isV2LifecycleItem(item)) { return ( void; + readonly disabled?: boolean; +}) { + const labelId = useId(); + const selected = accessConfig[value]; + return ( +
+ + What agents here may do there + + +

+ {selected.description} An agent never gets more access there than its own mode here. Below + full access, it can change only threads this link started, not that machine's own threads, + projects or settings. +

+
+ ); +} diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index c4804281f54c..8ee4a087bb69 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { AuthMcpClientAccess, type EnvironmentId, type PeerLinkSummary } from "@t3tools/contracts"; +import type { AuthMcpClientAccess, EnvironmentId, PeerLinkSummary } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -13,7 +13,7 @@ import { requestConfirmDialog } from "~/confirmDialog"; import { peerLinkEnvironment } from "~/state/peerLinks"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { AccessOption, accessConfig } from "../auth/ConnectAgentSurface"; +import { accessConfig } from "../auth/ConnectAgentSurface"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { Button } from "../ui/button"; import { @@ -28,9 +28,9 @@ import { DialogTrigger, } from "../ui/dialog"; import { Input } from "../ui/input"; -import { RadioGroup } from "../ui/radio-group"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { LinkAccessPicker } from "./LinkAccessPicker"; import { SettingsRow, SettingsSection } from "./settingsLayout"; import { useNowMinute } from "../../hooks/useNowMinute"; import { searchableSetting } from "./settingsSearch"; @@ -162,7 +162,6 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi const [access, setAccess] = useState("approval-required"); const [busy, setBusy] = useState(false); const formId = useId(); - const accessLabelId = `${formId}-access`; const link = useAtomCommand(peerLinkEnvironment.link, { label: "link environment" }); const handleOpenChange = (next: boolean) => { setOpen(next); @@ -243,26 +242,7 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi spellCheck={false} /> -
- - What agents here may do there - - setAccess(value as AuthMcpClientAccess)} - > - {AuthMcpClientAccess.literals.map((option) => ( - - ))} - -

- An agent never gets more access there than its own mode here. Below full access, it - can change only threads this link started, not that environment's own threads, - projects or settings. -

-
+ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7fef6c52fe31..eb61b244075a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -300,6 +300,7 @@ const STANDALONE_V2_ITEM_TYPES = new Set readable.has(environment.environmentId)); } + +/** + * The user's other machines, for picking one to link `environmentId` to. + * Reads that environment's links only while `enabled`, since listing them + * asks every linked machine whether it answers. + */ +export function useLinkableMachines(environmentId: EnvironmentId, enabled: boolean) { + const { environments } = useEnvironments(); + const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom); + const links = useEnvironmentQuery( + enabled ? peerLinkEnvironment.list({ environmentId, input: {} }) : null, + ); + return linkableMachines({ + environments, + threadEnvironmentId: environmentId, + linkedIds: new Set(links.data?.links.map((link) => link.environmentId)), + connectAddresses: new Map( + [...discovery.environments].map(([id, found]) => [ + id, + found.environment.endpoint.httpBaseUrl, + ]), + ), + }); +} diff --git a/apps/web/src/state/peerPairingCode.ts b/apps/web/src/state/peerPairingCode.ts new file mode 100644 index 000000000000..dc3afa7b3ff5 --- /dev/null +++ b/apps/web/src/state/peerPairingCode.ts @@ -0,0 +1,42 @@ +import { mintPeerPairingCode } from "@t3tools/client-runtime/state/peerPairingCode"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSession } from "./session"; + +// Each read mints a new one-use code; nothing keeps one once it is read. +const peerPairingCodeAtom = Atom.family((environmentId: EnvironmentId) => + connectionAtomRuntime + .atom((get) => { + const prepared = Option.getOrNull( + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); + if (prepared === null) return Effect.never; + return mintPeerPairingCode(prepared); + }) + .pipe(Atom.withLabel(`peer-pairing-code:${environmentId}`)), +); + +/** + * A fresh pairing code minted on `environmentId` with this client's session + * there, for answering a link request. Fails with `PeerPairingCodeError` when + * the session may not create one. + */ +export function mintPairingCodeOn(environmentId: EnvironmentId): Promise { + const atom = peerPairingCodeAtom(environmentId); + appAtomRegistry.refresh(atom); + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* AtomRegistry.mount(appAtomRegistry, atom); + return yield* AtomRegistry.getResult(appAtomRegistry, atom, { + suspendOnWaiting: true, + }).pipe(Effect.timeout("15 seconds")); + }), + ), + ); +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index a14285d97446..a2084af52b87 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -257,6 +257,13 @@ choose **Link**, and enter the other machine's address and a pairing code from it. From a terminal, use `t3 environment link
--pairing-code --access `. +You can also ask an agent to link a machine by name, such as "delegate this to +my vps". It shows a card in the thread where you pick the machine from the ones +this app knows (or enter an address) and the access, then approve. If this app +is connected to that machine with a session that manages access there, it uses +that session; otherwise paste a pairing code from that machine's +**Settings → Connections**. + - The access you pick is the most agents from this machine may do there. An agent also never gets more there than its own mode here. - Threads a link starts are marked **From** and this machine's name there. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 949ef95e0fb4..c08c6e59a2ec 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -55,6 +55,10 @@ "types": "./src/secretRequest.ts", "default": "./src/secretRequest.ts" }, + "./link-request": { + "types": "./src/linkRequest.ts", + "default": "./src/linkRequest.ts" + }, "./scheduled-task-webhook": { "types": "./src/scheduledTaskWebhook.ts", "default": "./src/scheduledTaskWebhook.ts" @@ -211,6 +215,10 @@ "types": "./src/state/peerLinks.ts", "default": "./src/state/peerLinks.ts" }, + "./state/peerPairingCode": { + "types": "./src/state/peerPairingCode.ts", + "default": "./src/state/peerPairingCode.ts" + }, "./state/preview": { "types": "./src/state/preview.ts", "default": "./src/state/preview.ts" diff --git a/packages/client-runtime/src/linkRequest.test.ts b/packages/client-runtime/src/linkRequest.test.ts new file mode 100644 index 000000000000..29ec717f9456 --- /dev/null +++ b/packages/client-runtime/src/linkRequest.test.ts @@ -0,0 +1,255 @@ +import { EnvironmentId, type ServerConfig, ThreadId, TurnItemId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { describe, expect, it } from "vite-plus/test"; + +import { BearerConnectionProfile } from "./connection/catalog.ts"; +import { BearerConnectionTarget, RelayConnectionTarget } from "./connection/model.ts"; +import type { EnvironmentPresentation } from "./connection/presentation.ts"; +import { + linkableMachines, + linkRequestAnswerInput, + linkRequestDisplay, + linkRequestFailureMessage, + linkRequestInitialMachine, + rankMachinesByHint, +} from "./linkRequest.ts"; + +const item = { + id: TurnItemId.make("turn-item:link-request:1"), + threadId: ThreadId.make("thread-1"), +}; +const asked = { url: "https://box.example.ts.net", label: "Box" }; +const accessLabel = (access: string) => `<${access}>`; + +describe("linkRequestDisplay", () => { + it("names the environment linked and the access the user chose", () => { + expect( + linkRequestDisplay( + { ...asked, linkStatus: "linked", linkedLabel: "Box", linkedAccess: "auto" }, + "local", + accessLabel, + ), + ).toEqual({ kind: "answered", outcome: "linked", label: "Linked Box · " }); + }); + + it("shows why a link failed, and never offers the actions outside the asking thread", () => { + expect( + linkRequestDisplay( + { ...asked, linkStatus: "failed", failure: "Pairing code expired." }, + "local", + accessLabel, + ), + ).toMatchObject({ outcome: "failed", label: "Could not link: Pairing code expired." }); + expect( + linkRequestDisplay({ ...asked, linkStatus: "pending" }, "inherited", accessLabel), + ).toMatchObject({ kind: "pending-elsewhere" }); + }); +}); + +describe("linkRequestAnswerInput", () => { + const vps = { + environmentId: EnvironmentId.make("environment-vps"), + label: "VPS", + urls: ["https://vps.ts.net", "http://10.0.0.2:3773"], + }; + + it("answers with the machine picked and every address known for it", () => { + expect( + linkRequestAnswerInput(item, { + type: "link", + target: vps, + access: "auto", + pairingCode: " abc ", + }), + ).toEqual({ + threadId: item.threadId, + turnItemId: item.id, + answer: { type: "link", ...vps, access: "auto", pairingCode: "abc" }, + }); + expect( + linkRequestAnswerInput(item, { + type: "link", + target: { url: " https://box.ts.net " }, + access: "auto", + pairingCode: "abc", + }), + ).toMatchObject({ answer: { urls: ["https://box.ts.net"] } }); + }); + + it("refuses a blank code, a blank address, or a machine with no address", () => { + const blank = (target: Parameters[1] & { type: "link" }) => + linkRequestAnswerInput(item, target); + expect(blank({ type: "link", target: vps, access: "auto", pairingCode: " " })).toBeNull(); + expect( + blank({ type: "link", target: { url: " " }, access: "auto", pairingCode: "abc" }), + ).toBeNull(); + expect( + blank({ type: "link", target: { ...vps, urls: [] }, access: "auto", pairingCode: "abc" }), + ).toBeNull(); + }); +}); + +const presentation = ( + id: string, + label: string, + options: { + readonly saved?: ReadonlyArray; + readonly reported?: ReadonlyArray; + readonly connected?: boolean; + } = {}, +): EnvironmentPresentation & { readonly environmentId: EnvironmentId } => { + const environmentId = EnvironmentId.make(id); + const [first, ...rest] = (options.saved ?? []).map((httpBaseUrl, index) => { + const connectionId = `${id}-${index}`; + return { + target: new BearerConnectionTarget({ environmentId, label, connectionId }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId, + label, + httpBaseUrl, + wsBaseUrl: httpBaseUrl, + }), + ), + }; + }); + return { + environmentId, + entry: { + ...(first ?? { + target: new RelayConnectionTarget({ environmentId, label }), + profile: Option.none(), + }), + ...(rest.length === 0 ? {} : { alternateRoutes: rest }), + enabled: true, + }, + connection: { + phase: options.connected === false ? "offline" : "connected", + error: null, + traceId: null, + }, + serverConfig: + options.reported === undefined + ? null + : ({ + environment: { platform: { machine: "laptop" } }, + directEndpoints: options.reported.map((httpBaseUrl) => ({ + kind: "tailnet", + httpBaseUrl, + })), + } as unknown as ServerConfig), + }; +}; + +describe("linkableMachines", () => { + it("lists the other machines with reported addresses before saved ones, once each", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const vps = presentation("environment-vps", "VPS", { + saved: ["https://vps.example.com", "https://vps.ts.net"], + reported: ["https://vps.ts.net"], + }); + const mini = presentation("environment-mini", "Mini", { connected: false }); + expect( + linkableMachines({ + environments: [here, vps, mini], + threadEnvironmentId: here.environmentId, + linkedIds: new Set([mini.environmentId]), + connectAddresses: new Map(), + }), + ).toEqual([ + { + environmentId: vps.environmentId, + label: "VPS", + machine: "laptop", + connected: true, + linked: false, + urls: ["https://vps.ts.net", "https://vps.example.com"], + }, + { + environmentId: mini.environmentId, + label: "Mini", + machine: "server", + connected: false, + linked: true, + urls: [], + }, + ]); + }); + + it("reaches a machine saved only over T3 Connect at its Connect address", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const box = presentation("environment-box", "Box"); + const [listed] = linkableMachines({ + environments: [here, box], + threadEnvironmentId: here.environmentId, + linkedIds: new Set(), + connectAddresses: new Map([[box.environmentId, "https://box.t3.example"]]), + }); + expect(listed?.urls).toEqual(["https://box.t3.example"]); + // Until discovery has found it, there is no address to offer. + expect( + linkableMachines({ + environments: [here, box], + threadEnvironmentId: here.environmentId, + linkedIds: new Set(), + connectAddresses: new Map(), + })[0]?.urls, + ).toEqual([]); + }); +}); + +describe("rankMachinesByHint", () => { + const machines = [ + { label: "Old vps box" }, + { label: "VPS-2" }, + { label: "Mac mini" }, + { label: "vps" }, + ]; + + it("puts an exact name first, then a prefix, then a substring, keeping order within each", () => { + expect(rankMachinesByHint(machines, " VPS ").map((machine) => machine.label)).toEqual([ + "vps", + "VPS-2", + "Old vps box", + "Mac mini", + ]); + expect(rankMachinesByHint(machines, undefined)).toBe(machines); + }); + + it("preselects the card's machine, or the hint's match, and nothing for a hint that matches none", () => { + const listed = [ + { environmentId: EnvironmentId.make("environment-mini"), label: "Mac mini" }, + { environmentId: EnvironmentId.make("environment-vps"), label: "Hetzner VPS" }, + ].map( + (machine) => + ({ ...machine, machine: "server", connected: true, linked: false, urls: [] }) as const, + ); + expect(linkRequestInitialMachine({ hint: "vps" }, listed)).toBe("environment-vps"); + // Agents pass the user's words, which name the machine among others. + expect(linkRequestInitialMachine({ hint: "my mac mini" }, listed)).toBe("environment-mini"); + expect(linkRequestInitialMachine({ hint: "my mac" }, listed)).toBe("environment-mini"); + expect(linkRequestInitialMachine({ hint: "the hetzner vps box" }, listed)).toBe( + "environment-vps", + ); + expect(linkRequestInitialMachine({ hint: "desk" }, listed)).toBeNull(); + expect(linkRequestInitialMachine({ hint: "minimal" }, listed)).toBeNull(); + expect( + linkRequestInitialMachine( + { environmentId: EnvironmentId.make("environment-x"), hint: "vps" }, + listed, + ), + ).toBe("environment-x"); + }); +}); + +describe("linkRequestFailureMessage", () => { + it("passes through only known user-facing errors", () => { + expect( + linkRequestFailureMessage({ _tag: "PeerLinkRequestError", message: "Already answered." }), + ).toBe("Already answered."); + expect(linkRequestFailureMessage({ _tag: "SchemaError", message: "code=abc" })).toBe( + "Could not answer the request. Try again.", + ); + }); +}); diff --git a/packages/client-runtime/src/linkRequest.ts b/packages/client-runtime/src/linkRequest.ts new file mode 100644 index 000000000000..58d6cd5a69c0 --- /dev/null +++ b/packages/client-runtime/src/linkRequest.ts @@ -0,0 +1,249 @@ +import { + type AuthMcpClientAccess, + type EnvironmentId, + type EnvironmentMachineKind, + type OrchestrationV2TurnItem, + type PeerLinkRequestAnswerInput, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; + +import type { EnvironmentPresentation } from "./connection/presentation.ts"; +import { connectionRoutes, routeHttpBaseUrl } from "./connection/routes.ts"; + +export type LinkRequestItem = Extract; + +/** The access a request opens with: the agent's suggestion, else supervised. */ +export const linkRequestDefaultAccess = ( + item: Pick, +): AuthMcpClientAccess => item.requestedAccess ?? "approval-required"; + +/** What the card calls the other environment: its own name, else the address asked for. */ +export const linkRequestTargetName = (item: Pick): string => + item.label?.trim() || item.url || "a machine"; + +/** A machine the user could link a thread's environment to. */ +export interface LinkableMachine { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly machine: EnvironmentMachineKind; + readonly connected: boolean; + /** Whether the thread's environment already has a link to it. */ + readonly linked: boolean; + /** + * Where the client knows it answers, most preferred first: the addresses it + * reports, then its saved routes, a T3 Connect route by its Connect address. + * The server keeps only those that answer as this machine. Empty for a + * machine reached only over SSH, whose forward lives in this client. + */ + readonly urls: ReadonlyArray; +} + +/** + * The machines in the client's catalog other than the thread's own + * environment, in catalog order. `linkedIds` are the environments the thread's + * environment links to already. `connectAddresses` are the T3 Connect + * addresses relay discovery found, by environment id: a server reaches a + * machine there as a browser does, so a Connect route is a usable address. + */ +export function linkableMachines(input: { + readonly environments: ReadonlyArray< + EnvironmentPresentation & { readonly environmentId: EnvironmentId } + >; + readonly threadEnvironmentId: EnvironmentId; + readonly linkedIds: ReadonlySet; + readonly connectAddresses: ReadonlyMap; +}): ReadonlyArray { + return input.environments + .filter((environment) => environment.environmentId !== input.threadEnvironmentId) + .map((environment) => { + const reported = (environment.serverConfig?.directEndpoints ?? []).map( + (endpoint) => endpoint.httpBaseUrl, + ); + const connect = input.connectAddresses.get(environment.environmentId); + const saved = connectionRoutes(environment.entry).flatMap((route) => { + const url = + route.target._tag === "RelayConnectionTarget" ? connect : routeHttpBaseUrl(route); + return url === null || url === undefined ? [] : [url]; + }); + return { + environmentId: environment.environmentId, + label: environment.entry.target.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + connected: environment.connection.phase === "connected", + linked: input.linkedIds.has(environment.environmentId), + urls: [...new Set([...reported, ...saved])], + }; + }); +} + +const words = (text: string) => + ` ${text + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .join(" ") + .trim()} `; +const NO_MATCH = 4; + +/** + * How well a machine's name matches the agent's hint, which is often the + * user's own words ("my box"): exact, prefix, substring, then a hint that + * names it among other words ("the box on hetzner"). + */ +const hintRank = (label: string, hint: string) => { + const name = label.trim().toLowerCase(); + const wanted = hint + .trim() + .toLowerCase() + .replace(/^(?:my|our|the)\s+/, ""); + if (name === wanted) return 0; + if (name.startsWith(wanted)) return 1; + if (name.includes(wanted)) return 2; + if (words(wanted).includes(words(name))) return 3; + return NO_MATCH; +}; + +/** The machines best match for the agent's hint first, each rank in catalog order. */ +export function rankMachinesByHint( + machines: ReadonlyArray, + hint: string | undefined, +): ReadonlyArray { + if (hint === undefined || hint.trim().length === 0) return machines; + return machines + .map((machine, index) => ({ machine, index, rank: hintRank(machine.label, hint) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map(({ machine }) => machine); +} + +/** The machine a card opens on: the one it names, else the hint's best match. */ +export function linkRequestInitialMachine( + item: Pick, + machines: ReadonlyArray, +): EnvironmentId | null { + if (item.environmentId !== undefined) return item.environmentId; + if (item.hint === undefined) return null; + const [best] = rankMachinesByHint(machines, item.hint); + return best !== undefined && hintRank(best.label, item.hint) < NO_MATCH + ? best.environmentId + : null; +} + +/** What a link request card shows: the actions while pending, otherwise a one-line outcome. */ +export type LinkRequestDisplay = + | { readonly kind: "pending" } + | { readonly kind: "pending-elsewhere"; readonly label: string } + | { + readonly kind: "answered"; + readonly outcome: "linked" | "declined" | "ended" | "failed"; + readonly label: string; + }; + +/** + * `visibility` is the projected row's: a request inherited from another + * thread (a fork) can only be answered where it was asked. `accessLabel` + * names an access level the way the client's access picker does. + */ +export function linkRequestDisplay( + item: Pick< + LinkRequestItem, + "linkStatus" | "label" | "url" | "linkedLabel" | "linkedAccess" | "failure" + >, + visibility: "local" | "inherited" | "synthetic", + accessLabel: (access: AuthMcpClientAccess) => string, +): LinkRequestDisplay { + switch (item.linkStatus) { + case "pending": + return visibility === "local" + ? { kind: "pending" } + : { kind: "pending-elsewhere", label: "Waiting for an answer in the original thread" }; + case "linked": + return { + kind: "answered", + outcome: "linked", + label: `Linked ${item.linkedLabel ?? linkRequestTargetName(item)}${ + item.linkedAccess === undefined ? "" : ` · ${accessLabel(item.linkedAccess)}` + }`, + }; + case "declined": + return { kind: "answered", outcome: "declined", label: "Declined" }; + case "failed": + return { + kind: "answered", + outcome: "failed", + label: `Could not link: ${item.failure ?? "the other environment refused"}`, + }; + case "cancelled": + return { kind: "answered", outcome: "ended", label: "Request ended" }; + } +} + +/** + * Builds the RPC payload for an answer. A link with a blank code returns null, + * since the server rejects it; callers keep Link disabled instead. + */ +export function linkRequestAnswerInput( + item: Pick, + answer: + | { + readonly type: "link"; + /** The machine picked, or an address typed in its place. */ + readonly target: + | Pick + | { readonly url: string }; + readonly access: AuthMcpClientAccess; + readonly pairingCode: string; + } + | { readonly type: "use-existing"; readonly environmentId: EnvironmentId } + | { readonly type: "decline" }, +): PeerLinkRequestAnswerInput | null { + const base = { threadId: item.threadId, turnItemId: item.id }; + if (answer.type !== "link") return { ...base, answer }; + const pairingCode = answer.pairingCode.trim(); + if (pairingCode.length === 0) return null; + const { target } = answer; + if ("url" in target) { + const url = target.url.trim(); + if (url.length === 0) return null; + return { ...base, answer: { type: "link", urls: [url], access: answer.access, pairingCode } }; + } + const [first, ...rest] = target.urls; + if (first === undefined) return null; + return { + ...base, + answer: { + type: "link", + environmentId: target.environmentId, + label: target.label, + urls: [first, ...rest], + access: answer.access, + pairingCode, + }, + }; +} + +/** Failures whose message is written for the user and never echoes the request payload. */ +const USER_FACING_FAILURE_TAGS = new Set([ + "PeerLinkRequestError", + "EnvironmentAuthorizationError", + "PeerPairingCodeError", +]); + +/** + * Inline error copy for a failed answer. Only known errors pass their message + * through: anything else (transport or encoding failures) gets the generic + * copy, so the pairing code can never surface in the UI. + */ +export function linkRequestFailureMessage(failure: unknown): string { + if ( + typeof failure === "object" && + failure !== null && + "_tag" in failure && + typeof failure._tag === "string" && + USER_FACING_FAILURE_TAGS.has(failure._tag) && + "message" in failure && + typeof failure.message === "string" && + failure.message.trim().length > 0 + ) { + return failure.message; + } + return "Could not answer the request. Try again."; +} diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 86fa95636538..ed28cd13e3a0 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -423,6 +423,30 @@ it.effect( ), ); +it.effect("requires access management to answer a link request", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + registry.set(sessions(env), AsyncResult.success(grant(true))); + const answer = createCommandPermissions(runtime, WS_METHODS.peerLinksAnswerRequest); + expect(registry.get(answer.permissionAtom(env))).toBe(false); + expect((yield* answer.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe( + AuthAccessWriteScope, + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthAccessWriteScope], + permissions: [AuthAccessWriteScope], + }), + ); + expect(registry.get(answer.permissionAtom(env))).toBe(true); + yield* answer.authorize(registry, env); + }), + ), +); + it.effect("honors exact empty permissions and preserves legacy parent grants", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/peerPairingCode.ts b/packages/client-runtime/src/state/peerPairingCode.ts new file mode 100644 index 000000000000..0799c1cd7115 --- /dev/null +++ b/packages/client-runtime/src/state/peerPairingCode.ts @@ -0,0 +1,75 @@ +/** + * A pairing code for linking, minted on the environment being linked to with + * the user's own session there, so a link card can be answered without the + * user fetching a code by hand. The code is one-use, carries only what an + * agent sign-in needs, and goes straight into the link answer. + */ +import { + type AuthGrantScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, +} from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import type * as HttpClient from "effect/http/HttpClient"; + +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import * as ManagedRelay from "../relay/managedRelay.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; + +const MINT_TIMEOUT_MS = 8_000; +const PEER_PAIRING_CODE_LABEL = "T3 Code link"; +/** An agent signing in there needs these, whatever access the user picks. */ +const LINK_SCOPES: ReadonlyArray = [ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, +]; + +/** Minting failed; the user can still paste a code from the other environment. */ +export class PeerPairingCodeError extends Data.TaggedError("PeerPairingCodeError")<{ + readonly reason: "not_allowed" | "unavailable"; + readonly cause: unknown; +}> { + override get message(): string { + return this.reason === "not_allowed" + ? "Your session there cannot create pairing codes. Paste a code from there instead." + : "Could not get a pairing code from there. Paste a code from there instead."; + } +} + +export const mintPeerPairingCode = Effect.fn("clientRuntime.state.mintPeerPairingCode")(function* ( + prepared: PreparedConnection, +): Effect.fn.Return { + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); + const remoteAuthorization = yield* Effect.serviceOption( + RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, + ); + const minted = yield* executeAuthenticatedEnvironmentHttpRequest({ + prepared, + signer, + remoteAuthorization, + group: "auth", + method: "POST", + url: (urls) => urls.pairingCredential(), + timeoutMs: MINT_TIMEOUT_MS, + request: ({ client, headers }) => + client.pairingCredential({ + headers, + payload: { label: PEER_PAIRING_CODE_LABEL, scopes: LINK_SCOPES }, + }), + }).pipe( + Effect.mapError( + (cause) => + new PeerPairingCodeError({ + reason: + cause._tag === "EnvironmentScopeRequiredError" || + cause._tag === "EnvironmentOperationForbiddenError" + ? "not_allowed" + : "unavailable", + cause, + }), + ), + ); + return minted.credential; +}); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f9f1f9140704..77db29b9e940 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1525,6 +1525,16 @@ export function createServerEnvironmentAtoms( JSON.stringify([environmentId, input.threadId, input.turnItemId]), }, }), + // Like a secret card's answer: off the config lane, one per card at a time. + answerLinkRequest: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:peer-links:answer-request", + tag: WS_METHODS.peerLinksAnswerRequest, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => + JSON.stringify([environmentId, input.threadId, input.turnItemId]), + }, + }), refreshUsageRates: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-usage-rates", tag: WS_METHODS.serverRefreshUsageRates, diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 1aed057817cb..1e9629d5803a 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -339,6 +339,16 @@ export function summarizeT3ToolCalls( case "environment-links": label = phrase("Listed", "list", `linked environments ${times}`); break; + case "environment-link": + label = phrase("Asked to link", "ask to link", quantity(selected.length, "environment")); + break; + case "environment-unlink": + label = phrase( + "Forgot", + "forget", + quantity(countEntities(entityIds("environmentId")), "linked environment"), + ); + break; case "attachment-prepare": label = phrase( "Prepared", diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 5ff35ad13341..364b3d191af3 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -54,6 +54,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.guestShareCreate]: AuthAccessWriteScope, [WS_METHODS.peerLinksLink]: AuthAccessWriteScope, [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope, + // A link lets every agent here reach the other environment: Settings' bar. + [WS_METHODS.peerLinksAnswerRequest]: AuthAccessWriteScope, } as const; export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 2ee6f3d3bd0c..18d7c03cd77b 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1506,6 +1506,42 @@ const OrchestrationV2SecretRequestFields = { secretStatus: OrchestrationV2SecretRequestStatus, } as const; +export const OrchestrationV2LinkRequestStatus = Schema.Literals([ + "pending", + "linked", + "declined", + "cancelled", + "failed", +]); +export type OrchestrationV2LinkRequestStatus = typeof OrchestrationV2LinkRequestStatus.Type; + +/** + * An agent asked the user to link another environment. The user picks the + * access; the pairing code never passes through orchestration, so the item + * carries only what was asked and how it ended. + */ +const OrchestrationV2LinkRequestFields = { + type: Schema.Literal("link_request"), + /** The address the agent named, if it named one. */ + url: Schema.optional(TrimmedNonEmptyString), + reason: Schema.String, + /** + * The environment the agent named, or the one answering at `url`. Without + * either the user picks the machine in the card. + */ + environmentId: Schema.optional(EnvironmentId), + label: Schema.optional(Schema.String), + /** A phrase from the user's words, such as "vps", that preselects a machine. */ + hint: Schema.optional(Schema.String), + /** The access the agent suggested; the user's choice is final. */ + requestedAccess: Schema.optional(AuthMcpClientAccess), + linkStatus: OrchestrationV2LinkRequestStatus, + linkedEnvironmentId: Schema.optional(EnvironmentId), + linkedLabel: Schema.optional(Schema.String), + linkedAccess: Schema.optional(AuthMcpClientAccess), + failure: Schema.optional(Schema.String), +} as const; + export const OrchestrationV2TurnItem = Schema.Union([ Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -1695,6 +1731,10 @@ export const OrchestrationV2TurnItem = Schema.Union([ ...OrchestrationV2TurnItemBaseFields, ...OrchestrationV2SecretRequestFields, }), + Schema.Struct({ + ...OrchestrationV2TurnItemBaseFields, + ...OrchestrationV2LinkRequestFields, + }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, type: Schema.Literal("subagent"), @@ -2530,6 +2570,10 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ ...OrchestrationV2TurnItemJsonBaseFields, ...OrchestrationV2SecretRequestFields, }), + Schema.Struct({ + ...OrchestrationV2TurnItemJsonBaseFields, + ...OrchestrationV2LinkRequestFields, + }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, type: Schema.Literal("subagent"), @@ -3434,6 +3478,37 @@ const OrchestrationV2InternalCommand = Schema.Union([ delivery: Schema.optional(OrchestrationV2SecretRequestDelivery), secretStatus: OrchestrationV2SecretRequestStatus, }), + /** + * Records or updates a link an agent asked the user to make. Internal so no + * client can mark a request linked without the link being made. + */ + Schema.Struct({ + type: Schema.Literal("link_request.record"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + nodeId: NodeId, + turnItemId: TurnItemId, + url: Schema.optional(TrimmedNonEmptyString), + reason: Schema.String, + environmentId: Schema.optional(EnvironmentId), + label: Schema.optional(Schema.String), + hint: Schema.optional(Schema.String), + requestedAccess: Schema.optional(AuthMcpClientAccess), + linkStatus: OrchestrationV2LinkRequestStatus, + linkedEnvironmentId: Schema.optional(EnvironmentId), + linkedLabel: Schema.optional(Schema.String), + linkedAccess: Schema.optional(AuthMcpClientAccess), + failure: Schema.optional(Schema.String), + /** Tells the agent how the card ended, in the same commit that closes it. */ + wake: Schema.optional( + Schema.Struct({ + messageId: MessageId, + text: Schema.String, + notification: OrchestrationV2Notification, + }), + ), + }), ]); export type OrchestrationV2InternalCommand = typeof OrchestrationV2InternalCommand.Type; diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 97911133f4db..4a1c5f4ce423 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -619,6 +619,8 @@ export type OrchestratorMcpEnvironmentLink = typeof OrchestratorMcpEnvironmentLi export const OrchestratorMcpEnvironmentLinksResult = Schema.Struct({ environmentId: EnvironmentId, links: Schema.Array(OrchestratorMcpEnvironmentLink), + /** What to do when the machine the user means is not among `links`. */ + notListed: Schema.String, }); export type OrchestratorMcpEnvironmentLinksResult = typeof OrchestratorMcpEnvironmentLinksResult.Type; @@ -764,6 +766,79 @@ export const OrchestratorMcpRequestSecretResult = Schema.Union([ ]); export type OrchestratorMcpRequestSecretResult = typeof OrchestratorMcpRequestSecretResult.Type; +export const OrchestratorMcpEnvironmentLinkInput = Schema.Struct({ + environmentId: Schema.optional(EnvironmentId).annotate({ + description: + "The environment to link, when you know it, e.g. from a machine the user mentioned. Omit it otherwise: the user picks the machine in the card.", + }), + url: Schema.optional(TrimmedNonEmptyString).annotate({ + description: + "Only when the user gave an address: its https, Tailscale, LAN or loopback base URL. Never guess one.", + }), + hint: Schema.optional(TrimmedNonEmptyString).annotate({ + description: + 'A short phrase from the user\'s words naming the machine, e.g. "vps" or "mac mini". It only preselects a machine in the card.', + }), + reason: Schema.optional(TrimmedNonEmptyString).annotate({ + description: "One sentence on why, shown on the card.", + }), + requestedAccess: Schema.optional(AuthMcpClientAccess).annotate({ + description: + "The access to suggest. The user picks the access in the card; it defaults to approval-required.", + }), + clientRequestId: Schema.optional(OrchestratorMcpClientRequestId).annotate({ + description: + "Reuse when retrying a call that lost its result, so the user sees one card and its current outcome is returned. Use a new id to ask again.", + }), +}); +export type OrchestratorMcpEnvironmentLinkInput = typeof OrchestratorMcpEnvironmentLinkInput.Type; + +export const OrchestratorMcpEnvironmentLinkResult = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("pending").annotate({ + description: + "The card is waiting for the user. Their answer arrives as a message in this thread.", + }), + turnItemId: TurnItemId, + }), + Schema.Struct({ + status: Schema.Literal("linked"), + environmentId: EnvironmentId.annotate({ + description: "Pass it as environmentId to act there; t3_environment_links lists it.", + }), + label: Schema.String, + access: AuthMcpClientAccess.annotate({ description: "The access the user chose." }), + }), + Schema.Struct({ + status: Schema.Literals(["declined", "cancelled"]).annotate({ + description: + "declined: the user chose not to. cancelled: the card closed unanswered, when the thread was stopped or archived.", + }), + }), + Schema.Struct({ + status: Schema.Literal("failed"), + message: Schema.String.annotate({ + description: "Why the other environment refused the link, as the user saw it.", + }), + }), +]); +export type OrchestratorMcpEnvironmentLinkResult = typeof OrchestratorMcpEnvironmentLinkResult.Type; + +export const OrchestratorMcpEnvironmentUnlinkInput = Schema.Struct({ + environmentId: EnvironmentId.annotate({ + description: "The linked environment to forget, from t3_environment_links.", + }), +}); +export type OrchestratorMcpEnvironmentUnlinkInput = + typeof OrchestratorMcpEnvironmentUnlinkInput.Type; + +export const OrchestratorMcpEnvironmentUnlinkResult = Schema.Struct({ + environmentId: EnvironmentId, + removed: Schema.Boolean, +}); +export type OrchestratorMcpEnvironmentUnlinkResult = + typeof OrchestratorMcpEnvironmentUnlinkResult.Type; + export const OrchestratorMcpDeleteScheduledTaskInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, scheduledTaskId: ScheduledTaskId, diff --git a/packages/contracts/src/peerLink.ts b/packages/contracts/src/peerLink.ts index 022b1b9b9f8c..7882e8d2c74c 100644 --- a/packages/contracts/src/peerLink.ts +++ b/packages/contracts/src/peerLink.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { AuthMcpClientAccess } from "./auth.ts"; -import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts"; /** * A link from this environment to another one (the peer). This environment @@ -40,6 +40,13 @@ export const PeerLinkCreateInput = Schema.Struct({ /** A pairing code from the peer (`t3 pair` there, or its Connections settings). */ pairingCode: TrimmedNonEmptyString, access: AuthMcpClientAccess, + /** + * The environment the user means. Addresses that answer as another one are + * skipped, and only those that answer as this one are kept. + */ + expectedEnvironmentId: Schema.optionalKey(EnvironmentId), + /** What the user calls that environment, to name it when it cannot be reached. */ + expectedLabel: Schema.optionalKey(TrimmedNonEmptyString), }); export type PeerLinkCreateInput = typeof PeerLinkCreateInput.Type; @@ -64,3 +71,60 @@ export class PeerLinkError extends Schema.TaggedError()("PeerLink message: Schema.String, cause: Schema.optionalKey(Schema.Defect()), }) {} + +/** + * The user's answer to an agent asking to link an environment. The pairing + * code is used once to link and is never recorded; the thread learns only the + * outcome and the access the user chose. + */ +export const PeerLinkRequestAnswerInput = Schema.Struct({ + threadId: ThreadId, + turnItemId: TurnItemId, + answer: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("link"), + /** The machine the user picked, when they picked one rather than typing an address. */ + environmentId: Schema.optionalKey(EnvironmentId), + /** What the client calls that machine. */ + label: Schema.optionalKey(TrimmedNonEmptyString), + /** The addresses the client knows for that machine, most preferred first. */ + urls: Schema.NonEmptyArray(TrimmedNonEmptyString), + access: AuthMcpClientAccess, + pairingCode: TrimmedNonEmptyString, + }), + /** Answers with a machine this environment is already linked to, as it is. */ + Schema.Struct({ type: Schema.Literal("use-existing"), environmentId: EnvironmentId }), + Schema.Struct({ type: Schema.Literal("decline") }), + ]), +}); +export type PeerLinkRequestAnswerInput = typeof PeerLinkRequestAnswerInput.Type; + +const PEER_LINK_REQUEST_FAILURE_MESSAGES = { + load_failed: "Could not load the link request.", + not_found: "This link request no longer exists.", + already_answered: "This link request was already answered.", + record_failed: "Could not update the link request.", +} as const; + +export const PeerLinkRequestFailureReason = Schema.Literals( + Object.keys(PEER_LINK_REQUEST_FAILURE_MESSAGES) as Array< + keyof typeof PEER_LINK_REQUEST_FAILURE_MESSAGES + >, +); +export type PeerLinkRequestFailureReason = typeof PeerLinkRequestFailureReason.Type; + +/** + * Answering a link request failed before anything was linked. A link the + * other environment refused is not this error: the card records it as failed. + */ +export class PeerLinkRequestError extends Schema.TaggedError()( + "PeerLinkRequestError", + { + reason: PeerLinkRequestFailureReason, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return PEER_LINK_REQUEST_FAILURE_MESSAGES[this.reason]; + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e0a6486668e1..9c32c6de25bd 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -22,6 +22,8 @@ import { PeerLinkError, PeerLinkRemoveInput, PeerLinkRemoveResult, + PeerLinkRequestAnswerInput, + PeerLinkRequestError, PeerLinkSummary, } from "./peerLink.ts"; import { @@ -497,6 +499,7 @@ export const WS_METHODS = { peerLinksList: "peerLinks.list", peerLinksLink: "peerLinks.link", peerLinksUnlink: "peerLinks.unlink", + peerLinksAnswerRequest: "peerLinks.answerRequest", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -797,6 +800,11 @@ const WsPeerLinksUnlinkRpc = Rpc.make(WS_METHODS.peerLinksUnlink, { error: Schema.Union([PeerLinkError, EnvironmentAuthorizationError]), }); +const WsPeerLinksAnswerRequestRpc = Rpc.make(WS_METHODS.peerLinksAnswerRequest, { + payload: PeerLinkRequestAnswerInput, + error: Schema.Union([PeerLinkRequestError, EnvironmentAuthorizationError]), +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1977,6 +1985,7 @@ export const WsRpcGroup = RpcGroup.make( WsPeerLinksListRpc, WsPeerLinksLinkRpc, WsPeerLinksUnlinkRpc, + WsPeerLinksAnswerRequestRpc, WsServerDiscoverSourceControlRpc, WsServerSearchAcpRegistryRpc, WsServerPrepareAcpRegistryAgentRpc, diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 7790bed28f9b..613d80bdd29e 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -14,6 +14,7 @@ Ordinary coding tasks need no T3 tools. `- \`t3_thread_launch\` and \`create_threads\` make new top-level threads, only when the user asks for a new thread. "Subagent", "delegate", and "parallel" never mean one. Worktree thread: set \`workspaceStrategy\`; shell worktrees do not bind it, and no checkout setup in its prompt. No \`workspaceStrategy\`: project root, not your worktree. - Given a thread id, \`t3_thread_read\` it before you answer about it. - Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` to a T3 tool to act there. Ids are then that machine's, so pass \`threadId\`. +- A machine the user names that is not linked yet ("delegate this to my vps"): call \`t3_environment_link\` with a \`hint\` from their words, or \`environmentId\` when one names it. The user picks it in a card and the outcome arrives here as a message: do not wait or poll, and never ask for a pairing code in chat. - In your reply, a thread's first mention is its \`link\` verbatim. No \`link\`: \`[title](t3-thread://v1/)\`. Never a plain title or bare id. A thread on a linked machine: its title and machine, not its link. - A secret: call \`request_secret\` yourself. Pass its \`secretRef\` to a T3 tool, or use \`delivery: "file"\` for a shell command. Never ask in chat or invent one. When asked only to schedule, \`schedule_task\` without running its prompt. - T3 tools may load late. Try the name once before you call it absent.`, diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 4c30698db8ce..5848861f47ac 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -52,6 +52,8 @@ export type T3McpToolSummaryAction = | "project-clone" | "environment-read" | "environment-links" + | "environment-link" + | "environment-unlink" | "environment-update" | "attachment-prepare" | "attachment-discard" @@ -326,6 +328,14 @@ const T3_MCP_TOOLS: Readonly> = { ["List", "Listing", "Listed", "linked environments"], "environment-links", ), + t3_environment_link: tool( + ["Ask to link", "Asking to link", "Asked to link", "an environment"], + "environment-link", + ), + t3_environment_unlink: tool( + ["Forget", "Forgetting", "Forgot", "a linked environment"], + "environment-unlink", + ), t3_environment_preferences_update: tool( ["Update", "Updating", "Updated", "environment preferences"], "environment-update", From 014b2e7f2ecd7c7c069a83f081417c6e566ca6ed Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:57:23 -0700 Subject: [PATCH 02/10] fix(server): a link card's id names the provider session that asked A later provider session in the same thread reusing a clientRequestId got the earlier session's card back, even for another machine. The card id now includes the caller's request namespace, as the command id already did. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinkRequests.test.ts | 23 +++++++++++++++++++ apps/server/src/peer/PeerLinkRequests.ts | 7 +++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index 661a4bdef83d..084e5220580f 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -479,6 +479,29 @@ it.live("a retry of a pending request returns pending and shows no second card", ), ); +it.live("a later provider session reusing a clientRequestId gets a card of its own", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + const first = yield* a.call({ url: b.url, clientRequestId: "link" }); + const later = yield* a.call( + { environmentId: box.environmentId, clientRequestId: "link" }, + { + ...scope, + requestNamespace: "provider-session:laptop-2", + thread: { ...scope.thread!, providerSessionId: "provider-session:laptop-2" }, + }, + ); + expect(later.structuredContent).toMatchObject({ status: "pending" }); + expect(later.structuredContent).not.toEqual(first.structuredContent); + const cards = (yield* a.orchestrator.getThreadProjection(threadId)).turnItems.filter( + (item) => item.type === "link_request", + ); + expect(cards).toHaveLength(2); + }), + ), +); + it.live("a declined request links nothing and tells the agent", () => withBoth((a, b) => Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index c66d5782a70c..b4a52745415f 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -174,10 +174,11 @@ const make = Effect.gen(function* () { } } const key = input.clientRequestId ?? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)); - // Turn item ids are global; scope the key to this thread. A retry with - // the same clientRequestId finds this card and records nothing. + // Turn item ids are global; scope the key to this thread and provider + // session, since a later session may reuse it. A retry with the same + // clientRequestId finds this card and records nothing. const turnItemId = TurnItemId.make( - `turn-item:link-request:${encodeURIComponent(threadId)}:${encodeURIComponent(key)}`, + `turn-item:link-request:${encodeURIComponent(threadId)}:${encodeURIComponent(scope.requestNamespace)}:${encodeURIComponent(key)}`, ); if (input.clientRequestId !== undefined) { const existing = yield* readCard(threadId, turnItemId).pipe(Effect.mapError(unreadable)); From 2718b6a9df95f7c2600f09c5bd8ee1ce345eed2f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:01:12 -0700 Subject: [PATCH 03/10] fix(server): an expired link goes through the link card t3_environment_link told the agent a machine was linked whenever a row existed, and "Use this link" accepted it, even after the link expired. The agent then failed on its first call and the user had no way to renew it from the card. Both now treat an expired link as no link, so the card asks for a new pairing code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinkRequests.test.ts | 46 +++++++++++++++++++ apps/server/src/peer/PeerLinkRequests.ts | 32 +++++++++---- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index 084e5220580f..6b671b2cdc26 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -16,8 +16,10 @@ import { ThreadId, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -53,6 +55,7 @@ import * as PeerLinks from "./PeerLinks.ts"; import { descriptorOf, layerLinkingEnvironment, + linkTo, servePeer, type ServedPeer, } from "./PeerLinks.testkit.ts"; @@ -367,6 +370,26 @@ const errorOf = (result: McpSchema.CallToolResult) => { return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; }; +/** Runs `effect` a year from now, when every link made today has expired. */ +const afterLinksExpire = (effect: Effect.Effect) => + Effect.gen(function* () { + const clock = yield* Clock.Clock; + const ahead = Duration.days(365); + const later = () => clock.currentTimeMillisUnsafe() + Duration.toMillis(ahead); + const laterNanos = () => clock.currentTimeNanosUnsafe() + Duration.toNanosUnsafe(ahead); + return yield* effect.pipe( + Effect.provideService(Clock.Clock, { + currentTimeMillisUnsafe: later, + currentTimeMillis: Effect.sync(later), + currentTimeNanosUnsafe: laterNanos, + currentTimeNanos: Effect.sync(laterNanos), + monotonicTimeNanosUnsafe: () => clock.monotonicTimeNanosUnsafe(), + monotonicTimeNanos: clock.monotonicTimeNanos, + sleep: (duration) => clock.sleep(duration), + }), + ); + }); + const withBoth = (body: (a: Laptop, b: ServedPeer) => Effect.Effect) => Effect.scoped( Effect.gen(function* () { @@ -701,6 +724,29 @@ it.live("naming a machine that is already linked returns the link at once, with ), ); +it.live("a link that expired goes through the card, so the user can link it again", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + yield* linkTo(a.links, b, "read-only"); + const asked = yield* a.call({ environmentId: box.environmentId }).pipe(afterLinksExpire); + expect(asked.structuredContent).toMatchObject({ status: "pending" }); + const card = yield* pendingCard(a); + + // Keeping the expired link is no answer: the card fails and says why. + yield* a.requests + .answer({ + threadId, + turnItemId: card.id, + answer: { type: "use-existing", environmentId: box.environmentId }, + }) + .pipe(afterLinksExpire); + expect(yield* cardOf(a)).toMatchObject({ linkStatus: "failed" }); + expect((yield* outcomeMessages(a)).messages[0]!.text).toContain("expired"); + }), + ), +); + it.live( "a card for a machine named by id keeps it, and the user may keep a link it already has", () => diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index b4a52745415f..4f4512fe5278 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -18,6 +18,7 @@ */ import { CommandId, + type EnvironmentId, MessageId, type NodeId, type OrchestrationV2Notification, @@ -35,6 +36,7 @@ import { } 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -111,6 +113,17 @@ const make = Effect.gen(function* () { }), ); + /** + * The stored link to `environmentId`, or nothing once it has expired: an + * expired link needs a new pairing code, which only the card asks for. + */ + const usableLink = (environmentId: EnvironmentId) => + Effect.all([peerLinks.get(environmentId), DateTime.now]).pipe( + Effect.map(([linked, now]) => + Option.filter(linked, (link) => DateTime.isGreaterThan(link.expiresAt, now)), + ), + ); + const record = ( card: LinkRequestAsk, outcome: LinkRequestOutcome, @@ -156,14 +169,12 @@ const make = Effect.gen(function* () { } // A machine already linked needs no card: the agent can use it now. if (input.environmentId !== undefined) { - const linked = yield* peerLinks - .get(input.environmentId) - .pipe( - Effect.mapError( - (error) => - new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), - ), - ); + const linked = yield* usableLink(input.environmentId).pipe( + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), + ), + ); if (Option.isSome(linked)) { return { status: "linked", @@ -251,14 +262,15 @@ const make = Effect.gen(function* () { case "decline": return Effect.succeed({ linkStatus: "declined" }); case "use-existing": - return peerLinks.get(answer.environmentId).pipe( + return usableLink(answer.environmentId).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.fail( new PeerLinkError({ reason: "unknown_link", - message: "That environment is no longer linked here.", + message: + "That environment is no longer linked here, or its link expired. Link it again.", }), ), onSome: (linked) => Effect.succeed(linkedOutcome(linked)), From a501e77d015aae65aa7a93d0baf2167e0558d7ec Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:04:59 -0700 Subject: [PATCH 04/10] fix(server): a link card stays answerable after a steer, and Stop shows no new one The link_request.record command required the run's current root node on every update. A steer that restarts the turn moves the root, so an answer recorded after it was refused once the link had already been made: the card stayed pending, the agent never heard back, and each retry paired again. The root and a live, unstopped run are now required only when a card is created; an update keeps the card's own node and provider turn. The same check closes a race with Stop: a request that read an active run before thread.stop closed the thread's cards could still record a new pending card, which would wake the agent when answered. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 40 ++++++- apps/server/src/peer/PeerLinkRequests.test.ts | 110 ++++++++++++++++++ 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 4521a382a61b..e3a9dc47b30f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8274,11 +8274,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); const run = projection.runs.find((candidate) => candidate.id === command.runId); - if (run === undefined || run.rootNodeId !== command.nodeId) { + if (run === undefined) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Node ${command.nodeId} is not the root of run ${command.runId}.`, + cause: `Run ${command.runId} was not found.`, }); } const existing = projection.turnItems.find((item) => item.id === command.turnItemId); @@ -8296,6 +8296,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Link request ${command.turnItemId} belongs to another run.`, }); } + // Only a new card needs its run's current root and a live run. An answer + // may land long after a steer restarted the run or the run ended, and the + // link it reports is already made. + if (existing === undefined && run.rootNodeId !== command.nodeId) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Node ${command.nodeId} is not the root of run ${command.runId}.`, + }); + } + if ( + existing === undefined && + (!isBlockingRun(run) || (yield* stopReachedRun(command, command.threadId, run.id))) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Run ${command.runId} is not active.`, + }); + } + const nodeId = existing?.nodeId ?? command.nodeId; const now = yield* DateTime.now; // A request is answered once, and a retry finds its card as it was // asked: either one records the card unchanged. @@ -8315,8 +8336,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : { ...fields, id: command.turnItemId, - providerThreadId: run.providerThreadId, - providerTurnId: providerTurnForRun(projection, run)?.id ?? null, + // An update keeps where the card was asked, even after a restart. + ...(existing === undefined + ? { + providerThreadId: run.providerThreadId, + providerTurnId: providerTurnForRun(projection, run)?.id ?? null, + } + : { + nodeId: existing.nodeId, + providerThreadId: existing.providerThreadId, + providerTurnId: existing.providerTurnId, + }), nativeItemRef: null, parentItemId: null, ordinal: existing?.ordinal ?? (yield* nextTurnItemOrdinal(projection)), @@ -8340,7 +8370,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "turn-item.updated", threadId: command.threadId, runId: command.runId, - nodeId: command.nodeId, + nodeId, providerInstanceId: run.providerInstanceId, occurredAt: now, payload: turnItem, diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index 6b671b2cdc26..fcb396e8cc65 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -14,6 +14,7 @@ import { type RuntimeMode, RunId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Clock from "effect/Clock"; @@ -344,6 +345,55 @@ const endRun = (a: Laptop) => }); }); +/** A steer restarts the agent's turn: its run gets a new root node. */ +const restartRun = (a: Laptop) => + Effect.gen(function* () { + const projection = yield* a.orchestrator.getThreadProjection(threadId); + const run = projection.runs.find((candidate) => candidate.id === runId)!; + const node = projection.nodes.find((candidate) => candidate.id === rootNode)!; + const restarted = NodeId.make("node:laptop:root:2"); + const now = yield* DateTime.now; + yield* a.sink.write({ + commandId: CommandId.make("command:restart-run:laptop"), + events: [ + { + id: EventId.make("event:restart-node"), + type: "node.updated", + threadId, + runId, + nodeId: restarted, + driver, + providerInstanceId: instanceId, + occurredAt: now, + payload: { ...node, id: restarted, rootNodeId: restarted, startedAt: now }, + }, + { + id: EventId.make("event:restart-run"), + type: "run.updated", + threadId, + runId, + nodeId: restarted, + providerInstanceId: instanceId, + occurredAt: now, + payload: { ...run, rootNodeId: restarted }, + }, + ], + }); + }); + +/** What `request` records for a new card, as if it read the run before anything changed. */ +const recordPendingCard = (a: Laptop, key: string) => + a.orchestrator.dispatch({ + type: "link_request.record", + commandId: CommandId.make(`command:pending:${key}`), + threadId, + runId, + nodeId: rootNode, + turnItemId: TurnItemId.make(`turn-item:link-request:${key}`), + reason: "", + linkStatus: "pending", + }); + /** * Every row of every table in the laptop's database, as text, to scan for * anything leaked: events, projections, receipts and outbox alike. @@ -593,6 +643,66 @@ it.live("stopping the thread closes its open card, which then takes no answer", ), ); +it.live("a request that read the run before Stop shows no card once Stop lands", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + yield* a.call({ url: b.url }); + const open = yield* pendingCard(a); + yield* a.orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("command:stop:laptop"), + threadId, + }); + const late = yield* recordPendingCard(a, "after-stop").pipe(Effect.flip); + expect(String(late.cause)).toContain("not active"); + const cards = (yield* a.orchestrator.getThreadProjection(threadId)).turnItems.filter( + (item) => item.type === "link_request", + ); + expect(cards.map((card) => card.id)).toEqual([open.id]); + }), + ), +); + +it.live("a card shows up only while its run is going, and takes an answer after", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + yield* a.call({ url: b.url }); + const card = yield* pendingCard(a); + yield* endRun(a); + const late = yield* recordPendingCard(a, "after-end").pipe(Effect.flip); + expect(String(late.cause)).toContain("not active"); + yield* a.requests.answer({ threadId, turnItemId: card.id, answer: { type: "decline" } }); + expect(yield* cardOf(a)).toMatchObject({ linkStatus: "declined" }); + }), + ), +); + +it.live("a steer that restarts the turn leaves its open card answerable", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + yield* a.call({ url: b.url }); + const card = yield* pendingCard(a); + yield* restartRun(a); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.requests.answer({ + threadId, + turnItemId: card.id, + answer: { type: "link", urls: [b.url], access: "auto", pairingCode: pairing.credential }, + }); + // The card keeps the node it was asked from, and the agent hears the answer. + expect(yield* cardOf(a)).toMatchObject({ + linkStatus: "linked", + nodeId: rootNode, + linkedEnvironmentId: box.environmentId, + }); + expect((yield* outcomeMessages(a)).messages).toHaveLength(1); + }), + ), +); + it.live("archiving the thread closes its open card", () => withBoth((a, b) => Effect.gen(function* () { From 31706c9b16ec8264742ea6e6f449a7f51b598896 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:20:17 -0700 Subject: [PATCH 05/10] fix(server): a slow machine no longer holds up every link card's answer Answering a link card took one environment-wide lock and held it across PeerLinks.link, which calls the other machine. A peer that was slow to answer blocked every other card's answer until it timed out. The lock is now per card, through the shared KeyedLock, which is all that keeps two answers to one card from both linking. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinkRequests.test.ts | 71 +++++++++++++++++++ apps/server/src/peer/PeerLinkRequests.ts | 12 ++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index fcb396e8cc65..f4a231dea1ab 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -20,8 +20,12 @@ import { expect, it } from "@effect/vitest"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as HttpServer from "effect/http/HttpServer"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -703,6 +707,73 @@ it.live("a steer that restarts the turn leaves its open card answerable", () => ), ); +it.live("a slow machine holds up only its own card's answer", () => + withBoth((a, b) => + Effect.scoped( + Effect.gen(function* () { + // A machine that takes the descriptor request and answers when the test says. + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + const slow = yield* Layer.build(Layer.fresh(NodeHttpServer.layerTest)); + const server = Context.get(slow, HttpServer.HttpServer); + yield* server.serve( + Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(HttpServerResponse.empty({ status: 404 })), + ), + ); + if (server.address._tag === "UnixPathAddress") return yield* Effect.die("expected TCP"); + const slowUrl = `http://127.0.0.1:${server.address.port}`; + + yield* seedThread(a); + // Recorded directly: asking would probe the slow machine before the answer does. + const slowCard = { id: TurnItemId.make("turn-item:link-request:slow") }; + yield* a.orchestrator.dispatch({ + type: "link_request.record", + commandId: CommandId.make("command:pending:slow"), + threadId, + runId, + nodeId: rootNode, + turnItemId: slowCard.id, + url: slowUrl, + reason: "", + linkStatus: "pending", + }); + yield* a.call({ url: b.url, clientRequestId: "box" }); + const boxCard = (yield* a.orchestrator.getThreadProjection(threadId)).turnItems.find( + (item) => item.type === "link_request" && item.url === b.url, + )!; + + const slowAnswer = yield* a.requests + .answer({ + threadId, + turnItemId: slowCard.id, + answer: { type: "link", urls: [slowUrl], access: "auto", pairingCode: "code" }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(reached); + + // While the slow machine holds its card, the user answers the other one. + yield* a.requests.answer({ threadId, turnItemId: boxCard.id, answer: { type: "decline" } }); + const items = (yield* a.orchestrator.getThreadProjection(threadId)).turnItems; + expect(items.find((item) => item.id === boxCard.id)).toMatchObject({ + linkStatus: "declined", + }); + expect(items.find((item) => item.id === slowCard.id)).toMatchObject({ + linkStatus: "pending", + }); + + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(slowAnswer); + const failed = (yield* a.orchestrator.getThreadProjection(threadId)).turnItems.find( + (item) => item.id === slowCard.id, + ); + expect(failed).toMatchObject({ linkStatus: "failed" }); + }), + ), + ), +); + it.live("archiving the thread closes its open card", () => withBoth((a, b) => Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index 4f4512fe5278..a932a0630bbd 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -40,7 +40,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Semaphore from "effect/Semaphore"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -303,8 +303,9 @@ const make = Effect.gen(function* () { } }; - // One answer at a time: two answers racing on one card must not both link. - const answerLock = yield* Semaphore.make(1); + // One answer per card at a time: two answers racing on one card must not + // both link. Other cards go on while a slow peer holds one. + const answerLocks = yield* KeyedLock.make(); const answer: PeerLinkRequests["Service"]["answer"] = (input) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ @@ -336,7 +337,10 @@ const make = Effect.gen(function* () { ).pipe( Effect.mapError((cause) => new PeerLinkRequestError({ reason: "record_failed", cause })), ); - }).pipe(answerLock.withPermits(1), Effect.withSpan("PeerLinkRequests.answer")); + }).pipe( + (effect) => answerLocks.withLock(input.turnItemId, effect), + Effect.withSpan("PeerLinkRequests.answer"), + ); return PeerLinkRequests.of({ request, answer }); }); From af808565e41e2fd0c1fa6139cb3620105ce8e917 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:21:55 -0700 Subject: [PATCH 06/10] fix(web): the link dialog starts fresh each time it opens A successful answer and Cancel closed the dialog without the reset the dialog's own close ran, so reopening it showed the last step, machine, address, access, error and pairing code. Every close now goes through one function that resets; a close while linking is still refused. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/LinkRequestCard.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index 52dc9a62e0ab..6cf580bc7dd9 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -262,6 +262,12 @@ function LinkRequestDialog(props: { setError(null); }; + // Every close resets, so a reopened dialog never shows the last answer or its pairing code. + const close = () => { + props.onOpenChange(false); + reset(); + }; + const submit = async () => { if (inFlight.current || target.kind === "none") return; inFlight.current = true; @@ -299,8 +305,7 @@ function LinkRequestDialog(props: { const result = await answer({ environmentId: props.environmentId, input }); if (result._tag === "Success") { // The card switches to its answered row once the item updates. - setPairingCode(""); - props.onOpenChange(false); + close(); return; } if (!isAtomCommandInterrupted(result)) { @@ -321,8 +326,8 @@ function LinkRequestDialog(props: { onOpenChange={(next) => { // An answer that lands after a close would still link, and would close a reopened dialog. if (!next && busy) return; - props.onOpenChange(next); - if (!next) reset(); + if (next) props.onOpenChange(true); + else close(); }} > @@ -441,12 +446,7 @@ function LinkRequestDialog(props: { Back ) : ( - )} From 8c5d65de34ef94774cbf607d1cc24acea2afa70e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:24:44 -0700 Subject: [PATCH 07/10] fix(web,mobile): each link answer mints its own pairing code The mint went through one atom per environment, refreshed and then read on every call, so two overlapping answers could both receive the same one-use code and one link failed with the code already spent. Minting now runs as an environment command, which builds a fresh effect for every call, shared by web and mobile through client-runtime. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/peerPairingCode.ts | 41 ++++--------------- apps/web/src/state/peerPairingCode.ts | 41 ++++--------------- .../src/state/peerPairingCode.ts | 32 ++++++++++++++- 3 files changed, 49 insertions(+), 65 deletions(-) diff --git a/apps/mobile/src/state/peerPairingCode.ts b/apps/mobile/src/state/peerPairingCode.ts index 6aea8e4bfaa5..789278078bff 100644 --- a/apps/mobile/src/state/peerPairingCode.ts +++ b/apps/mobile/src/state/peerPairingCode.ts @@ -1,42 +1,19 @@ -import { mintPeerPairingCode } from "@t3tools/client-runtime/state/peerPairingCode"; +import { createPeerPairingCodeCommand } from "@t3tools/client-runtime/state/peerPairingCode"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { Atom, AtomRegistry } from "effect/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; -import { environmentSession } from "./session"; -// Each read mints a new one-use code; nothing keeps one once it is read. -const peerPairingCodeAtom = Atom.family((environmentId: EnvironmentId) => - connectionAtomRuntime - .atom((get) => { - const prepared = Option.getOrNull( - get(environmentSession.preparedConnectionValueAtom(environmentId)), - ); - if (prepared === null) return Effect.never; - return mintPeerPairingCode(prepared); - }) - .pipe(Atom.withLabel(`mobile-peer-pairing-code:${environmentId}`)), -); +const mintPairingCode = createPeerPairingCodeCommand(connectionAtomRuntime); /** * A fresh pairing code minted on `environmentId` with this client's session - * there, for answering a link request. Fails with `PeerPairingCodeError` when - * the session may not create one. + * there, for answering a link request. Rejects with `PeerPairingCodeError` + * when the session may not create one. */ -export function mintPairingCodeOn(environmentId: EnvironmentId): Promise { - const atom = peerPairingCodeAtom(environmentId); - appAtomRegistry.refresh(atom); - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* AtomRegistry.mount(appAtomRegistry, atom); - return yield* AtomRegistry.getResult(appAtomRegistry, atom, { - suspendOnWaiting: true, - }).pipe(Effect.timeout("15 seconds")); - }), - ), - ); +export async function mintPairingCodeOn(environmentId: EnvironmentId): Promise { + const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: undefined }); + if (result._tag === "Success") return result.value; + throw squashAtomCommandFailure(result); } diff --git a/apps/web/src/state/peerPairingCode.ts b/apps/web/src/state/peerPairingCode.ts index dc3afa7b3ff5..9e3fa1fe5434 100644 --- a/apps/web/src/state/peerPairingCode.ts +++ b/apps/web/src/state/peerPairingCode.ts @@ -1,42 +1,19 @@ -import { mintPeerPairingCode } from "@t3tools/client-runtime/state/peerPairingCode"; +import { createPeerPairingCodeCommand } from "@t3tools/client-runtime/state/peerPairingCode"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { Atom, AtomRegistry } from "effect/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { environmentSession } from "./session"; -// Each read mints a new one-use code; nothing keeps one once it is read. -const peerPairingCodeAtom = Atom.family((environmentId: EnvironmentId) => - connectionAtomRuntime - .atom((get) => { - const prepared = Option.getOrNull( - get(environmentSession.preparedConnectionValueAtom(environmentId)), - ); - if (prepared === null) return Effect.never; - return mintPeerPairingCode(prepared); - }) - .pipe(Atom.withLabel(`peer-pairing-code:${environmentId}`)), -); +const mintPairingCode = createPeerPairingCodeCommand(connectionAtomRuntime); /** * A fresh pairing code minted on `environmentId` with this client's session - * there, for answering a link request. Fails with `PeerPairingCodeError` when - * the session may not create one. + * there, for answering a link request. Rejects with `PeerPairingCodeError` + * when the session may not create one. */ -export function mintPairingCodeOn(environmentId: EnvironmentId): Promise { - const atom = peerPairingCodeAtom(environmentId); - appAtomRegistry.refresh(atom); - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - yield* AtomRegistry.mount(appAtomRegistry, atom); - return yield* AtomRegistry.getResult(appAtomRegistry, atom, { - suspendOnWaiting: true, - }).pipe(Effect.timeout("15 seconds")); - }), - ), - ); +export async function mintPairingCodeOn(environmentId: EnvironmentId): Promise { + const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: undefined }); + if (result._tag === "Success") return result.value; + throw squashAtomCommandFailure(result); } diff --git a/packages/client-runtime/src/state/peerPairingCode.ts b/packages/client-runtime/src/state/peerPairingCode.ts index 0799c1cd7115..a9d5ee9cabdb 100644 --- a/packages/client-runtime/src/state/peerPairingCode.ts +++ b/packages/client-runtime/src/state/peerPairingCode.ts @@ -12,11 +12,17 @@ import { import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import type * as HttpClient from "effect/http/HttpClient"; +import * as Option from "effect/Option"; +import type { Atom } from "effect/reactivity"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import type { PreparedConnection } from "../connection/model.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; +import { createEnvironmentCommand } from "./runtime.ts"; const MINT_TIMEOUT_MS = 8_000; const PEER_PAIRING_CODE_LABEL = "T3 Code link"; @@ -38,7 +44,7 @@ export class PeerPairingCodeError extends Data.TaggedError("PeerPairingCodeError } } -export const mintPeerPairingCode = Effect.fn("clientRuntime.state.mintPeerPairingCode")(function* ( +const mintPeerPairingCode = Effect.fn("clientRuntime.state.mintPeerPairingCode")(function* ( prepared: PreparedConnection, ): Effect.fn.Return { const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); @@ -73,3 +79,27 @@ export const mintPeerPairingCode = Effect.fn("clientRuntime.state.mintPeerPairin ); return minted.credential; }); + +/** + * Mints a code on the environment it runs in. Each run mints its own: a code + * is one-use, so two overlapping answers must never share one. + */ +export function createPeerPairingCodeCommand( + runtime: Atom.AtomRuntime, +) { + return createEnvironmentCommand(runtime, { + label: "environment-data:peer-pairing-code:mint", + execute: (_input: void) => + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new PeerPairingCodeError({ + reason: "unavailable", + cause: "The environment is not connected.", + }); + } + return yield* mintPeerPairingCode(prepared.value); + }), + }); +} From 55469c2e634549718804b65f5b03bcaab3519a61 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:40:33 -0700 Subject: [PATCH 08/10] fix(server): a thread waits on its link card after the agent's turn ends A waiting link card counted as the thread asking for input only while its run was live. The agent asks without waiting, so its run usually completes first, and the thread then read as done, could auto-settle, and dropped out of the user's inbox while the card still waited for them. A waiting link card now counts until it is answered or closed; a secret card still counts only while its agent waits in the run. The shell query reads it through a new partial index, so finished history is never scanned, and auto-settlement skips a thread with a waiting link card. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/ProjectionStore.ts | 49 +++++++++++++++---- apps/server/src/peer/PeerLinkRequests.test.ts | 12 ++++- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/072_WaitingLinkRequestIndex.ts | 13 +++++ .../reconcileV2PreviewMigration.test.ts | 2 + 5 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/072_WaitingLinkRequestIndex.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index e115ab0ec5ae..5f7247c0a594 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1139,6 +1139,7 @@ type ShellThreadRow = { readonly blocking_failure_payload_json: string | null; readonly pending_request_payload_json: string | null; readonly pending_secret_request_payload_json: string | null; + readonly pending_link_request_payload_json: string | null; readonly latest_user_message_at: string | null; readonly latest_user_authored_message_at: string | null; readonly has_actionable_proposed_plan: number; @@ -1573,6 +1574,14 @@ function secretRequestAsPendingInput( }; } +/** + * A secret card is answered while its agent waits in the run. A link card's + * agent asked without waiting, so the card waits on the user after its run. + */ +const waitsOnUser = (item: OrchestrationV2TurnItem, liveRunIds: ReadonlySet) => + item.type === "link_request" || + (item.type === "secret_request" && item.runId !== null && liveRunIds.has(item.runId)); + export function threadShellFromProjection( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2ThreadShell { @@ -1612,13 +1621,7 @@ export function threadShellFromProjection( pendingRuntimeRequest = secretRequestAsPendingInput( projection.turnItems - .filter( - (item) => - (item.type === "secret_request" || item.type === "link_request") && - item.status === "waiting" && - item.runId !== null && - liveRunIds.has(item.runId), - ) + .filter((item) => item.status === "waiting" && waitsOnUser(item, liveRunIds)) .toSorted( (left, right) => DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), @@ -5923,10 +5926,19 @@ export const layer: Layer.Layer = WHERE r.thread_id = t.thread_id AND r.status IN ('preparing', 'starting', 'running', 'waiting') AND secret.thread_id = t.thread_id AND secret.run_id = r.run_id - AND secret.type IN ('secret_request', 'link_request') AND secret.status = 'waiting' + AND secret.type = 'secret_request' AND secret.status = 'waiting' ORDER BY secret.updated_at DESC, secret.turn_item_id DESC LIMIT 1 ) AS pending_secret_request_payload_json, + ( + SELECT link.payload_json + FROM orchestration_v2_projection_turn_items link + INDEXED BY orchestration_v2_projection_turn_items_waiting_link_idx + WHERE link.thread_id = t.thread_id + AND link.type = 'link_request' AND link.status = 'waiting' + ORDER BY link.updated_at DESC, link.turn_item_id DESC + LIMIT 1 + ) AS pending_link_request_payload_json, ( SELECT message.updated_at FROM orchestration_v2_projection_messages message @@ -6221,6 +6233,12 @@ export const layer: Layer.Layer = SELECT 1 FROM orchestration_v2_projection_runtime_requests request WHERE request.thread_id = t.thread_id AND request.status = 'pending' ) + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items link + INDEXED BY orchestration_v2_projection_turn_items_waiting_link_idx + WHERE link.thread_id = t.thread_id + AND link.type = 'link_request' AND link.status = 'waiting' + ) ORDER BY t.updated_at ASC, t.thread_id ASC `; if (rows.length === 0) return []; @@ -6385,6 +6403,14 @@ export const layer: Layer.Layer = pendingTurnItemsByThreadId, } = input; const thread = yield* decodeThreadPayload(row.payload_json); + const waitingCards = [ + row.pending_secret_request_payload_json === null + ? null + : yield* decodeTurnItemPayload(row.pending_secret_request_payload_json), + row.pending_link_request_payload_json === null + ? null + : yield* decodeTurnItemPayload(row.pending_link_request_payload_json), + ].filter((item) => item !== null); let pendingRuntimeRequest = row.pending_request_payload_json !== null ? pendingRuntimeRequestSummary( @@ -6393,11 +6419,14 @@ export const layer: Layer.Layer = : null; if ( (pendingRuntimeRequest === null || pendingRuntimeRequest.blocking === false) && - row.pending_secret_request_payload_json !== null + waitingCards.length > 0 ) { pendingRuntimeRequest = secretRequestAsPendingInput( - yield* decodeTurnItemPayload(row.pending_secret_request_payload_json), + waitingCards.toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), + )[0] ?? null, ) ?? pendingRuntimeRequest; } let terminalFailureItem = diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index f4a231dea1ab..5ec7aff82b46 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -181,6 +181,7 @@ const makeLaptop = Effect.gen(function* () { requests: Context.get(here, PeerLinkRequests.PeerLinkRequests), orchestrator: Context.get(here, Orchestrator.OrchestratorV2), sink: Context.get(here, EventSink.EventSinkV2), + projections: Context.get(here, ProjectionStore.ProjectionStoreV2), sql: Context.get(database, SqlClient.SqlClient), call: (args: Record, callScope = scope) => server @@ -480,9 +481,18 @@ it.live("the agent asks without waiting, and the user links the box after its tu { kind: "user_input" }, ); - // The agent ends its turn; the card stays answerable. + // The agent ends its turn; the card stays answerable, and the thread still asks. yield* endRun(a); expect(yield* cardOf(a)).toMatchObject({ status: "waiting", linkStatus: "pending" }); + expect((yield* a.orchestrator.getThreadShell(threadId))?.pendingRuntimeRequest).toMatchObject( + { kind: "user_input" }, + ); + expect( + ProjectionStore.threadShellFromProjection( + yield* a.orchestrator.getThreadProjection(threadId), + ).pendingRuntimeRequest, + ).toMatchObject({ kind: "user_input" }); + expect(yield* a.projections.getSettlementCandidates(threadId)).toEqual([]); // The user's client mints a code on the box and picks more than the agent suggested. const pairing = yield* b.auth.issuePairingCredential(); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 86a795612d43..cc395406eb50 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,6 +74,7 @@ import Migration0068 from "./Migrations/068_ShellStopRequestIndex.ts"; import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; import Migration0070 from "./Migrations/070_PeerLinks.ts"; import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; +import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -148,6 +149,7 @@ const migrationEntries = [ [69, "ThreadMessageSearch", Migration0069], [70, "PeerLinks", Migration0070], [71, "PeerLinkFence", Migration0071], + [72, "WaitingLinkRequestIndex", Migration0072], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/072_WaitingLinkRequestIndex.ts b/apps/server/src/persistence/Migrations/072_WaitingLinkRequestIndex.ts new file mode 100644 index 000000000000..e025a00d49ba --- /dev/null +++ b/apps/server/src/persistence/Migrations/072_WaitingLinkRequestIndex.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Keep this predicate aligned with ProjectionStore's waiting link card query. + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_waiting_link_idx + ON orchestration_v2_projection_turn_items(thread_id, updated_at DESC, turn_item_id DESC) + WHERE type = 'link_request' AND status = 'waiting' + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 802f77beb34d..91e40578091a 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -51,6 +51,7 @@ describe("V2 preview upgrade", () => { [69, "ThreadMessageSearch"], [70, "PeerLinks"], [71, "PeerLinkFence"], + [72, "WaitingLinkRequestIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -145,6 +146,7 @@ describe("V2 preview upgrade", () => { [69, "ThreadMessageSearch"], [70, "PeerLinks"], [71, "PeerLinkFence"], + [72, "WaitingLinkRequestIndex"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 913c77dc1819babc539f992c7232647ea5e1273a Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:06:17 -0700 Subject: [PATCH 09/10] feat(web,mobile): pick a T3 Connect machine when linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linking asked for the other machine's address by hand unless an agent asked first. Settings → Linked environments now opens on a machine dropdown, and both it and the agent's link card list the machines on the user's T3 Connect account next to the saved ones, at their Connect address. When this client may mint a pairing code there it does; when it can't (a Connect-only machine, or a session without access:write), it asks for a code but no longer for the address. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/LinkRequestCard.tsx | 90 +++++----- apps/mobile/src/state/peerLinks.ts | 27 +-- apps/mobile/src/state/peerPairingCode.ts | 14 +- .../src/components/chat/LinkRequestCard.tsx | 102 +++++------ .../components/settings/LinkMachineSelect.tsx | 87 +++++++++ .../settings/LinkedEnvironmentsSettings.tsx | 167 ++++++++++++++---- apps/web/src/state/peerLinks.ts | 26 ++- apps/web/src/state/peerPairingCode.ts | 14 +- docs/user/remote-access.md | 14 +- .../client-runtime/src/linkRequest.test.ts | 92 +++++++++- packages/client-runtime/src/linkRequest.ts | 49 +++-- 11 files changed, 497 insertions(+), 185 deletions(-) create mode 100644 apps/web/src/components/settings/LinkMachineSelect.tsx diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 5847e94a9e6f..03b2f3ba02d1 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -21,6 +21,7 @@ import { type OrchestrationV2ProjectedTurnItem, } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import { useMemo, useRef, useState } from "react"; import { Modal, Platform, Pressable, ScrollView, View, type ColorValue } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -266,52 +267,51 @@ function LinkRequestSheet(props: { (useExisting || ((!needsAddress || address.trim().length > 0) && (canMint || pairingCode.trim().length > 0))); + const prepareAnswer = async () => { + if (target.kind === "none") return null; + if (target.kind === "machine" && target.linked) { + return linkRequestAnswerInput(item, { + type: "use-existing", + environmentId: target.environmentId, + }); + } + let code = pairingCode; + if (canMint && targetId !== null) { + const minted = await mintPairingCodeOn(targetId); + if (Result.isFailure(minted)) { + setMintFailed((failed) => new Set(failed).add(targetId)); + setError(linkRequestFailureMessage(minted.failure)); + return null; + } + code = minted.success; + } + return linkRequestAnswerInput(item, { + type: "link", + target: + target.kind === "address" + ? { url: address } + : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, + access, + pairingCode: code, + }); + }; + const submit = async () => { if (inFlight.current || target.kind === "none") return; inFlight.current = true; setBusy(true); setError(null); - try { - let input; - if (target.kind === "machine" && target.linked) { - input = linkRequestAnswerInput(item, { - type: "use-existing", - environmentId: target.environmentId, - }); - } else { - let code = pairingCode; - if (canMint && targetId !== null) { - try { - code = await mintPairingCodeOn(targetId); - } catch (cause) { - setMintFailed((failed) => new Set(failed).add(targetId)); - setError(linkRequestFailureMessage(cause)); - return; - } - } - input = linkRequestAnswerInput(item, { - type: "link", - target: - target.kind === "address" - ? { url: address } - : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, - access, - pairingCode: code, - }); - } - if (input === null) return; - const result = await answer({ environmentId: props.environmentId, input }); - if (result._tag === "Success") { - setPairingCode(""); - props.onClose(); - return; - } - if (!isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); - } - } finally { - inFlight.current = false; - setBusy(false); + const input = await prepareAnswer(); + const result = + input === null ? null : await answer({ environmentId: props.environmentId, input }); + inFlight.current = false; + setBusy(false); + if (result === null) return; + if (result._tag === "Success") { + setPairingCode(""); + props.onClose(); + } else if (!isAtomCommandInterrupted(result)) { + setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); } }; @@ -470,7 +470,13 @@ function MachineRow(props: { readonly onPress: () => void; }) { const { machine } = props; - const status = machine.linked ? "Linked" : machine.connected ? null : "Not connected"; + const status = machine.linked + ? "Linked" + : machine.connected + ? null + : machine.connectOnly + ? "T3 Connect" + : "Not connected"; return ( { + if (discovery.environments.size === 0 && !discovery.refreshing) void refreshDiscovery(); + }); + useEffect(() => { + if (enabled) discoverIfUnasked(); + }, [enabled]); const links = useEnvironmentQuery( enabled ? peerLinkEnvironment.list({ environmentId, input: {} }) : null, ); @@ -28,12 +40,7 @@ export function useLinkableMachines(environmentId: EnvironmentId, enabled: boole environments, threadEnvironmentId: environmentId, linkedIds: new Set(links.data?.links.map((link) => link.environmentId)), - connectAddresses: new Map( - [...discovery.environments].map(([id, found]) => [ - id, - found.environment.endpoint.httpBaseUrl, - ]), - ), + connect: discovery.environments, }), [discovery.environments, environmentId, environments, links.data], ); diff --git a/apps/mobile/src/state/peerPairingCode.ts b/apps/mobile/src/state/peerPairingCode.ts index 789278078bff..2d2d3de20c1a 100644 --- a/apps/mobile/src/state/peerPairingCode.ts +++ b/apps/mobile/src/state/peerPairingCode.ts @@ -1,6 +1,7 @@ import { createPeerPairingCodeCommand } from "@t3tools/client-runtime/state/peerPairingCode"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; +import * as Result from "effect/Result"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; @@ -9,11 +10,14 @@ const mintPairingCode = createPeerPairingCodeCommand(connectionAtomRuntime); /** * A fresh pairing code minted on `environmentId` with this client's session - * there, for answering a link request. Rejects with `PeerPairingCodeError` - * when the session may not create one. + * there, for answering a link request. Fails with `PeerPairingCodeError` when + * the session may not create one. */ -export async function mintPairingCodeOn(environmentId: EnvironmentId): Promise { +export async function mintPairingCodeOn( + environmentId: EnvironmentId, +): Promise> { const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: undefined }); - if (result._tag === "Success") return result.value; - throw squashAtomCommandFailure(result); + return result._tag === "Success" + ? Result.succeed(result.value) + : Result.fail(squashAtomCommandFailure(result)); } diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index 6cf580bc7dd9..a7e5dde46658 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -21,8 +21,9 @@ import { type OrchestrationV2ProjectedTurnItem, } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import { CheckIcon, LinkIcon, MinusIcon, XIcon } from "lucide-react"; -import { useId, useMemo, useRef, useState } from "react"; +import { useId, useRef, useState } from "react"; import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; import { mintPairingCodeOn } from "../../state/peerPairingCode"; @@ -32,7 +33,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { accessConfig } from "../auth/ConnectAgentSurface"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { LinkAccessPicker } from "../settings/LinkAccessPicker"; -import { Badge } from "../ui/badge"; +import { ANOTHER_ADDRESS, LinkMachineBadge } from "../settings/LinkMachineSelect"; import { Button } from "../ui/button"; import { Dialog } from "../ui/dialog"; import { Input } from "../ui/input"; @@ -42,9 +43,6 @@ import { WorkLogRow } from "./WorkLog"; const accessLabel = (access: AuthMcpClientAccess) => accessConfig[access].label; -/** The machine list's row for linking by an address typed in. */ -const ANOTHER_ADDRESS = "another-address"; - /** Whether the agent left the machine for the user to pick. */ const picksMachine = (item: LinkRequestItem) => item.environmentId === undefined && item.url === undefined; @@ -211,7 +209,7 @@ function LinkRequestDialog(props: { const formId = useId(); const pickMachine = picksMachine(item); const listed = useLinkableMachines(props.environmentId, props.open); - const machines = useMemo(() => rankMachinesByHint(listed, item.hint), [listed, item.hint]); + const machines = rankMachinesByHint(listed, item.hint); // Null until the user picks; until then the hint's best match is picked. const [choice, setChoice] = useState(null); const [address, setAddress] = useState(item.url ?? ""); @@ -268,52 +266,52 @@ function LinkRequestDialog(props: { reset(); }; + // Settles to the answer to send, an error to show, or nothing to do. + const prepareAnswer = async () => { + if (target.kind === "none") return null; + if (target.kind === "machine" && target.linked) { + return linkRequestAnswerInput(item, { + type: "use-existing", + environmentId: target.environmentId, + }); + } + let code = pairingCode; + if (canMint && targetId !== null) { + const minted = await mintPairingCodeOn(targetId); + if (Result.isFailure(minted)) { + setMintFailed((failed) => new Set(failed).add(targetId)); + setError(linkRequestFailureMessage(minted.failure)); + return null; + } + code = minted.success; + } + return linkRequestAnswerInput(item, { + type: "link", + target: + target.kind === "address" + ? { url: address } + : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, + access, + pairingCode: code, + }); + }; + const submit = async () => { if (inFlight.current || target.kind === "none") return; inFlight.current = true; setBusy(true); setError(null); - try { - let input; - if (target.kind === "machine" && target.linked) { - input = linkRequestAnswerInput(item, { - type: "use-existing", - environmentId: target.environmentId, - }); - } else { - let code = pairingCode; - if (canMint && targetId !== null) { - try { - code = await mintPairingCodeOn(targetId); - } catch (cause) { - setMintFailed((failed) => new Set(failed).add(targetId)); - setError(linkRequestFailureMessage(cause)); - return; - } - } - input = linkRequestAnswerInput(item, { - type: "link", - target: - target.kind === "address" - ? { url: address } - : { ...target, urls: needsAddress ? [address.trim()] : target.urls }, - access, - pairingCode: code, - }); - } - if (input === null) return; - const result = await answer({ environmentId: props.environmentId, input }); - if (result._tag === "Success") { - // The card switches to its answered row once the item updates. - close(); - return; - } - if (!isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); - } - } finally { - inFlight.current = false; - setBusy(false); + const input = await prepareAnswer(); + const result = + input === null ? null : await answer({ environmentId: props.environmentId, input }); + inFlight.current = false; + setBusy(false); + if (result === null) return; + if (result._tag === "Success") { + // The card switches to its answered row once the item updates. + close(); + } else if (!isAtomCommandInterrupted(result)) { + setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); } }; @@ -493,15 +491,7 @@ function MachinePicker(props: { className="size-4 shrink-0 text-muted-foreground" /> {machine.label} - {machine.linked ? ( - - Linked - - ) : machine.connected ? null : ( - - Not connected - - )} + ))}
--pairing-code --access `. You can also ask an agent to link a machine by name, such as "delegate this to -my vps". It shows a card in the thread where you pick the machine from the ones -this app knows (or enter an address) and the access, then approve. If this app -is connected to that machine with a session that manages access there, it uses -that session; otherwise paste a pairing code from that machine's -**Settings → Connections**. +my vps". It shows a card in the thread where you pick the machine the same way +and the access, then approve. - The access you pick is the most agents from this machine may do there. An agent also never gets more there than its own mode here. diff --git a/packages/client-runtime/src/linkRequest.test.ts b/packages/client-runtime/src/linkRequest.test.ts index 29ec717f9456..1176f77b5ac2 100644 --- a/packages/client-runtime/src/linkRequest.test.ts +++ b/packages/client-runtime/src/linkRequest.test.ts @@ -1,10 +1,12 @@ import { EnvironmentId, type ServerConfig, ThreadId, TurnItemId } from "@t3tools/contracts"; +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; import { describe, expect, it } from "vite-plus/test"; import { BearerConnectionProfile } from "./connection/catalog.ts"; import { BearerConnectionTarget, RelayConnectionTarget } from "./connection/model.ts"; import type { EnvironmentPresentation } from "./connection/presentation.ts"; +import type { RelayDiscoveredEnvironment } from "./relay/discovery.ts"; import { linkableMachines, linkRequestAnswerInput, @@ -142,6 +144,45 @@ const presentation = ( }; }; +const discovered = ( + id: string, + label: string, + machine?: "mac-mini", +): RelayDiscoveredEnvironment => ({ + environment: { + environmentId: EnvironmentId.make(id), + label, + endpoint: { + httpBaseUrl: `https://${id}.t3.example`, + wsBaseUrl: `wss://${id}.t3.example`, + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-10-01T00:00:00.000Z", + }, + availability: "online", + status: + machine === undefined + ? Option.none() + : Option.some({ + environmentId: EnvironmentId.make(id), + endpoint: { + httpBaseUrl: `https://${id}.t3.example`, + wsBaseUrl: `wss://${id}.t3.example`, + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-10-01T00:00:00.000Z", + descriptor: { + environmentId: EnvironmentId.make(id), + label, + platform: { os: "darwin", arch: "arm64", machine }, + serverVersion: "1.0.0", + capabilities: {}, + }, + } as unknown as RelayEnvironmentStatusResponse), + error: Option.none(), +}); + describe("linkableMachines", () => { it("lists the other machines with reported addresses before saved ones, once each", () => { const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); @@ -155,7 +196,7 @@ describe("linkableMachines", () => { environments: [here, vps, mini], threadEnvironmentId: here.environmentId, linkedIds: new Set([mini.environmentId]), - connectAddresses: new Map(), + connect: new Map(), }), ).toEqual([ { @@ -163,6 +204,7 @@ describe("linkableMachines", () => { label: "VPS", machine: "laptop", connected: true, + connectOnly: false, linked: false, urls: ["https://vps.ts.net", "https://vps.example.com"], }, @@ -171,6 +213,7 @@ describe("linkableMachines", () => { label: "Mini", machine: "server", connected: false, + connectOnly: false, linked: true, urls: [], }, @@ -184,19 +227,51 @@ describe("linkableMachines", () => { environments: [here, box], threadEnvironmentId: here.environmentId, linkedIds: new Set(), - connectAddresses: new Map([[box.environmentId, "https://box.t3.example"]]), + connect: new Map([[box.environmentId, discovered(box.environmentId, "Box")]]), }); - expect(listed?.urls).toEqual(["https://box.t3.example"]); + expect(listed?.urls).toEqual(["https://environment-box.t3.example"]); // Until discovery has found it, there is no address to offer. expect( linkableMachines({ environments: [here, box], threadEnvironmentId: here.environmentId, linkedIds: new Set(), - connectAddresses: new Map(), + connect: new Map(), })[0]?.urls, ).toEqual([]); }); + + it("offers machines only on T3 Connect after the saved ones, at their Connect address", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const vps = presentation("environment-vps", "VPS", { saved: ["https://vps.ts.net"] }); + const listed = linkableMachines({ + environments: [here, vps], + threadEnvironmentId: here.environmentId, + linkedIds: new Set([EnvironmentId.make("environment-old")]), + connect: new Map([ + // This environment and saved machines are listed once, as saved. + [here.environmentId, discovered(here.environmentId, "Here")], + [vps.environmentId, discovered(vps.environmentId, "VPS")], + ["environment-mini", discovered("environment-mini", "Mac mini", "mac-mini")], + ["environment-old", discovered("environment-old", "Old box")], + ]), + }); + expect(listed.map((machine) => [machine.label, machine.connectOnly])).toEqual([ + ["VPS", false], + ["Mac mini", true], + ["Old box", true], + ]); + expect(listed[1]).toEqual({ + environmentId: EnvironmentId.make("environment-mini"), + label: "Mac mini", + machine: "mac-mini", + connected: false, + connectOnly: true, + linked: false, + urls: ["https://environment-mini.t3.example"], + }); + expect(listed[2]).toMatchObject({ machine: "server", linked: true }); + }); }); describe("rankMachinesByHint", () => { @@ -223,7 +298,14 @@ describe("rankMachinesByHint", () => { { environmentId: EnvironmentId.make("environment-vps"), label: "Hetzner VPS" }, ].map( (machine) => - ({ ...machine, machine: "server", connected: true, linked: false, urls: [] }) as const, + ({ + ...machine, + machine: "server", + connected: true, + connectOnly: false, + linked: false, + urls: [], + }) as const, ); expect(linkRequestInitialMachine({ hint: "vps" }, listed)).toBe("environment-vps"); // Agents pass the user's words, which name the machine among others. diff --git a/packages/client-runtime/src/linkRequest.ts b/packages/client-runtime/src/linkRequest.ts index 58d6cd5a69c0..27e633672231 100644 --- a/packages/client-runtime/src/linkRequest.ts +++ b/packages/client-runtime/src/linkRequest.ts @@ -6,9 +6,11 @@ import { type PeerLinkRequestAnswerInput, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import type { EnvironmentPresentation } from "./connection/presentation.ts"; import { connectionRoutes, routeHttpBaseUrl } from "./connection/routes.ts"; +import type { RelayDiscoveredEnvironment } from "./relay/discovery.ts"; export type LinkRequestItem = Extract; @@ -27,6 +29,8 @@ export interface LinkableMachine { readonly label: string; readonly machine: EnvironmentMachineKind; readonly connected: boolean; + /** Found on the user's T3 Connect account rather than saved in this client. */ + readonly connectOnly: boolean; /** Whether the thread's environment already has a link to it. */ readonly linked: boolean; /** @@ -40,10 +44,10 @@ export interface LinkableMachine { /** * The machines in the client's catalog other than the thread's own - * environment, in catalog order. `linkedIds` are the environments the thread's - * environment links to already. `connectAddresses` are the T3 Connect - * addresses relay discovery found, by environment id: a server reaches a - * machine there as a browser does, so a Connect route is a usable address. + * environment, in catalog order, then the ones only T3 Connect knows. + * `linkedIds` are the environments the thread's environment links to already. + * `connect` is what relay discovery found, by environment id: a server reaches + * a machine there as a browser does, so its Connect address is a usable one. */ export function linkableMachines(input: { readonly environments: ReadonlyArray< @@ -51,18 +55,20 @@ export function linkableMachines(input: { >; readonly threadEnvironmentId: EnvironmentId; readonly linkedIds: ReadonlySet; - readonly connectAddresses: ReadonlyMap; + readonly connect: ReadonlyMap; }): ReadonlyArray { - return input.environments + const saved = input.environments .filter((environment) => environment.environmentId !== input.threadEnvironmentId) .map((environment) => { const reported = (environment.serverConfig?.directEndpoints ?? []).map( (endpoint) => endpoint.httpBaseUrl, ); - const connect = input.connectAddresses.get(environment.environmentId); - const saved = connectionRoutes(environment.entry).flatMap((route) => { + const connect = input.connect.get(environment.environmentId)?.environment.endpoint; + const routes = connectionRoutes(environment.entry).flatMap((route) => { const url = - route.target._tag === "RelayConnectionTarget" ? connect : routeHttpBaseUrl(route); + route.target._tag === "RelayConnectionTarget" + ? connect?.httpBaseUrl + : routeHttpBaseUrl(route); return url === null || url === undefined ? [] : [url]; }); return { @@ -70,10 +76,33 @@ export function linkableMachines(input: { label: environment.entry.target.label, machine: resolveEnvironmentMachineKind(environment.serverConfig), connected: environment.connection.phase === "connected", + connectOnly: false, linked: input.linkedIds.has(environment.environmentId), - urls: [...new Set([...reported, ...saved])], + urls: [...new Set([...reported, ...routes])], }; }); + const savedIds = new Set(saved.map((machine) => machine.environmentId)); + const connectOnly = [...input.connect.values()] + .filter( + ({ environment }) => + environment.environmentId !== input.threadEnvironmentId && + !savedIds.has(environment.environmentId), + ) + .map(({ environment, status }) => { + const descriptor = Option.getOrUndefined(status)?.descriptor; + return { + environmentId: environment.environmentId, + label: environment.label, + machine: resolveEnvironmentMachineKind( + descriptor === undefined ? null : { environment: descriptor }, + ), + connected: false, + connectOnly: true, + linked: input.linkedIds.has(environment.environmentId), + urls: [environment.endpoint.httpBaseUrl], + }; + }); + return [...saved, ...connectOnly]; } const words = (text: string) => From 6edd16995f3dff11e1a76ecdd1f73f80db330648 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:16:56 -0700 Subject: [PATCH 10/10] fix(web,mobile): a minted pairing code carries the access the link asks for The client minted every link code with read and operate only, so linking with full access always failed: the server wants settings and source control too. The access-to-scope mapping moves into contracts, the server checks against it, and the client mints exactly those scopes for the access picked. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/LinkRequestCard.tsx | 2 +- apps/mobile/src/lib/filePreview.test.ts | 1 + apps/mobile/src/state/peerLinks.ts | 3 +- apps/mobile/src/state/peerPairingCode.ts | 9 +- apps/server/src/auth/EnvironmentAuth.ts | 18 +- apps/server/src/auth/McpOAuth.test.ts | 19 ++ apps/server/src/auth/McpOAuth.ts | 7 +- apps/server/src/auth/mcpOAuthHttp.ts | 6 +- .../src/mcp/toolkits/orchestrator/tools.ts | 6 +- .../src/orchestration-v2/Orchestrator.ts | 17 +- apps/server/src/peer/PeerForwarding.ts | 115 +++---- apps/server/src/peer/PeerLinkRequests.test.ts | 14 + apps/server/src/peer/PeerLinkRequests.ts | 45 ++- apps/server/src/peer/PeerLinks.test.ts | 50 ++- apps/server/src/peer/PeerLinks.testkit.ts | 7 +- apps/server/src/peer/PeerLinks.ts | 302 +++++++++--------- .../src/provider/AgentContextBudget.test.ts | 4 + .../src/provider/agentContextBudget.json | 6 +- .../src/components/chat/LinkRequestCard.tsx | 56 ++-- .../settings/LinkedEnvironmentsSettings.tsx | 2 +- apps/web/src/state/peerLinks.ts | 3 +- apps/web/src/state/peerPairingCode.ts | 9 +- .../client-runtime/src/linkRequest.test.ts | 48 ++- packages/client-runtime/src/linkRequest.ts | 42 +-- .../src/state/peerPairingCode.ts | 44 +-- packages/contracts/src/auth.ts | 13 + packages/contracts/src/orchestratorMcp.ts | 17 +- .../src/server/orchestrationInstructions.ts | 2 +- 28 files changed, 493 insertions(+), 374 deletions(-) diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 03b2f3ba02d1..ef5516b8d095 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -277,7 +277,7 @@ function LinkRequestSheet(props: { } let code = pairingCode; if (canMint && targetId !== null) { - const minted = await mintPairingCodeOn(targetId); + const minted = await mintPairingCodeOn(targetId, access); if (Result.isFailure(minted)) { setMintFailed((failed) => new Set(failed).add(targetId)); setError(linkRequestFailureMessage(minted.failure)); diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 7525868e5b04..2373b7da0bc4 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -271,6 +271,7 @@ vi.mock("../features/threads/thread-context-divider", () => ({})); vi.mock("../features/threads/thread-handoff-row", () => ({})); vi.mock("../features/threads/ThreadGuestShareBar", () => ({ useThreadGuestShare: () => null })); vi.mock("../features/threads/SecretRequestCard", () => ({})); +vi.mock("../features/threads/LinkRequestCard", () => ({ LinkRequestCard: () => null })); vi.mock("../features/threads/worktree-setup-card", () => ({})); vi.mock("../features/threads/CopyTextButton", () => ({ CopyTextButton: () => null })); vi.mock("../features/threads/thread-work-log", () => ({ THREAD_DISCLOSURE_TRANSITION_MS: 100 })); diff --git a/apps/mobile/src/state/peerLinks.ts b/apps/mobile/src/state/peerLinks.ts index 9ab0d664c68b..c0b36dfadec0 100644 --- a/apps/mobile/src/state/peerLinks.ts +++ b/apps/mobile/src/state/peerLinks.ts @@ -28,6 +28,7 @@ export function useLinkableMachines(environmentId: EnvironmentId, enabled: boole const discoverIfUnasked = useEffectEvent(() => { if (discovery.environments.size === 0 && !discovery.refreshing) void refreshDiscovery(); }); + // 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 useEffect(() => { if (enabled) discoverIfUnasked(); }, [enabled]); @@ -39,7 +40,7 @@ export function useLinkableMachines(environmentId: EnvironmentId, enabled: boole linkableMachines({ environments, threadEnvironmentId: environmentId, - linkedIds: new Set(links.data?.links.map((link) => link.environmentId)), + links: links.data?.links ?? [], connect: discovery.environments, }), [discovery.environments, environmentId, environments, links.data], diff --git a/apps/mobile/src/state/peerPairingCode.ts b/apps/mobile/src/state/peerPairingCode.ts index 2d2d3de20c1a..6cf4c6dc3342 100644 --- a/apps/mobile/src/state/peerPairingCode.ts +++ b/apps/mobile/src/state/peerPairingCode.ts @@ -1,6 +1,6 @@ import { createPeerPairingCodeCommand } from "@t3tools/client-runtime/state/peerPairingCode"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { AuthMcpClientAccess, EnvironmentId } from "@t3tools/contracts"; import * as Result from "effect/Result"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -10,13 +10,14 @@ const mintPairingCode = createPeerPairingCodeCommand(connectionAtomRuntime); /** * A fresh pairing code minted on `environmentId` with this client's session - * there, for answering a link request. Fails with `PeerPairingCodeError` when - * the session may not create one. + * there, for linking with `access`. Fails with `PeerPairingCodeError` when the + * session may not create one. */ export async function mintPairingCodeOn( environmentId: EnvironmentId, + access: AuthMcpClientAccess, ): Promise> { - const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: undefined }); + const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: access }); return result._tag === "Success" ? Result.succeed(result.value) : Result.fail(squashAtomCommandFailure(result)); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 476a3b9f33e4..4ddb5066cb1f 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -2,9 +2,6 @@ import { AuthAccessTokenType, AuthAccessWriteScope, AuthOrchestrationOperateScope, - AuthOrchestrationReadScope, - AuthSettingsWriteScope, - AuthSourceControlWriteScope, AuthStandardClientScopes, type AuthAccessTokenResult, type AuthBrowserSessionResult, @@ -24,6 +21,7 @@ import { type AuthWebSocketTicketUse, DpopFailureReason, type DpopFailureReason as DpopFailureReasonType, + mcpClientScopes, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; @@ -74,20 +72,6 @@ export interface IssuedBearerSession { const MCP_CLIENT_SUBJECT = "mcp-client"; const MCP_CLIENT_SESSION_TTL = Duration.days(30); -export const mcpClientScopes = ( - access: AuthMcpClientAccess, -): ReadonlyArray => - access === "read-only" - ? [AuthOrchestrationReadScope] - : access === "full-access" - ? [ - AuthOrchestrationReadScope, - AuthOrchestrationOperateScope, - AuthSettingsWriteScope, - AuthSourceControlWriteScope, - ] - : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; - export interface McpClientSession { readonly sessionId: AuthSessionId; readonly label: string; diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 0846056dafe5..5c73cc03fff9 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -1,8 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { + AuthMcpClientAccess, AuthStandardClientScopes, type AuthCreatePairingCredentialInput, EnvironmentHttpApi, + mcpClientScopes, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; @@ -480,6 +482,23 @@ it.live( ), ); +it.live("a code minted with an access level's scopes approves that access", () => + withRoutes((handler, auth) => + Effect.gen(function* () { + const params = authorizeParams(yield* registeredClientId(handler)); + for (const access of AuthMcpClientAccess.literals) { + const code = yield* auth.issuePairingCredential({ scopes: [...mcpClientScopes(access)] }); + const approval = yield* decide(handler, params, { + _tag: "pairing-code", + access, + code: code.credential, + }); + expect(approval.status, access).toBe(200); + } + }), + ), +); + it.live("one-click approves only access the browser session holds the scopes for", () => withRoutes((handler, auth) => Effect.gen(function* () { diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 064304bce7a2..3be31570b4d4 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -14,6 +14,7 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, MCP_PEER_LINK_SOFTWARE_ID, + mcpClientScopes, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Clock from "effect/Clock"; @@ -436,9 +437,7 @@ const make = Effect.gen(function* () { if ( session === undefined || !timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) || - !EnvironmentAuth.mcpClientScopes(decision.access).every((scope) => - session.scopes.includes(scope), - ) + !mcpClientScopes(decision.access).every((scope) => session.scopes.includes(scope)) ) { return yield* new McpOAuthPageError({ description: "Your session cannot approve this request. Enter a pairing code instead.", @@ -523,7 +522,7 @@ const make = Effect.gen(function* () { access_token: issued.token, token_type: "Bearer", expires_in: Math.max(0, Math.floor((issued.expiresAt.epochMilliseconds - now) / 1000)), - scope: encodeOAuthScope(EnvironmentAuth.mcpClientScopes(pending.access)), + scope: encodeOAuthScope(mcpClientScopes(pending.access)), }; }, ); diff --git a/apps/server/src/auth/mcpOAuthHttp.ts b/apps/server/src/auth/mcpOAuthHttp.ts index 398116e71c24..dfb272605bce 100644 --- a/apps/server/src/auth/mcpOAuthHttp.ts +++ b/apps/server/src/auth/mcpOAuthHttp.ts @@ -3,6 +3,7 @@ import { type AuthMcpAuthorizationRequest, AuthMcpClientAccess, EnvironmentHttpApi, + mcpClientScopes, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Result from "effect/Result"; @@ -10,7 +11,6 @@ import { HttpServerRequest, HttpServerResponse } from "effect/http"; import * as HttpEffect from "effect/http/HttpEffect"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; -import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as McpOAuth from "./McpOAuth.ts"; import { renderErrorPage } from "./mcpOAuthHtml.ts"; @@ -121,9 +121,7 @@ export const layer = HttpApiBuilder.group( : { csrfToken: session.csrfToken, oneClickAccess: AuthMcpClientAccess.literals.filter((access) => - EnvironmentAuth.mcpClientScopes(access).every((scope) => - session.scopes.includes(scope), - ), + mcpClientScopes(access).every((scope) => session.scopes.includes(scope)), ), }), }; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index b76d4d2afada..859004d9ce66 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -307,7 +307,7 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", { const EnvironmentLinksTool = Tool.make("t3_environment_links", { description: - "List the other T3 Code environments this one is linked to, such as the user's other machines, and whether each answers now. A machine the user names that is not listed is not linked yet: call t3_environment_link for it rather than looking elsewhere. Pass one's environmentId to any tool that takes environmentId, such as t3_thread_launch, t3_thread_send, t3_pending_request_respond, t3_queue_list or t3_project_list, to act there; delegate_task takes it as target.environmentId. A linked environment lets this one's agents do at most its access there, and never more than the calling agent may do here. An expired link has to be linked again from a new pairing code.", + "List linked environments (the user's other machines) and whether each answers now. Pass one's `environmentId` to a tool to act there; `delegate_task` takes `target.environmentId`. Expired: link it again with `t3_environment_link`.", success: OrchestratorMcpEnvironmentLinksResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -320,7 +320,7 @@ const EnvironmentLinksTool = Tool.make("t3_environment_links", { const EnvironmentLinkTool = Tool.make("t3_environment_link", { description: - 'Ask the user to link another T3 Code environment, such as their VPS or another machine, so agents here can work there (t3_environment_links). Call it when the user asks to link, connect, or delegate to another machine. You usually do not know where it answers, so pass no address: pass a hint from their words ("vps") and the card lets the user pick the machine. Pass environmentId when a machine the user mentioned names it; if it is already linked, this returns linked at once. Pass url only when the user gave an address. The card lets the user pick the access agents here get there and approve it; their app signs in there with their own session, or they paste a pairing code from there into the card. It returns pending at once: the user answers in the card whenever they get to it, and the outcome (linked with the environmentId to pass to other tools, declined, or failed with the reason) arrives as a message in this thread. End your turn or keep working; do not poll or call it again to check. Never ask for a pairing code in chat.', + "Ask the user to link another machine, in a card. For a machine the user names that is not linked. Pass `hint` from their words, `environmentId` when one names it (already linked: returns `linked`), `url` only when the user gave an address. Returns `pending`; the outcome arrives as a message in this thread. Do not wait, poll, or call it again to check. Never ask for a pairing code in chat.", parameters: OrchestratorMcpEnvironmentLinkInput, success: OrchestratorMcpEnvironmentLinkResult, failure: OrchestratorMcpFailure, @@ -333,7 +333,7 @@ const EnvironmentLinkTool = Tool.make("t3_environment_link", { const EnvironmentUnlinkTool = Tool.make("t3_environment_unlink", { description: - "Forget a linked environment, so agents here stop working there. The session there stays listed in that environment's Connections until it is revoked there.", + "Forget a linked environment. That machine's Connections list its session until revoked there.", parameters: OrchestratorMcpEnvironmentUnlinkInput, success: OrchestratorMcpEnvironmentUnlinkResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e3a9dc47b30f..123381860783 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8410,13 +8410,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio * waiting long ago, so nobody is woken: stopping or archiving the thread * means the user is done with it. */ - const closePendingLinkRequests = (input: { - readonly command: OrchestrationV2ServerCommand; - readonly events: Ref.Ref>; - readonly threadId: ThreadId; - readonly now: DateTime.Utc; - }) => - Effect.gen(function* () { + const closePendingLinkRequests = Effect.fn("orchestrationV2.closePendingLinkRequests")( + function* (input: { + readonly command: OrchestrationV2ServerCommand; + readonly events: Ref.Ref>; + readonly threadId: ThreadId; + readonly now: DateTime.Utc; + }) { const { turnItems } = yield* projectionStore .getThreadRecords(input.threadId, ["turnItems"], { turnItemTypes: ["link_request"], @@ -8444,7 +8444,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } - }); + }, + ); const dispatchRuntimeRequestRespond = Effect.fn("Orchestrator.dispatchRuntimeRequestRespond")( function* ( diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index e5c05e464607..ddcf3d0fa06a 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -116,38 +116,37 @@ const make = Effect.gen(function* () { ); /** Tool and contract schemas are plain data: they encode and decode without services. */ - const forward = ( + const forward = Effect.fn("PeerForwarding.forward")(function* ( scope: Scope, toolName: string, parameters: Schema.Top, success: Schema.Top, environmentId: EnvironmentId, params: unknown, - ) => - Effect.gen(function* () { - const limits = yield* callerLimits(scope); - const encoded = (yield* Schema.encodeUnknownEffect(parameters as Schema.Encoder)( - params, - ).pipe(Effect.mapError(unavailable))) as Record; - const { environmentId: _target, ...args } = encoded; - // 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({ - code: "invalid_request", - message: "A launch in a linked environment cannot carry attachments yet.", - }); - } - if (typeof args.clientRequestId === "string") { - args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId); - } - return yield* peers.call({ - environmentId, - tool: toolName, - arguments: args, - limits, - success: success as Schema.Decoder, + ) { + const limits = yield* callerLimits(scope); + const encoded = (yield* Schema.encodeUnknownEffect(parameters as Schema.Encoder)( + params, + ).pipe(Effect.mapError(unavailable))) as Record; + const { environmentId: _target, ...args } = encoded; + // 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({ + code: "invalid_request", + message: "A launch in a linked environment cannot carry attachments yet.", }); + } + if (typeof args.clientRequestId === "string") { + args.clientRequestId = yield* scopedRequestId(scope.requestNamespace, args.clientRequestId); + } + return yield* peers.call({ + environmentId, + tool: toolName, + arguments: args, + limits, + success: success as Schema.Decoder, }); + }); const readThread: PeerForwarding["Service"]["readThread"] = (scope, environmentId, input) => forward( @@ -173,36 +172,37 @@ const make = Effect.gen(function* () { input, ); - const waitForThread: PeerForwarding["Service"]["waitForThread"] = (scope, environmentId, input) => - Effect.gen(function* () { - let remaining = Math.min( - MAX_WAIT_TIMEOUT_MS, - Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + const waitForThread: PeerForwarding["Service"]["waitForThread"] = Effect.fn( + "PeerForwarding.waitForThread", + )(function* (scope, environmentId, input) { + let remaining = Math.min( + MAX_WAIT_TIMEOUT_MS, + Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + ); + let runId = input.runId; + while (true) { + const chunk = Math.min(remaining, FORWARDED_WAIT_CHUNK_MS); + const result = yield* forward( + scope, + "t3_thread_wait", + OrchestratorMcpThreadWaitInput, + OrchestratorMcpThreadWaitResult, + environmentId, + { + threadId: input.threadId, + ...(runId === undefined ? {} : { runId }), + timeoutMs: chunk, + ...(input.untilDelegatedWorkSettled === undefined + ? {} + : { untilDelegatedWorkSettled: input.untilDelegatedWorkSettled }), + }, ); - let runId = input.runId; - while (true) { - const chunk = Math.min(remaining, FORWARDED_WAIT_CHUNK_MS); - const result = yield* forward( - scope, - "t3_thread_wait", - OrchestratorMcpThreadWaitInput, - OrchestratorMcpThreadWaitResult, - environmentId, - { - threadId: input.threadId, - ...(runId === undefined ? {} : { runId }), - timeoutMs: chunk, - ...(input.untilDelegatedWorkSettled === undefined - ? {} - : { untilDelegatedWorkSettled: input.untilDelegatedWorkSettled }), - }, - ); - remaining -= chunk; - if (!result.timedOut || remaining <= 0) return result; - // Later pieces wait for the run the first one picked, not a newer one. - runId = result.runId ?? undefined; - } - }); + remaining -= chunk; + if (!result.timedOut || remaining <= 0) return result; + // Later pieces wait for the run the first one picked, not a newer one. + runId = result.runId ?? undefined; + } + }); const call: PeerForwarding["Service"]["call"] = (scope, tool, environmentId, params) => tool.name === "t3_thread_wait" @@ -218,10 +218,10 @@ const make = Effect.gen(function* () { * empty list reads as a dead end and agents go looking elsewhere. */ const NOT_LISTED = - "A machine the user means that is not listed here is not linked yet: call t3_environment_link with a hint from their words (or the environmentId of a machine they mentioned), and the user picks it and its access in a card."; + "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."; - const listLinks: PeerForwarding["Service"]["links"] = (scope) => - Effect.gen(function* () { + const listLinks: PeerForwarding["Service"]["links"] = Effect.fn("PeerForwarding.listLinks")( + function* (scope) { yield* callerLimits(scope); const listed = yield* links.list.pipe( Effect.mapError( @@ -241,7 +241,8 @@ const make = Effect.gen(function* () { })), notListed: NOT_LISTED, }; - }); + }, + ); return PeerForwarding.of({ call, diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index 5ec7aff82b46..b7353beb6499 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -178,6 +178,7 @@ const makeLaptop = Effect.gen(function* () { const server = Context.get(here, McpServer.McpServer); return { links: Context.get(linking, PeerLinks.PeerLinks), + linksSql: Context.get(linking, SqlClient.SqlClient), requests: Context.get(here, PeerLinkRequests.PeerLinkRequests), orchestrator: Context.get(here, Orchestrator.OrchestratorV2), sink: Context.get(here, EventSink.EventSinkV2), @@ -938,6 +939,19 @@ it.live("a link that expired goes through the card, so the user can link it agai ), ); +it.live("a link made before links were fenced goes through the card, as an expired one", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + yield* linkTo(a.links, b); + yield* a.linksSql`UPDATE peer_environment_links SET fenced = 0`; + const asked = yield* a.call({ environmentId: box.environmentId }); + expect(asked.structuredContent).toMatchObject({ status: "pending" }); + yield* pendingCard(a); + }), + ), +); + it.live( "a card for a machine named by id keeps it, and the user may keep a link it already has", () => diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index a932a0630bbd..12a96831d3de 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -1,20 +1,8 @@ /** - * PeerLinkRequests - links an agent asks the user to make. - * - * The agent rarely knows where the user's machines answer, so it usually - * names none: the user picks the machine in a card, along with the access, - * and their client answers with the addresses it knows for that machine and - * a pairing code from it, which the client mints with its own session there - * or the user pastes. The code goes straight - * to `PeerLinks.link` and is never recorded, so it never reaches the - * transcript, projections, or the agent's context. - * - * Asking does not wait: provider CLIs abandon a tool call after a few - * minutes, and a person may answer much later. The card stays open until the - * user answers it or stops or archives the thread, and the answer reaches the - * agent as a notification in its thread. - * - * @module PeerLinkRequests + * Links an agent asks the user to make. The user picks the machine and access in a card, and + * their client answers with its addresses and a pairing code that goes straight to + * `PeerLinks.link`, never into the transcript or the agent's context. Asking does not wait + * (provider CLIs abandon a long tool call): the answer reaches the agent as a notification. */ import { CommandId, @@ -147,8 +135,8 @@ const make = Effect.gen(function* () { ...(wake === undefined ? {} : { wake }), }); - const request: PeerLinkRequests["Service"]["request"] = (scope, input) => - Effect.gen(function* () { + const request: PeerLinkRequests["Service"]["request"] = Effect.fn("PeerLinkRequests.request")( + function* (scope, input) { // The card is shown in, and answered from, the caller's own thread. const threadScope = yield* McpInvocationContext.requireThreadScope( scope, @@ -245,7 +233,9 @@ const make = Effect.gen(function* () { ), ); return pendingResult(turnItemId); - }).pipe(Effect.withSpan("PeerLinkRequests.request")); + }, + Effect.withSpan("PeerLinkRequests.request"), + ); const linkedOutcome = (linked: PeerLink): LinkRequestOutcome => ({ linkStatus: "linked", @@ -306,8 +296,8 @@ const make = Effect.gen(function* () { // One answer per card at a time: two answers racing on one card must not // both link. Other cards go on while a slow peer holds one. const answerLocks = yield* KeyedLock.make(); - const answer: PeerLinkRequests["Service"]["answer"] = (input) => - Effect.gen(function* () { + const answer: PeerLinkRequests["Service"]["answer"] = Effect.fn("PeerLinkRequests.answer")( + function* (input) { yield* Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId, "link_request.answer": input.answer.type, @@ -337,10 +327,13 @@ const make = Effect.gen(function* () { ).pipe( Effect.mapError((cause) => new PeerLinkRequestError({ reason: "record_failed", cause })), ); - }).pipe( - (effect) => answerLocks.withLock(input.turnItemId, effect), - Effect.withSpan("PeerLinkRequests.answer"), - ); + }, + (effect, input) => + effect.pipe( + (effect) => answerLocks.withLock(input.turnItemId, effect), + Effect.withSpan("PeerLinkRequests.answer"), + ), + ); return PeerLinkRequests.of({ request, answer }); }); @@ -387,7 +380,7 @@ const outcomeNotice = ( case "linked": { const label = outcome.linkedLabel ?? target; return { - text: `The user linked ${label} with ${outcome.linkedAccess} access. Pass environmentId ${outcome.linkedEnvironmentId} to act there; t3_environment_links lists it.`, + text: `The user linked ${label} with ${outcome.linkedAccess} access. Pass environmentId ${outcome.linkedEnvironmentId} to act there.`, notification: { source: { kind: "background_task" }, outcome: "completed", diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 2ea6ead46450..2d6a0c267e34 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -19,6 +19,7 @@ import { HttpServerResponse, } from "effect/http"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as McpToolAccess from "../mcp/McpToolAccess.ts"; @@ -73,8 +74,11 @@ const serveB = (descriptor: ExecutionEnvironmentDescriptor) => servePeer(descriptor, McpHttpServer.toolkitRegistration(ProbeToolkit, probeHandlers)); /** Environment A, which links to B. */ -const makeA = (descriptor: ExecutionEnvironmentDescriptor) => - layerLinkingEnvironment(descriptor).pipe( +const makeA = ( + descriptor: ExecutionEnvironmentDescriptor, + secrets?: typeof ServerSecretStore.layer, +) => + layerLinkingEnvironment(descriptor, secrets).pipe( Layer.build, Effect.map((context) => ({ links: Context.get(context, PeerLinks.PeerLinks), @@ -151,6 +155,46 @@ it.effect("links with a pairing code, and the peer holds its agents to the limit ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("a link whose row cannot be stored keeps none of its token", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const held = yield* Ref.make>(new Set()); + const noted = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.make, (store) => + ServerSecretStore.ServerSecretStore.of({ + ...store, + set: (name, value) => + store + .set(name, value) + .pipe(Effect.andThen(Ref.update(held, (names) => new Set([...names, name])))), + remove: (name) => + store + .remove(name) + .pipe( + Effect.andThen( + Ref.update(held, (names) => new Set([...names].filter((at) => at !== name))), + ), + ), + }), + ), + ); + const a = yield* makeA(laptop, noted); + yield* a.sql` + CREATE TRIGGER refuse_link BEFORE INSERT ON peer_environment_links + BEGIN SELECT RAISE(ABORT, 'database or disk is full'); END + `; + const pairing = yield* b.auth.issuePairingCredential(); + const failed = yield* a.links + .link({ url: b.url, pairingCode: pairing.credential, access: "auto" }) + .pipe(Effect.flip); + expect(failed.reason).toBe("storage"); + expect(yield* Ref.get(held)).toEqual(new Set()); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("two links racing to one peer leave the stored access matching the token", () => Effect.scoped( Effect.gen(function* () { @@ -306,6 +350,8 @@ it.effect("tells the agent to link again once the peer revokes the link or it ex // A link stored before links were marked: its session there is not a // link's, so the peer would not fence its work even though it can. yield* a.sql`UPDATE peer_environment_links SET fenced = 0`; + // Linking again is its only way back, as for an expired link. + expect((yield* a.links.list).map((link) => link.status)).toEqual(["expired"]); const legacy = yield* probe(a).pipe(Effect.flip); expect(legacy.code).toBe("capability_denied"); expect(legacy.message).toContain("Link it again"); diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index 59be493d92db..6c41af9e3634 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -148,11 +148,14 @@ export type ServedPeer = Effect.Success>; * The environment that links: its own database and secret store, reaching * peers over plain HTTP. Provide it to anything built on its links. */ -export const layerLinkingEnvironment = (descriptor: ExecutionEnvironmentDescriptor) => +export const layerLinkingEnvironment = ( + descriptor: ExecutionEnvironmentDescriptor, + secrets: typeof ServerSecretStore.layer = ServerSecretStore.layer, +) => PeerMcpClient.layer.pipe( Layer.provideMerge(PeerLinks.layer), Layer.provideMerge(Sqlite.layerMemory), - Layer.provide(Layer.mergeAll(ServerSecretStore.layer, FetchHttpClient.layer)), + Layer.provide(Layer.mergeAll(secrets, FetchHttpClient.layer)), // Services that act for this environment, such as delegation, name it too. Layer.provideMerge( Layer.succeed(ServerEnvironment.ServerEnvironment, { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index d4f244104639..04b59b677e96 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -185,20 +185,20 @@ const make = Effect.gen(function* () { Effect.option, ); - const toLink = (row: PeerLinkRow) => - Effect.gen(function* () { - return { - environmentId: EnvironmentId.make(row.environment_id), - label: row.label, - urls: yield* decodeUrls(row.urls_json), - access: yield* decodeAccess(row.access), - linkedAt: DateTime.makeUnsafe(row.linked_at), - expiresAt: DateTime.makeUnsafe(row.expires_at), - lastReachedAt: - row.last_reached_at === null ? null : DateTime.makeUnsafe(row.last_reached_at), - lastError: row.last_error, - } satisfies PeerLink; - }).pipe(Effect.catch(failStorage)); + const toLink = Effect.fn("PeerLinks.toLink")(function* (row: PeerLinkRow) { + return { + environmentId: EnvironmentId.make(row.environment_id), + label: row.label, + urls: yield* decodeUrls(row.urls_json), + access: yield* decodeAccess(row.access), + linkedAt: DateTime.makeUnsafe(row.linked_at), + // Its token predates link fencing, so the peer would not fence its work: + // like an expired link, only a new pairing code brings it back. + expiresAt: DateTime.makeUnsafe(row.fenced === 1 ? row.expires_at : row.linked_at), + lastReachedAt: row.last_reached_at === null ? null : DateTime.makeUnsafe(row.last_reached_at), + lastError: row.last_error, + } satisfies PeerLink; + }, Effect.catch(failStorage)); const readRows = sql` SELECT * FROM peer_environment_links ORDER BY label, environment_id @@ -213,8 +213,8 @@ const make = Effect.gen(function* () { ); /** Signs in to the peer's `/mcp` with a pairing code: register, decide, exchange. */ - const signIn = (origin: string, input: PeerLinkCreateInput, clientName: string) => - Effect.gen(function* () { + const signIn = Effect.fn("PeerLinks.signIn")( + function* (origin: string, input: PeerLinkCreateInput, clientName: string) { const client = yield* clientFor(origin); // Only the peer's own refusals are rejections. A dropped connection or // an answer this version cannot read is classified below. @@ -303,129 +303,135 @@ const make = Effect.gen(function* () { ), ), ); - }).pipe( - // Endpoint errors are mapped above; what is left is the transport or an - // answer this version cannot decode. - Effect.catchTags({ - SchemaError: (cause) => - Effect.fail( - new PeerLinkError({ - reason: "incompatible", - message: `${origin} answered in a shape this version does not understand. Update both environments.`, - cause, - }), - ), - HttpClientError: (cause) => - Effect.fail( - new PeerLinkError({ - reason: "unreachable", - message: `${origin} stopped answering while linking.`, - cause, - }), - ), - }), - ); + }, + (effect, origin) => + effect.pipe( + // Endpoint errors are mapped above; what is left is the transport or an + // answer this version cannot decode. + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new PeerLinkError({ + reason: "incompatible", + message: `${origin} answered in a shape this version does not understand. Update both environments.`, + cause, + }), + ), + HttpClientError: (cause) => + Effect.fail( + new PeerLinkError({ + reason: "unreachable", + message: `${origin} stopped answering while linking.`, + cause, + }), + ), + }), + ), + ); /** * The addresses that answer as `environmentId`, in the order given, with * what the first one answered. Addresses this environment would not send * credentials to are skipped; when every one is, the failure says why. */ - const answeringAs = (urls: ReadonlyArray, environmentId: EnvironmentId, name: string) => - Effect.gen(function* () { - const checked = yield* Effect.forEach( - urls, - (url) => - Effect.gen(function* () { - const origin = yield* peerOrigin(url); - return { origin, descriptor: yield* describe(origin) }; - }).pipe(Effect.result), - { concurrency: "unbounded" }, - ); - const matching = checked.flatMap((result) => - Result.isSuccess(result) && - Option.isSome(result.success.descriptor) && - result.success.descriptor.value.environmentId === environmentId - ? [{ origin: result.success.origin, descriptor: result.success.descriptor.value }] - : [], + const answeringAs = Effect.fn("PeerLinks.answeringAs")(function* ( + urls: ReadonlyArray, + environmentId: EnvironmentId, + name: string, + ) { + const checked = yield* Effect.forEach( + urls, + (url) => + Effect.gen(function* () { + const origin = yield* peerOrigin(url); + return { origin, descriptor: yield* describe(origin) }; + }).pipe(Effect.result), + { concurrency: "unbounded" }, + ); + const matching = checked.flatMap((result) => + Result.isSuccess(result) && + Option.isSome(result.success.descriptor) && + result.success.descriptor.value.environmentId === environmentId + ? [{ origin: result.success.origin, descriptor: result.success.descriptor.value }] + : [], + ); + const first = matching[0]; + if (first === undefined) { + const refusals = checked.flatMap((result) => + Result.isFailure(result) ? [result.failure.message] : [], ); - const first = matching[0]; - if (first === undefined) { - const refusals = checked.flatMap((result) => - Result.isFailure(result) ? [result.failure.message] : [], - ); - return yield* new PeerLinkError({ - reason: "unreachable", - message: - refusals.length === checked.length - ? `This environment could not reach ${name}: ${refusals.join(" ")}` - : `This environment could not reach ${name} at ${urls.join(", ")}.`, - }); - } - // Each address is kept once, even when two spellings share an origin. - const origins = [...new Set(matching.map((found) => found.origin))]; - return { primary: first.origin, peer: first.descriptor, origins }; - }); + return yield* new PeerLinkError({ + reason: "unreachable", + message: + refusals.length === checked.length + ? `This environment could not reach ${name}: ${refusals.join(" ")}` + : `This environment could not reach ${name} at ${urls.join(", ")}.`, + }); + } + // Each address is kept once, even when two spellings share an origin. + const origins = [...new Set(matching.map((found) => found.origin))]; + return { primary: first.origin, peer: first.descriptor, origins }; + }); /** The peer at `url`, with every address checked for credentials first. */ - const answeringAt = (urls: ReadonlyArray) => - Effect.gen(function* () { - const origins = yield* Effect.forEach(urls, peerOrigin); - const primary = origins[0]!; - const descriptor = yield* describe(primary); - if (Option.isNone(descriptor)) { - return yield* new PeerLinkError({ - reason: "not_a_t3_environment", - message: `Nothing at ${primary} answered as a T3 Code environment.`, - }); - } - return { primary, peer: descriptor.value, origins }; - }); + const answeringAt = Effect.fn("PeerLinks.answeringAt")(function* (urls: ReadonlyArray) { + const origins = yield* Effect.forEach(urls, peerOrigin); + const primary = origins[0]!; + const descriptor = yield* describe(primary); + if (Option.isNone(descriptor)) { + return yield* new PeerLinkError({ + reason: "not_a_t3_environment", + message: `Nothing at ${primary} answered as a T3 Code environment.`, + }); + } + return { primary, peer: descriptor.value, origins }; + }); - const link: PeerLinks["Service"]["link"] = (input) => - Effect.gen(function* () { - const urls = [input.url, ...(input.alternateUrls ?? [])]; - // The user picked a machine: link through the addresses that prove to - // be it, so a reused LAN address never receives the pairing code. - const { primary, peer, origins } = - input.expectedEnvironmentId === undefined - ? yield* answeringAt(urls) - : yield* answeringAs( - urls, - input.expectedEnvironmentId, - input.expectedLabel ?? input.expectedEnvironmentId, - ); - const self = yield* environment.getDescriptor; - if (peer.environmentId === self.environmentId) { - return yield* new PeerLinkError({ - reason: "self", - message: "That address is this environment.", - }); - } - if (!linkLimitsHold(peer)) { - return yield* new PeerLinkError({ - reason: "incompatible", - message: `${peer.label} runs a T3 Code version that cannot keep a linked agent's limits. Update it, then link again.`, - }); - } - const issued = yield* signIn(primary, input, `T3 Code · ${self.label}`); - const now = yield* DateTime.now; - const linked: PeerLink = { - environmentId: peer.environmentId, - label: peer.label, - urls: origins, - access: input.access, - linkedAt: now, - expiresAt: DateTime.add(now, { seconds: issued.expires_in }), - lastReachedAt: now, - lastError: null, - }; - // The token goes under a name of its own before the row points at it, - // so the row and its token are replaced together. - const secretName = yield* newSecretName; - yield* secrets - .set(secretName, new TextEncoder().encode(issued.access_token)) - .pipe(Effect.catch(failStorage)); + const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) { + const urls = [input.url, ...(input.alternateUrls ?? [])]; + // The user picked a machine: link through the addresses that prove to + // be it, so a reused LAN address never receives the pairing code. + const { primary, peer, origins } = + input.expectedEnvironmentId === undefined + ? yield* answeringAt(urls) + : yield* answeringAs( + urls, + input.expectedEnvironmentId, + input.expectedLabel ?? input.expectedEnvironmentId, + ); + const self = yield* environment.getDescriptor; + if (peer.environmentId === self.environmentId) { + return yield* new PeerLinkError({ + reason: "self", + message: "That address is this environment.", + }); + } + if (!linkLimitsHold(peer)) { + return yield* new PeerLinkError({ + reason: "incompatible", + message: `${peer.label} runs a T3 Code version that cannot keep a linked agent's limits. Update it, then link again.`, + }); + } + const issued = yield* signIn(primary, input, `T3 Code · ${self.label}`); + const now = yield* DateTime.now; + const linked: PeerLink = { + environmentId: peer.environmentId, + label: peer.label, + urls: origins, + access: input.access, + linkedAt: now, + expiresAt: DateTime.add(now, { seconds: issued.expires_in }), + lastReachedAt: now, + lastError: null, + }; + // The token goes under a name of its own before the row points at it, + // so the row and its token are replaced together. + const secretName = yield* newSecretName; + yield* secrets + .set(secretName, new TextEncoder().encode(issued.access_token)) + .pipe(Effect.catch(failStorage)); + // A token no row names could never be removed. + const previous = yield* Effect.gen(function* () { const urlsJson = yield* encodeUrls(origins).pipe(Effect.catch(failStorage)); const previous = yield* readRow(peer.environmentId); yield* sql` @@ -448,13 +454,15 @@ const make = Effect.gen(function* () { last_reached_at = excluded.last_reached_at, last_error = NULL `.pipe(Effect.catch(failStorage)); - // Linking again replaces the old token. A racing link may leave its own - // behind; it is unused either way. - if (Option.isSome(previous) && previous.value.secret_name !== secretName) { - yield* secrets.remove(previous.value.secret_name).pipe(Effect.ignore); - } - return linked; - }); + return previous; + }).pipe(Effect.onError(() => secrets.remove(secretName).pipe(Effect.ignore))); + // Linking again replaces the old token. A racing link may leave its own + // behind; it is unused either way. + if (Option.isSome(previous) && previous.value.secret_name !== secretName) { + yield* secrets.remove(previous.value.secret_name).pipe(Effect.ignore); + } + return linked; + }); const describeUrl: PeerLinks["Service"]["describe"] = (url) => peerOrigin(url).pipe( @@ -498,8 +506,8 @@ const make = Effect.gen(function* () { ), ); - const unlink: PeerLinks["Service"]["unlink"] = (environmentId) => - Effect.gen(function* () { + const unlink: PeerLinks["Service"]["unlink"] = Effect.fn("PeerLinks.unlink")( + function* (environmentId) { const deleted = yield* sql<{ secret_name: string }>` DELETE FROM peer_environment_links WHERE environment_id = ${environmentId} RETURNING secret_name @@ -508,10 +516,11 @@ const make = Effect.gen(function* () { yield* secrets.remove(row.secret_name).pipe(Effect.catch(failStorage)); } return deleted.length > 0; - }); + }, + ); - const resolve: PeerLinks["Service"]["resolve"] = (environmentId) => - Effect.gen(function* () { + const resolve: PeerLinks["Service"]["resolve"] = Effect.fn("PeerLinks.resolve")( + function* (environmentId) { const row = yield* readRow(environmentId); if (Option.isNone(row)) { return yield* new PeerLinkError({ @@ -526,14 +535,6 @@ const make = Effect.gen(function* () { message: `The link to ${link.label} expired. Link it again with a new pairing code from ${link.label}.`, }); } - // Its token was issued before links were marked as links, so the peer - // never fences the work it carries, whatever version it runs now. - if (row.value.fenced !== 1) { - return yield* new PeerLinkError({ - reason: "incompatible", - message: `The link to ${link.label} was made by an older T3 Code. Link it again with a new pairing code from ${link.label}.`, - }); - } const stored = yield* secrets.get(row.value.secret_name).pipe(Effect.catch(failStorage)); if (Option.isNone(stored)) { return yield* new PeerLinkError({ @@ -568,7 +569,8 @@ const make = Effect.gen(function* () { }); } return { link, url: url.value, token: new TextDecoder().decode(stored.value) }; - }); + }, + ); const recordOutcome: PeerLinks["Service"]["recordOutcome"] = (environmentId, outcome) => DateTime.now.pipe( diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 99a8493eaafe..ad19948c0688 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -23,6 +23,8 @@ import { ThreadLaunchService } from "../orchestration-v2/ThreadLaunchService.ts" import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; import { ThreadSearch } from "../orchestration-v2/ThreadSearch.ts"; import { PeerForwarding } from "../peer/PeerForwarding.ts"; +import { PeerLinkRequests } from "../peer/PeerLinkRequests.ts"; +import { PeerLinks } from "../peer/PeerLinks.ts"; import { RemoteDelegation } from "../peer/RemoteDelegation.ts"; import { PreviewManager } from "../preview/Manager.ts"; import { PreviewBrowser } from "../preview/PreviewBrowser.ts"; @@ -51,6 +53,8 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(McpProviderSessions)({}), Layer.mock(OrchestratorV2)({}), Layer.mock(PeerForwarding)({}), + Layer.mock(PeerLinkRequests)({}), + Layer.mock(PeerLinks)({}), Layer.mock(PreviewAutomationBroker)({}), Layer.mock(PreviewBrowser)({}), Layer.mock(PreviewManager)({}), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 41a9242f28a1..beec65c80d12 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5086, - "codexAdditionalContextBytes": 5638, - "mcpToolListJsonBytes": 44745 + "claudeSystemAppendBytes": 5258, + "codexAdditionalContextBytes": 5810, + "mcpToolListJsonBytes": 45861 } diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index a7e5dde46658..9d9327368a5a 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -277,7 +277,7 @@ function LinkRequestDialog(props: { } let code = pairingCode; if (canMint && targetId !== null) { - const minted = await mintPairingCodeOn(targetId); + const minted = await mintPairingCodeOn(targetId, access); if (Result.isFailure(minted)) { setMintFailed((failed) => new Set(failed).add(targetId)); setError(linkRequestFailureMessage(minted.failure)); @@ -390,39 +390,45 @@ function LinkRequestDialog(props: { ) : null} ) : null} - {useExisting ? ( -

- This environment is already linked to {targetName}. The agent can use that link - as it is; change its access in Settings → Connections. -

- ) : null} ) : ( <> {item.url !== undefined && !pickMachine ? (

{item.url}

) : null} - {canMint ? null : ( - + {useExisting ? null : ( + <> + {canMint ? null : ( + + )} + + )} - )} + {useExisting ? ( +

+ This environment is already linked to {targetName}. The agent can use that link as + it is; change its access in Settings → Connections. +

+ ) : null} {error !== null ? (

{error} diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index 22ce8dcfd000..3b19489dd1e9 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -208,7 +208,7 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi // Settles to the code to link with, or null after reporting why there is none. const resolvePairingCode = async () => { if (!canMint || targetId === null) return pairingCode.trim(); - const minted = await mintPairingCodeOn(targetId); + const minted = await mintPairingCodeOn(targetId, access); if (Result.isSuccess(minted)) return minted.success; setMintFailed((failed) => new Set(failed).add(targetId)); failureToast("Could not link the environment", linkRequestFailureMessage(minted.failure)); diff --git a/apps/web/src/state/peerLinks.ts b/apps/web/src/state/peerLinks.ts index 2369afbec9e5..4cb8c3f5ed12 100644 --- a/apps/web/src/state/peerLinks.ts +++ b/apps/web/src/state/peerLinks.ts @@ -40,6 +40,7 @@ export function useLinkableMachines(environmentId: EnvironmentId, enabled: boole const discoverIfUnasked = useEffectEvent(() => { if (discovery.environments.size === 0 && !discovery.refreshing) void refreshDiscovery(); }); + // 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 useEffect(() => { if (enabled) discoverIfUnasked(); }, [enabled]); @@ -49,7 +50,7 @@ export function useLinkableMachines(environmentId: EnvironmentId, enabled: boole return linkableMachines({ environments, threadEnvironmentId: environmentId, - linkedIds: new Set(links.data?.links.map((link) => link.environmentId)), + links: links.data?.links ?? [], connect: discovery.environments, }); } diff --git a/apps/web/src/state/peerPairingCode.ts b/apps/web/src/state/peerPairingCode.ts index e90cd7efe6a3..3ac16819ecc9 100644 --- a/apps/web/src/state/peerPairingCode.ts +++ b/apps/web/src/state/peerPairingCode.ts @@ -1,6 +1,6 @@ import { createPeerPairingCodeCommand } from "@t3tools/client-runtime/state/peerPairingCode"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { AuthMcpClientAccess, EnvironmentId } from "@t3tools/contracts"; import * as Result from "effect/Result"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -10,13 +10,14 @@ const mintPairingCode = createPeerPairingCodeCommand(connectionAtomRuntime); /** * A fresh pairing code minted on `environmentId` with this client's session - * there, for answering a link request. Fails with `PeerPairingCodeError` when - * the session may not create one. + * there, for linking with `access`. Fails with `PeerPairingCodeError` when the + * session may not create one. */ export async function mintPairingCodeOn( environmentId: EnvironmentId, + access: AuthMcpClientAccess, ): Promise> { - const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: undefined }); + const result = await mintPairingCode.run(appAtomRegistry, { environmentId, input: access }); return result._tag === "Success" ? Result.succeed(result.value) : Result.fail(squashAtomCommandFailure(result)); diff --git a/packages/client-runtime/src/linkRequest.test.ts b/packages/client-runtime/src/linkRequest.test.ts index 1176f77b5ac2..f6ea56259c9f 100644 --- a/packages/client-runtime/src/linkRequest.test.ts +++ b/packages/client-runtime/src/linkRequest.test.ts @@ -195,7 +195,7 @@ describe("linkableMachines", () => { linkableMachines({ environments: [here, vps, mini], threadEnvironmentId: here.environmentId, - linkedIds: new Set([mini.environmentId]), + links: [{ environmentId: mini.environmentId, status: "unreachable" }], connect: new Map(), }), ).toEqual([ @@ -226,7 +226,7 @@ describe("linkableMachines", () => { const [listed] = linkableMachines({ environments: [here, box], threadEnvironmentId: here.environmentId, - linkedIds: new Set(), + links: [], connect: new Map([[box.environmentId, discovered(box.environmentId, "Box")]]), }); expect(listed?.urls).toEqual(["https://environment-box.t3.example"]); @@ -235,19 +235,59 @@ describe("linkableMachines", () => { linkableMachines({ environments: [here, box], threadEnvironmentId: here.environmentId, - linkedIds: new Set(), + links: [], connect: new Map(), })[0]?.urls, ).toEqual([]); }); + it("also offers the Connect address of a machine saved by direct address, last", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const vps = presentation("environment-vps", "VPS", { + saved: ["http://192.168.1.20:3773"], + reported: ["https://vps.ts.net"], + }); + const [listed] = linkableMachines({ + environments: [here, vps], + threadEnvironmentId: here.environmentId, + links: [], + connect: new Map([[vps.environmentId, discovered(vps.environmentId, "VPS")]]), + }); + expect(listed?.urls).toEqual([ + "https://vps.ts.net", + "http://192.168.1.20:3773", + "https://environment-vps.t3.example", + ]); + }); + + it("no longer counts a machine as linked once its link expired", () => { + const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); + const vps = presentation("environment-vps", "VPS", { saved: ["https://vps.ts.net"] }); + const mini = presentation("environment-mini", "Mini", { saved: ["http://mini.local:3773"] }); + const listed = linkableMachines({ + environments: [here, vps, mini], + threadEnvironmentId: here.environmentId, + links: [ + { environmentId: vps.environmentId, status: "expired" }, + { environmentId: mini.environmentId, status: "unreachable" }, + { environmentId: EnvironmentId.make("environment-old"), status: "expired" }, + ], + connect: new Map([["environment-old", discovered("environment-old", "Old box")]]), + }); + expect(listed.map((machine) => [machine.label, machine.linked])).toEqual([ + ["VPS", false], + ["Mini", true], + ["Old box", false], + ]); + }); + it("offers machines only on T3 Connect after the saved ones, at their Connect address", () => { const here = presentation("environment-here", "Here", { saved: ["http://localhost:3773"] }); const vps = presentation("environment-vps", "VPS", { saved: ["https://vps.ts.net"] }); const listed = linkableMachines({ environments: [here, vps], threadEnvironmentId: here.environmentId, - linkedIds: new Set([EnvironmentId.make("environment-old")]), + links: [{ environmentId: EnvironmentId.make("environment-old"), status: "reachable" }], connect: new Map([ // This environment and saved machines are listed once, as saved. [here.environmentId, discovered(here.environmentId, "Here")], diff --git a/packages/client-runtime/src/linkRequest.ts b/packages/client-runtime/src/linkRequest.ts index 27e633672231..8df10bda4726 100644 --- a/packages/client-runtime/src/linkRequest.ts +++ b/packages/client-runtime/src/linkRequest.ts @@ -4,6 +4,7 @@ import { type EnvironmentMachineKind, type OrchestrationV2TurnItem, type PeerLinkRequestAnswerInput, + type PeerLinkSummary, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -35,50 +36,55 @@ export interface LinkableMachine { readonly linked: boolean; /** * Where the client knows it answers, most preferred first: the addresses it - * reports, then its saved routes, a T3 Connect route by its Connect address. - * The server keeps only those that answer as this machine. Empty for a - * machine reached only over SSH, whose forward lives in this client. + * reports, then its saved routes, then its T3 Connect address. The server + * keeps only those that answer as this machine. Empty for a machine reached + * only over SSH, whose forward lives in this client. */ readonly urls: ReadonlyArray; } /** - * The machines in the client's catalog other than the thread's own - * environment, in catalog order, then the ones only T3 Connect knows. - * `linkedIds` are the environments the thread's environment links to already. - * `connect` is what relay discovery found, by environment id: a server reaches - * a machine there as a browser does, so its Connect address is a usable one. + * The catalog's machines other than the thread's own, in catalog order, then those only T3 + * Connect knows. `links` are the thread environment's; an expired one no longer counts as + * linked. `connect` is what relay discovery found, and a server reaches a machine there as a + * browser does, so its Connect address is usable. */ export function linkableMachines(input: { readonly environments: ReadonlyArray< EnvironmentPresentation & { readonly environmentId: EnvironmentId } >; readonly threadEnvironmentId: EnvironmentId; - readonly linkedIds: ReadonlySet; + readonly links: ReadonlyArray>; readonly connect: ReadonlyMap; }): ReadonlyArray { + const linkedIds = new Set( + input.links.flatMap((link) => (link.status === "expired" ? [] : [link.environmentId])), + ); const saved = input.environments .filter((environment) => environment.environmentId !== input.threadEnvironmentId) .map((environment) => { const reported = (environment.serverConfig?.directEndpoints ?? []).map( (endpoint) => endpoint.httpBaseUrl, ); - const connect = input.connect.get(environment.environmentId)?.environment.endpoint; const routes = connectionRoutes(environment.entry).flatMap((route) => { - const url = - route.target._tag === "RelayConnectionTarget" - ? connect?.httpBaseUrl - : routeHttpBaseUrl(route); - return url === null || url === undefined ? [] : [url]; + const url = route.target._tag === "RelayConnectionTarget" ? null : routeHttpBaseUrl(route); + return url === null ? [] : [url]; }); + const connect = input.connect.get(environment.environmentId)?.environment.endpoint; return { environmentId: environment.environmentId, label: environment.entry.target.label, machine: resolveEnvironmentMachineKind(environment.serverConfig), connected: environment.connection.phase === "connected", connectOnly: false, - linked: input.linkedIds.has(environment.environmentId), - urls: [...new Set([...reported, ...routes])], + linked: linkedIds.has(environment.environmentId), + urls: [ + ...new Set([ + ...reported, + ...routes, + ...(connect === undefined ? [] : [connect.httpBaseUrl]), + ]), + ], }; }); const savedIds = new Set(saved.map((machine) => machine.environmentId)); @@ -98,7 +104,7 @@ export function linkableMachines(input: { ), connected: false, connectOnly: true, - linked: input.linkedIds.has(environment.environmentId), + linked: linkedIds.has(environment.environmentId), urls: [environment.endpoint.httpBaseUrl], }; }); diff --git a/packages/client-runtime/src/state/peerPairingCode.ts b/packages/client-runtime/src/state/peerPairingCode.ts index a9d5ee9cabdb..814eeb88d544 100644 --- a/packages/client-runtime/src/state/peerPairingCode.ts +++ b/packages/client-runtime/src/state/peerPairingCode.ts @@ -1,14 +1,9 @@ /** - * A pairing code for linking, minted on the environment being linked to with - * the user's own session there, so a link card can be answered without the - * user fetching a code by hand. The code is one-use, carries only what an - * agent sign-in needs, and goes straight into the link answer. + * A one-use pairing code for linking, minted on the environment being linked to with the + * user's own session there, so a link card can be answered without fetching a code by hand. + * It carries exactly what an agent sign-in with the chosen access needs. */ -import { - type AuthGrantScope, - AuthOrchestrationOperateScope, - AuthOrchestrationReadScope, -} from "@t3tools/contracts"; +import { type AuthMcpClientAccess, mcpClientScopes } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import type * as HttpClient from "effect/http/HttpClient"; @@ -26,11 +21,6 @@ import { createEnvironmentCommand } from "./runtime.ts"; const MINT_TIMEOUT_MS = 8_000; const PEER_PAIRING_CODE_LABEL = "T3 Code link"; -/** An agent signing in there needs these, whatever access the user picks. */ -const LINK_SCOPES: ReadonlyArray = [ - AuthOrchestrationReadScope, - AuthOrchestrationOperateScope, -]; /** Minting failed; the user can still paste a code from the other environment. */ export class PeerPairingCodeError extends Data.TaggedError("PeerPairingCodeError")<{ @@ -46,6 +36,7 @@ export class PeerPairingCodeError extends Data.TaggedError("PeerPairingCodeError const mintPeerPairingCode = Effect.fn("clientRuntime.state.mintPeerPairingCode")(function* ( prepared: PreparedConnection, + access: AuthMcpClientAccess, ): Effect.fn.Return { const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); const remoteAuthorization = yield* Effect.serviceOption( @@ -62,7 +53,7 @@ const mintPeerPairingCode = Effect.fn("clientRuntime.state.mintPeerPairingCode") request: ({ client, headers }) => client.pairingCredential({ headers, - payload: { label: PEER_PAIRING_CODE_LABEL, scopes: LINK_SCOPES }, + payload: { label: PEER_PAIRING_CODE_LABEL, scopes: mcpClientScopes(access) }, }), }).pipe( Effect.mapError( @@ -89,17 +80,16 @@ export function createPeerPairingCodeCommand( ) { return createEnvironmentCommand(runtime, { label: "environment-data:peer-pairing-code:mint", - execute: (_input: void) => - Effect.gen(function* () { - const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; - const prepared = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(prepared)) { - return yield* new PeerPairingCodeError({ - reason: "unavailable", - cause: "The environment is not connected.", - }); - } - return yield* mintPeerPairingCode(prepared.value); - }), + execute: Effect.fn(function* (access: AuthMcpClientAccess) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) { + return yield* new PeerPairingCodeError({ + reason: "unavailable", + cause: "The environment is not connected.", + }); + } + return yield* mintPeerPairingCode(prepared.value, access); + }), }); } diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index fbdf2c3a34e6..7e4cc84b7415 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -408,6 +408,19 @@ export const AuthMcpClientAccess = Schema.Literals([ ]); export type AuthMcpClientAccess = typeof AuthMcpClientAccess.Type; +/** The scopes an agent session gets for an access level, and a pairing code must carry. */ +export const mcpClientScopes = (access: AuthMcpClientAccess): ReadonlyArray => + access === "read-only" + ? [AuthOrchestrationReadScope] + : access === "full-access" + ? [ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + AuthSourceControlWriteScope, + ] + : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; + export const AuthMcpProtectedResourceMetadata = Schema.Struct({ resource: Schema.String, authorization_servers: Schema.Array(Schema.String), diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 4a1c5f4ce423..9b9100287f9c 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -769,27 +769,22 @@ export type OrchestratorMcpRequestSecretResult = typeof OrchestratorMcpRequestSe export const OrchestratorMcpEnvironmentLinkInput = Schema.Struct({ environmentId: Schema.optional(EnvironmentId).annotate({ description: - "The environment to link, when you know it, e.g. from a machine the user mentioned. Omit it otherwise: the user picks the machine in the card.", + "The machine, when known, e.g. from a mentioned machine. Otherwise the user picks it in the card.", }), url: Schema.optional(TrimmedNonEmptyString).annotate({ description: - "Only when the user gave an address: its https, Tailscale, LAN or loopback base URL. Never guess one.", + "Only an address the user gave: https, Tailscale, LAN or loopback. Never guess one.", }), hint: Schema.optional(TrimmedNonEmptyString).annotate({ - description: - 'A short phrase from the user\'s words naming the machine, e.g. "vps" or "mac mini". It only preselects a machine in the card.', + description: 'The user\'s words for the machine, e.g. "vps". Preselects it in the card.', }), reason: Schema.optional(TrimmedNonEmptyString).annotate({ - description: "One sentence on why, shown on the card.", + description: "One sentence for the card.", }), requestedAccess: Schema.optional(AuthMcpClientAccess).annotate({ - description: - "The access to suggest. The user picks the access in the card; it defaults to approval-required.", - }), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId).annotate({ - description: - "Reuse when retrying a call that lost its result, so the user sees one card and its current outcome is returned. Use a new id to ask again.", + description: "Suggested access. The user picks; default approval-required.", }), + clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), }); export type OrchestratorMcpEnvironmentLinkInput = typeof OrchestratorMcpEnvironmentLinkInput.Type; diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 613d80bdd29e..5ce74766c401 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -14,7 +14,7 @@ Ordinary coding tasks need no T3 tools. `- \`t3_thread_launch\` and \`create_threads\` make new top-level threads, only when the user asks for a new thread. "Subagent", "delegate", and "parallel" never mean one. Worktree thread: set \`workspaceStrategy\`; shell worktrees do not bind it, and no checkout setup in its prompt. No \`workspaceStrategy\`: project root, not your worktree. - Given a thread id, \`t3_thread_read\` it before you answer about it. - Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` to a T3 tool to act there. Ids are then that machine's, so pass \`threadId\`. -- A machine the user names that is not linked yet ("delegate this to my vps"): call \`t3_environment_link\` with a \`hint\` from their words, or \`environmentId\` when one names it. The user picks it in a card and the outcome arrives here as a message: do not wait or poll, and never ask for a pairing code in chat. +- A machine the user names that is not linked ("delegate this to my vps"): call \`t3_environment_link\` with a \`hint\` from their words. Never ask for a pairing code in chat. - In your reply, a thread's first mention is its \`link\` verbatim. No \`link\`: \`[title](t3-thread://v1/)\`. Never a plain title or bare id. A thread on a linked machine: its title and machine, not its link. - A secret: call \`request_secret\` yourself. Pass its \`secretRef\` to a T3 tool, or use \`delivery: "file"\` for a shell command. Never ask in chat or invent one. When asked only to schedule, \`schedule_task\` without running its prompt. - T3 tools may load late. Try the name once before you call it absent.`,