Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/server/src/peer/TailnetDevices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { NodeHttpServer } from "@effect/platform-node";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import {
AuthAccessReadScope,
AuthDiagnosticsReadScope,
AuthFilesystemReadScope,
AuthOrchestrationReadScope,
AuthRelayReadScope,
AuthStandardClientScopes,
EnvironmentHttpApi,
EnvironmentId,
type ExecutionEnvironmentDescriptor,
Expand Down Expand Up @@ -190,6 +196,8 @@ const serveDeviceOn = (
),
),
settings: Context.get(context, ServerSettings.ServerSettingsService),
/** One-time pairing credentials this device issued, with their scopes. */
pairingLinks: auth.listPairingLinks(),
/** Grants delivered to this device, each carrying a token. */
grants,
/** Sessions this device issued to other devices. */
Expand Down Expand Up @@ -509,6 +517,60 @@ describe("tailnet device access", () => {
);
});

describe("opening a device from the app", () => {
it.live("gets a one-time app credential from the device, within its access", () =>
Effect.gen(function* () {
const tailnet = yield* makeTailnet;
const a = yield* serveDevice(laptop, tailnet);
const b = yield* serveDevice(studio, tailnet);
yield* tailnet.join(a.url);
yield* tailnet.join(b.url);
yield* a.devices.linkIfMine({ url: b.url });

const full = yield* a.devices.openDevice(studio.environmentId);
expect([full.environmentId, full.urls]).toEqual([studio.environmentId, [b.url]]);
yield* b.devices.setAccess(laptop.environmentId, "read-only");
yield* a.devices.openDevice(studio.environmentId);

expect((yield* b.pairingLinks).map((link) => link.permissions?.toSorted())).toEqual([
[
AuthAccessReadScope,
AuthDiagnosticsReadScope,
AuthFilesystemReadScope,
AuthOrchestrationReadScope,
AuthRelayReadScope,
].toSorted(),
[...AuthStandardClientScopes].toSorted(),
]);
}),
);

it.live("gets nothing from a device that revoked it, nor without its device session", () =>
Effect.gen(function* () {
const tailnet = yield* makeTailnet;
const a = yield* serveDevice(laptop, tailnet);
const b = yield* serveDevice(studio, tailnet);
yield* tailnet.join(a.url);
yield* tailnet.join(b.url);
yield* a.devices.linkIfMine({ url: b.url });
yield* b.devices.setAccess(laptop.environmentId, "revoked");

const refused = yield* a.devices.openDevice(studio.environmentId).pipe(Effect.flip);
// Anyone can claim to be a device; without its session the device mints nothing.
const forged = yield* Effect.gen(function* () {
const client = yield* PeerLinks.peerApiClient(yield* HttpClient.HttpClient, b.url);
return yield* client.tailnetDevices.clientCredential({
payload: { from: laptop.environmentId },
});
}).pipe(Effect.flip, Effect.provide(FetchHttpClient.layer));

expect(refused._tag).toBe("TailnetDevicesError");
expect(forged._tag).toBe("TailnetDevicesUnauthorizedError");
expect(yield* b.pairingLinks).toEqual([]);
}),
);
});

describe("locating a tailnet target", () => {
const tailnet: Tailnet = {
self: {
Expand Down
132 changes: 122 additions & 10 deletions apps/server/src/peer/TailnetDevices.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import {
AuthAccessReadScope,
AuthDiagnosticsReadScope,
AuthFilesystemReadScope,
type AuthGrantScope,
type AuthMcpClientAccess,
AuthOrchestrationReadScope,
AuthRelayReadScope,
AuthStandardClientScopes,
type EnvironmentId,
type ExecutionEnvironmentDescriptor,
type PeerLink,
TAILNET_DEVICE_CAPABILITY,
type TailnetChallengeConfirm,
type TailnetDevice,
type TailnetDeviceClientCredential,
type TailnetDeviceClientCredentialRequest,
type TailnetDeviceAccess,
type DevicePolicyEntry,
type TailnetDeviceDescriptor,
Expand Down Expand Up @@ -119,6 +129,21 @@ const unreachable = (message: string, cause?: unknown) =>
...(cause === undefined ? {} : { cause }),
});

/**
* App sessions carry no agent mode ceiling, so anything short of full access
* opens the app read-only rather than with a shell and full control.
*/
const clientScopesFor = (access: AuthMcpClientAccess): ReadonlyArray<AuthGrantScope> =>
access === "full-access"
? AuthStandardClientScopes
: [
AuthOrchestrationReadScope,
AuthFilesystemReadScope,
AuthDiagnosticsReadScope,
AuthAccessReadScope,
AuthRelayReadScope,
];

const storageError = (cause: unknown) =>
new TailnetDevicesError({
reason: "storage",
Expand Down Expand Up @@ -157,6 +182,13 @@ export class TailnetDevices extends Context.Service<
readonly environmentId?: EnvironmentId;
readonly url?: string;
}) => Effect.Effect<Option.Option<PeerLink>>;
/**
* A one-time pairing credential for my app on another of my devices, asked
* for over this device's session there, so the app connects without a code.
*/
readonly openDevice: (
environmentId: EnvironmentId,
) => Effect.Effect<TailnetDeviceClientCredential, TailnetDevicesError>;
/** Runs the handshake again for a device's link, as when its token stops working. */
readonly refresh: (environmentId: EnvironmentId) => Effect.Effect<void>;
readonly handleRequest: (request: TailnetLinkRequest) => Effect.Effect<TailnetLinkResult>;
Expand All @@ -166,6 +198,10 @@ export class TailnetDevices extends Context.Service<
request: HttpServerRequest.HttpServerRequest,
payload: TailnetDevicesSyncPayload,
) => Effect.Effect<TailnetDevicesSyncPayload, TailnetDevicesUnauthorizedError>;
readonly handleClientCredential: (
request: HttpServerRequest.HttpServerRequest,
payload: TailnetDeviceClientCredentialRequest,
) => Effect.Effect<TailnetDeviceClientCredential, TailnetDevicesUnauthorizedError>;
/** Finds and links devices on the tailnet once, and refreshes and syncs existing links. */
readonly scan: Effect.Effect<void>;
/** Scans at startup, every ten minutes and when the tailnet's devices change. */
Expand Down Expand Up @@ -634,21 +670,95 @@ const make = Effect.gen(function* () {
const handleSync: TailnetDevices["Service"]["handleSync"] = Effect.fn(
"TailnetDevices.handleSync",
)(function* (request, payload) {
yield* authenticateDevice(request, payload.from.environmentId);
yield* mergeSync(payload, payload.from.environmentId).pipe(
Effect.tapError((cause) => Effect.logWarning("Could not merge a device's sync", { cause })),
Effect.ignore,
);
return yield* syncPayload;
});

/** The device session a linked device holds here, by the id it claims. */
const authenticateDevice = Effect.fn("TailnetDevices.authenticateDevice")(function* (
request: HttpServerRequest.HttpServerRequest,
claimed: EnvironmentId,
) {
const caller = yield* auth.authenticateMcpClient(request).pipe(Effect.option);
if (
Option.isNone(caller) ||
caller.value.peerEnvironmentId === undefined ||
caller.value.peerEnvironmentId !== payload.from.environmentId
) {
if (Option.isNone(caller) || caller.value.peerEnvironmentId !== claimed) {
return yield* new TailnetDevicesUnauthorizedError({
message: "Sync needs the session this machine issued to the calling device.",
message: "This needs the session this machine issued to the calling device.",
});
}
yield* mergeSync(payload, caller.value.peerEnvironmentId).pipe(
Effect.tapError((cause) => Effect.logWarning("Could not merge a device's sync", { cause })),
Effect.ignore,
return caller.value;
});

const handleClientCredential: TailnetDevices["Service"]["handleClientCredential"] = Effect.fn(
"TailnetDevices.handleClientCredential",
)(function* (request, payload) {
const caller = yield* authenticateDevice(request, payload.from);
const self = yield* selfDescriptor;
const issued = yield* auth
.issuePairingCredential({
scopes: clientScopesFor(caller.access),
label: `Opened from ${caller.label.replace(/^T3 Code · /u, "")}`,
})
.pipe(Effect.orDie);
return {
environmentId: self.environmentId,
urls: self.urls,
credential: issued.credential,
expiresAt: issued.expiresAt,
} satisfies TailnetDeviceClientCredential;
});

const openDevice: TailnetDevices["Service"]["openDevice"] = Effect.fn(
"TailnetDevices.openDevice",
)(function* (environmentId) {
const self = yield* environment.getEnvironmentId;
if (environmentId === self) {
return yield* new TailnetDevicesError({
reason: "self",
message: "This machine is already the one you are using.",
});
}
if ((yield* policy.accessFor(environmentId)) === "revoked") {
return yield* new TailnetDevicesError({
reason: "revoked",
message: "That device is revoked. Restore it to open it.",
});
}
const ask = Effect.gen(function* () {
const resolved = yield* links.resolve(environmentId);
const authorized = HttpClient.mapRequest(
httpClient,
HttpClientRequest.bearerToken(resolved.token),
);
return yield* PeerLinks.peerApiClient(authorized, resolved.url).pipe(
Effect.flatMap((client) =>
client.tailnetDevices.clientCredential({ payload: { from: self } }),
),
Effect.timeout(DESCRIBE_TIMEOUT),
);
});
const credential = yield* ask.pipe(
Effect.catchTags({
TailnetDevicesUnauthorizedError: () => refresh(environmentId).pipe(Effect.andThen(ask)),
}),
Effect.mapError((cause) =>
cause._tag === "PeerLinkError"
? new TailnetDevicesError({
reason: "unknown_device",
message: "This machine has no link to that device.",
cause,
})
: unreachable("That device did not answer.", cause),
),
);
return yield* syncPayload;
// Only the device it asked may answer for it.
if (credential.environmentId !== environmentId) {
return yield* unreachable("A different machine answered for that device.");
}
return credential;
});

/** Exchanges device hints with a linked device; a refused token starts a new handshake. */
Expand Down Expand Up @@ -928,11 +1038,13 @@ const make = Effect.gen(function* () {
list,
setAccess,
linkIfMine,
openDevice,
refresh,
handleRequest,
handleConfirm,
handleGrant,
handleSync,
handleClientCredential,
scan,
start,
});
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/peer/tailnetDevicesHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const layer = HttpApiBuilder.group(
.handle("request", ({ payload }) => devices.handleRequest(payload))
.handle("confirm", ({ payload }) => devices.handleConfirm(payload))
.handle("grant", ({ payload }) => devices.handleGrant(payload))
.handle("sync", ({ payload, request }) => devices.handleSync(request, payload));
.handle("sync", ({ payload, request }) => devices.handleSync(request, payload))
.handle("clientCredential", ({ payload, request }) =>
devices.handleClientCredential(request, payload),
);
}),
);
11 changes: 3 additions & 8 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { OrchestrationCommandReceiptRepository } from "./persistence/OrchestrationCommandReceipts.ts";
import { TailnetDevicesError, OrchestrationDispatchCommandError } from "@t3tools/contracts";
import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
import * as McpGatewayBroker from "./mcp/McpGatewayBroker.ts";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -1374,12 +1374,6 @@ export const layerWsRpc = (
const serverSettings = yield* ServerSettings.ServerSettingsService;
const peerLinks = yield* PeerLinks.PeerLinks;
const tailnetDevices = yield* TailnetDevices.TailnetDevices;
const tailnetDevicesUnavailable = Effect.fail(
new TailnetDevicesError({
reason: "unreachable",
message: "This server cannot link devices yet.",
}),
);
const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests;
const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
Expand Down Expand Up @@ -2617,7 +2611,8 @@ export const layerWsRpc = (
[WS_METHODS.tailnetDevicesSetAccess]: (input) =>
tailnetDevices.setAccess(input.environmentId, input.access),
[WS_METHODS.tailnetDevicesUpdateSettings]: (input) => tailnetDevices.updateSettings(input),
[WS_METHODS.tailnetDevicesOpenDevice]: () => tailnetDevicesUnavailable,
[WS_METHODS.tailnetDevicesOpenDevice]: (input) =>
tailnetDevices.openDevice(input.environmentId),
[WS_METHODS.serverUpdateSettings]:
/* Request instrumentation already owns the span; keep this helper in that context. */
Effect.fnUntraced(function* ({ patch, providerInstanceMutation, replicateProfiles }) {
Expand Down
6 changes: 6 additions & 0 deletions docs/internals/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ through another. Each `/mcp` request narrows a device session's token to its
current entry, so a change applies without re-linking; widening past what the
token was minted with takes a new handshake.

An app opens another of my devices through the server it is connected to, which
asks that device over its device session for a one-time pairing credential. The
app redeems it directly at the first address that answers as that device. App
sessions carry no agent mode ceiling, so a device below full access opens
read-only.

**Work a link paired below full access starts stays within that link.** The
receiving environment knows a session is a peer link because the linking side
registers its OAuth client with the `t3code-peer-link` software id, and the
Expand Down
27 changes: 26 additions & 1 deletion packages/client-runtime/src/connection/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ORCHESTRATION_PROTOCOL_VERSION,
} from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -56,6 +57,8 @@ function layerPairingHttp(
calls: Array<{ readonly url: string; readonly init: RequestInit }>,
options?: {
readonly failDescriptor?: boolean;
/** Hosts whose descriptor does not answer. */
readonly downHosts?: ReadonlyArray<string>;
readonly failExchange?: boolean;
readonly protocolVersion?: number;
readonly selfUpdate?: boolean;
Expand All @@ -70,7 +73,10 @@ function layerPairingHttp(
calls.push({ url, init });

if (url.endsWith("/.well-known/t3/environment")) {
if (options?.failDescriptor === true) {
if (
options?.failDescriptor === true ||
options?.downHosts?.includes(new URL(url).host) === true
) {
return Promise.resolve(
Response.json({ message: "descriptor unavailable" }, { status: 503 }),
);
Expand Down Expand Up @@ -280,6 +286,25 @@ describe("connection onboarding", () => {
}),
);

it.effect("spends a device's credential only at an address that answers as that device", () => {
const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
return Effect.gen(function* () {
const onboarding = yield* Onboarding.ConnectionOnboarding;

const environmentId = yield* onboarding.registerDevice({
environmentId: PAIRED_ENVIRONMENT_ID,
urls: ["https://down.example.test", "https://remote.example.test"],
credential: "pairing-token",
expiresAt: DateTime.makeUnsafe(0),
});

expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
expect(
calls.filter((call) => call.url.endsWith("/oauth/token")).map((call) => call.url),
).toEqual(["https://remote.example.test/oauth/token"]);
}).pipe(Effect.provide(layerPairingOnboarding({ downHosts: ["down.example.test"] }, calls)));
});

it.effect("turns a switched-off environment back on when it is paired again", () =>
Effect.gen(function* () {
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
Expand Down
Loading
Loading