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
4 changes: 3 additions & 1 deletion apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ describe("DesktopServerExposure", () => {
mode: "network-accessible",
endpointUrl: "http://192.168.1.20:4173",
advertisedHost: "192.168.1.20",
tailscaleServeEnabled: false,
tailscaleServeEnabled: true,
tailscaleServePort: 443,
});

Expand Down Expand Up @@ -375,6 +375,8 @@ describe("DesktopServerExposure", () => {
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });
// Serve reads status on its own; this counts the endpoint cache's reads alone.
yield* serverExposure.setTailscaleServeEnabled({ enabled: false });
yield* serverExposure.setMode("network-accessible");

const initial = yield* serverExposure.getAdvertisedEndpoints;
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/settings/DesktopAppSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ describe("DesktopSettings", () => {
mainWindowBounds: null,
mainWindowMaximized: false,
serverExposureMode: "local-only",
tailscaleServeEnabled: false,
tailscaleServeEnabled: true,
tailscaleServePort: 443,
updateChannel: "nightly",
updateChannelConfiguredByUser: false,
Expand Down Expand Up @@ -303,7 +303,7 @@ describe("DesktopSettings", () => {
mainWindowBounds: null,
mainWindowMaximized: false,
serverExposureMode: "local-only",
tailscaleServeEnabled: false,
tailscaleServeEnabled: true,
tailscaleServePort: 443,
updateChannel: "latest",
updateChannelConfiguredByUser: true,
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/settings/DesktopAppSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
mainWindowBounds: null,
mainWindowMaximized: false,
serverExposureMode: "local-only",
tailscaleServeEnabled: false,
// Tailnet devices dial each other back over Serve; it never publishes beyond the tailnet.
tailscaleServeEnabled: true,
tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
updateChannel: "latest",
updateChannelConfiguredByUser: false,
Expand Down Expand Up @@ -221,7 +222,7 @@ function normalizeDesktopSettingsDocument(
mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,
serverExposureMode:
parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
tailscaleServeEnabled: parsed.tailscaleServeEnabled !== false,
tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
updateChannel: updateChannelConfiguredByUser
? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel)
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
DpopFailureReason,
type DpopFailureReason as DpopFailureReasonType,
mcpClientScopes,
type EnvironmentId,
} from "@t3tools/contracts";
import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -78,6 +79,8 @@ export interface McpClientSession {
readonly access: AuthMcpClientAccess;
/** Another T3 Code environment's link, whose work here stays within it. */
readonly peerLink: boolean;
/** That environment, when it said which one it is. */
readonly peerEnvironmentId: EnvironmentId | undefined;
}

export interface AuthenticatedSession {
Expand Down Expand Up @@ -539,6 +542,7 @@ export class EnvironmentAuth extends Context.Service<
readonly label: string;
readonly access: AuthMcpClientAccess;
readonly peerLink: boolean;
readonly peerEnvironmentId?: EnvironmentId;
readonly client: AuthClientMetadata;
}) => Effect.Effect<
{
Expand Down Expand Up @@ -1288,6 +1292,7 @@ export const make = Effect.gen(function* () {
? (session.runtimeModeCeiling ?? "approval-required")
: "read-only",
peerLink: session.peerLink === true,
peerEnvironmentId: session.peerEnvironmentId,
} satisfies McpClientSession)
: Effect.fail(
new ServerAuthInvalidCredentialError({
Expand All @@ -1308,6 +1313,9 @@ export const make = Effect.gen(function* () {
ttl: MCP_CLIENT_SESSION_TTL,
...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }),
peerLink: input.peerLink,
...(input.peerEnvironmentId === undefined
? {}
: { peerEnvironmentId: input.peerEnvironmentId }),
client: { ...input.client, label: input.label, deviceType: "bot" },
})
.pipe(
Expand Down
29 changes: 26 additions & 3 deletions apps/server/src/auth/McpOAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type AuthEnvironmentScope,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
EnvironmentId,
MCP_PEER_LINK_SOFTWARE_ID,
mcpClientScopes,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -137,6 +138,8 @@ const ClientIdPayload = Schema.Struct({
r: Schema.Array(Schema.String),
/** Registered as another T3 Code environment's peer link. */
p: Schema.optionalKey(Schema.Literal(1)),
/** The linking environment's id, as it said when registering. */
e: Schema.optionalKey(EnvironmentId),
});
const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload));

Expand All @@ -145,6 +148,7 @@ export interface McpOAuthClient {
readonly name: string;
readonly redirectUris: ReadonlyArray<string>;
readonly peerLink: boolean;
readonly peerEnvironmentId: EnvironmentId | undefined;
}

export class McpOAuthPageError extends Schema.TaggedError<McpOAuthPageError>()(
Expand Down Expand Up @@ -179,6 +183,7 @@ interface PendingCode {
readonly clientId: string;
readonly clientName: string;
readonly peerLink: boolean;
readonly peerEnvironmentId: EnvironmentId | undefined;
readonly redirectUri: string;
readonly codeChallenge: string;
readonly resource: string;
Expand Down Expand Up @@ -252,9 +257,20 @@ const make = Effect.gen(function* () {

const sign = (domain: string, payload: string) => signPayload(`${domain}.${payload}`, signingKey);

const signClientId = (name: string, redirectUris: ReadonlyArray<string>, peerLink: boolean) => {
const signClientId = (
name: string,
redirectUris: ReadonlyArray<string>,
peerLink: boolean,
peerEnvironmentId: EnvironmentId | undefined,
) => {
const body = base64UrlEncode(
JSON.stringify({ v: 1, n: name, r: redirectUris, ...(peerLink ? { p: 1 } : {}) }),
JSON.stringify({
v: 1,
n: name,
r: redirectUris,
...(peerLink ? { p: 1 } : {}),
...(peerLink && peerEnvironmentId !== undefined ? { e: peerEnvironmentId } : {}),
}),
);
return `${body}.${sign("mcp-client-id", body)}`;
};
Expand All @@ -276,6 +292,7 @@ const make = Effect.gen(function* () {
name: payload.n,
redirectUris: payload.r,
peerLink: payload.p === 1,
peerEnvironmentId: payload.e,
})),
),
);
Expand Down Expand Up @@ -330,11 +347,13 @@ const make = Effect.gen(function* () {
"Linked environments is off on that machine. Turn it on in its Settings → Labs.",
});
}
const peerEnvironmentId = peerLink ? metadata.t3_environment_id : undefined;
return {
clientId: signClientId(name, redirectUris, peerLink),
clientId: signClientId(name, redirectUris, peerLink, peerEnvironmentId),
name,
redirectUris,
peerLink,
peerEnvironmentId,
};
},
);
Expand Down Expand Up @@ -418,6 +437,7 @@ const make = Effect.gen(function* () {
clientId: authorization.client.clientId,
clientName: authorization.client.name,
peerLink: authorization.client.peerLink,
peerEnvironmentId: authorization.client.peerEnvironmentId,
redirectUri: authorization.redirectUri,
codeChallenge: authorization.codeChallenge,
resource: authorization.resource,
Expand Down Expand Up @@ -520,6 +540,9 @@ const make = Effect.gen(function* () {
label: pending.clientName,
access: pending.access,
peerLink: pending.peerLink,
...(pending.peerEnvironmentId === undefined
? {}
: { peerEnvironmentId: pending.peerEnvironmentId }),
client: deriveAuthClientMetadata({ request }),
})
.pipe(
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type AuthClientMetadata,
type AuthClientSession,
type AuthEnvironmentScope,
type EnvironmentId,
AuthWebSocketTicketUse,
RuntimeMode,
type ServerAuthSessionMethod,
Expand Down Expand Up @@ -61,6 +62,8 @@ export interface VerifiedSession {
readonly runtimeModeCeiling?: RuntimeMode;
/** An MCP client that is another T3 Code environment's peer link. */
readonly peerLink?: true;
/** The environment holding the session, when another T3 server holds it. */
readonly peerEnvironmentId?: EnvironmentId;
}

export interface VerifiedWebSocketTicket extends VerifiedSession {
Expand Down Expand Up @@ -383,6 +386,7 @@ export class SessionStore extends Context.Service<
readonly proofKeyThumbprint?: string;
readonly runtimeModeCeiling?: RuntimeMode;
readonly peerLink?: boolean;
readonly peerEnvironmentId?: EnvironmentId;
readonly replaceActiveForSubjectAndMethod?: boolean;
readonly replaceSessionId?: AuthSessionId;
}) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
Expand Down Expand Up @@ -566,6 +570,9 @@ const make = Effect.gen(function* () {
scopes: row.value.scopes,
method: row.value.method,
client: toClientMetadata(row.value.client),
...(row.value.peerEnvironmentId === null
? {}
: { peerEnvironmentId: row.value.peerEnvironmentId }),
issuedAt: row.value.issuedAt,
expiresAt: row.value.expiresAt,
lastConnectedAt: row.value.lastConnectedAt,
Expand Down Expand Up @@ -689,6 +696,9 @@ const make = Effect.gen(function* () {
os: client.os ?? null,
browser: client.browser ?? null,
},
...(input?.peerEnvironmentId === undefined
? {}
: { peerEnvironmentId: input.peerEnvironmentId }),
issuedAt,
expiresAt,
} satisfies AuthSessions.CreateAuthSessionInput;
Expand Down Expand Up @@ -723,6 +733,9 @@ const make = Effect.gen(function* () {
scopes: claims.scopes,
method: claims.method,
client,
...(input?.peerEnvironmentId === undefined
? {}
: { peerEnvironmentId: input.peerEnvironmentId }),
issuedAt,
expiresAt,
lastConnectedAt: null,
Expand Down Expand Up @@ -838,6 +851,9 @@ const make = Effect.gen(function* () {
...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}),
...(claims.plk === 1 ? { peerLink: true as const } : {}),
...(row.value.peerEnvironmentId === null
? {}
: { peerEnvironmentId: row.value.peerEnvironmentId }),
} satisfies VerifiedSession;
},
);
Expand Down Expand Up @@ -969,6 +985,7 @@ const make = Effect.gen(function* () {
scopes: row.scopes,
method: row.method,
client: toClientMetadata(row.client),
...(row.peerEnvironmentId === null ? {} : { peerEnvironmentId: row.peerEnvironmentId }),
issuedAt: row.issuedAt,
expiresAt: row.expiresAt,
lastConnectedAt: row.lastConnectedAt,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
desktopBootstrapToken: undefined,
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: false,
tailscaleServeEnabled: false,
tailscaleServeEnabled: true,
tailscaleServePort: undefined,
});
}),
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct
Option.fromUndefinedOr(env.tailscaleServeEnabled),
Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled),
),
() => false,
// Tailnet devices dial each other back over Serve. A dev server never owns the machine's route.
() => devUrl === undefined,
);
const tailscaleServePort = Option.getOrUndefined(
resolveOptionPrecedence(
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/peer/PeerLinkRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import {
RunId,
ThreadId,
TurnItemId,
type PeerLink,
} 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 Ref from "effect/Ref";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -57,6 +59,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as PeerForwarding from "./PeerForwarding.ts";
import * as PeerLinkRequests from "./PeerLinkRequests.ts";
import * as PeerLinks from "./PeerLinks.ts";
import * as TailnetDevices from "./TailnetDevices.ts";
import {
descriptorOf,
layerLinkingEnvironment,
Expand Down Expand Up @@ -128,6 +131,8 @@ const scope: McpInvocationContext.McpInvocationScope = {
/** The laptop: a real orchestrator, its orchestrator toolkit, and its links. */
const makeLaptop = Effect.gen(function* () {
const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build);
// What Tailscale proves: none of these machines is one of the user's tailnet devices unless set.
const tailnetLink = yield* Ref.make(Option.none<PeerLink>());
// The orchestrator's own database, apart from the one links are kept in.
const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build);
const layerDatabase = Layer.succeedContext(database);
Expand Down Expand Up @@ -155,6 +160,9 @@ const makeLaptop = Effect.gen(function* () {
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provideMerge(PeerLinkRequests.layer),
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(
Layer.mock(TailnetDevices.TailnetDevices)({ linkIfMine: () => Ref.get(tailnetLink) }),
),
Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})),
Layer.provide(Layer.succeedContext(linking)),
Layer.provide(NodeCrypto.layer),
Expand All @@ -177,6 +185,7 @@ const makeLaptop = Effect.gen(function* () {
const here = yield* Layer.build(layerHere);
const server = Context.get(here, McpServer.McpServer);
return {
tailnetLink,
links: Context.get(linking, PeerLinks.PeerLinks),
linksSql: Context.get(linking, SqlClient.SqlClient),
requests: Context.get(here, PeerLinkRequests.PeerLinkRequests),
Expand Down Expand Up @@ -916,6 +925,37 @@ it.live("naming a machine that is already linked returns the link at once, with
),
);

it.live("naming one of the user's tailnet devices links it at once, with no card", () =>
withBoth((a) =>
Effect.gen(function* () {
yield* seedThread(a);
const linkedAt = yield* DateTime.now;
yield* Ref.set(
a.tailnetLink,
Option.some({
environmentId: box.environmentId,
label: "Box",
urls: ["https://box.tail.ts.net"],
access: "full-access",
origin: "tailnet",
linkedAt,
expiresAt: DateTime.add(linkedAt, { days: 30 }),
lastReachedAt: linkedAt,
lastError: null,
}),
);
const asked = yield* a.call({ environmentId: box.environmentId });
expect(asked.structuredContent).toEqual({
status: "linked",
environmentId: box.environmentId,
label: "Box",
access: "full-access",
});
expect(yield* cardOf(a)).toBeUndefined();
}),
),
);

it.live("a link that expired goes through the card, so the user can link it again", () =>
withBoth((a, b) =>
Effect.gen(function* () {
Expand Down
Loading
Loading