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
56 changes: 39 additions & 17 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import * as ServerConfig from "../config.ts";
import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
import * as PairingGrantStore from "./PairingGrantStore.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";
import * as DevicePolicy from "../peer/DevicePolicy.ts";
import * as SessionStore from "./SessionStore.ts";
import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";
import { verifyRequestDpopProof } from "./dpop.ts";
Expand Down Expand Up @@ -703,6 +704,7 @@ export const make = Effect.gen(function* () {
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
const sessions = yield* SessionStore.SessionStore;
const devicePolicy = yield* DevicePolicy.DevicePolicy;
const secretStore = yield* ServerSecretStore.ServerSecretStore;
const crypto = yield* Crypto.Crypto;
const descriptor = yield* policy.getDescriptor();
Expand Down Expand Up @@ -1283,23 +1285,42 @@ export const make = Effect.gen(function* () {
if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({}));
return sessions.verify(token).pipe(
mapSessionVerificationErrors,
Effect.flatMap((session) =>
session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token"
? Effect.succeed({
sessionId: session.sessionId,
label: session.client.label ?? "MCP client",
access: session.scopes.includes(AuthOrchestrationOperateScope)
? (session.runtimeModeCeiling ?? "approval-required")
: "read-only",
peerLink: session.peerLink === true,
peerEnvironmentId: session.peerEnvironmentId,
} satisfies McpClientSession)
: Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "Only MCP client sessions are accepted here.",
}),
),
),
Effect.flatMap((session) => {
if (session.subject !== MCP_CLIENT_SUBJECT || session.method !== "bearer-access-token") {
return Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "Only MCP client sessions are accepted here.",
}),
);
}
const client: McpClientSession = {
sessionId: session.sessionId,
label: session.client.label ?? "MCP client",
access: session.scopes.includes(AuthOrchestrationOperateScope)
? (session.runtimeModeCeiling ?? "approval-required")
: "read-only",
peerLink: session.peerLink === true,
peerEnvironmentId: session.peerEnvironmentId,
};
if (session.peerEnvironmentId === undefined) return Effect.succeed(client);
// The device's current policy narrows what its token was issued with, on every request.
return devicePolicy.entryAccess(session.peerEnvironmentId).pipe(
Effect.flatMap((entry) =>
Option.isNone(entry)
? Effect.succeed(client)
: entry.value === "revoked"
? Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "This device's access was revoked.",
}),
)
: Effect.succeed({
...client,
access: DevicePolicy.narrowerAccess(client.access, entry.value),
}),
),
);
}),
Effect.withSpan("EnvironmentAuth.authenticateMcpClient"),
);
};
Expand Down Expand Up @@ -1395,6 +1416,7 @@ export const make = Effect.gen(function* () {
});

export const layer = Layer.effect(EnvironmentAuth, make).pipe(
Layer.provideMerge(DevicePolicy.layer),
Layer.provideMerge(PairingGrantStore.layer),
Layer.provideMerge(SessionStore.layer),
Layer.provideMerge(EnvironmentAuthPolicy.layer),
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,18 @@ export class OtherSessionsRevocationError extends Schema.TaggedError<OtherSessio
}
}

export class PeerSessionsRevocationError extends Schema.TaggedError<PeerSessionsRevocationError>()(
"PeerSessionsRevocationError",
{
peerEnvironmentId: Schema.String,
...sessionCredentialInternalErrorContext,
},
) {
override get message(): string {
return "Failed to revoke another environment's sessions.";
}
}

export const SessionCredentialInternalError = Schema.Union([
SessionClaimsEncodingError,
SessionCredentialIssueError,
Expand All @@ -363,6 +375,7 @@ export const SessionCredentialInternalError = Schema.Union([
ActiveSessionsListError,
SessionRevocationError,
OtherSessionsRevocationError,
PeerSessionsRevocationError,
]);
export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type;

Expand Down Expand Up @@ -422,6 +435,10 @@ export class SessionStore extends Context.Service<
readonly revokeAllExcept: (
sessionId: AuthSessionId,
) => Effect.Effect<number, SessionCredentialInternalError>;
/** Revokes every session another T3 environment holds here. */
readonly revokeByPeerEnvironment: (
peerEnvironmentId: EnvironmentId,
) => Effect.Effect<number, SessionCredentialInternalError>;
readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
}
Expand Down Expand Up @@ -1051,6 +1068,27 @@ const make = Effect.gen(function* () {
return revokedSessionIds.length;
});

const revokeByPeerEnvironment: SessionStore["Service"]["revokeByPeerEnvironment"] = Effect.fn(
"SessionStore.revokeByPeerEnvironment",
)(function* (peerEnvironmentId) {
const revokedAt = yield* DateTime.now;
const revokedSessionIds = yield* authSessions
.revokeByPeerEnvironment({ peerEnvironmentId, revokedAt })
.pipe(
Effect.mapError((cause) => new PeerSessionsRevocationError({ peerEnvironmentId, cause })),
);
yield* Ref.update(connectedSessionsRef, (current) => {
const next = new Map(current);
for (const revokedSessionId of revokedSessionIds) next.delete(revokedSessionId);
return next;
});
yield* Effect.forEach(revokedSessionIds, emitRemoved, {
concurrency: "unbounded",
discard: true,
});
return revokedSessionIds.length;
});

const awaitInvalidation = Effect.fn(function* (sessionId: AuthSessionId) {
return yield* Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -1094,6 +1132,7 @@ const make = Effect.gen(function* () {
},
revoke,
revokeAllExcept,
revokeByPeerEnvironment,
markConnected,
markDisconnected,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as PeerForwarding from "../peer/PeerForwarding.ts";
import * as PeerLinkRequests from "../peer/PeerLinkRequests.ts";
import * as PeerLinks from "../peer/PeerLinks.ts";
import * as TailnetDevices from "../peer/TailnetDevices.ts";

const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Expand Down Expand Up @@ -243,6 +244,7 @@ describe("orchestrator MCP toolkit", () => {
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provide(Layer.mock(TailnetDevices.TailnetDevices)({})),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provideMerge(
SecretRequests.layer.pipe(
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/agents/remoteThreads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import * as DocumentText from "../../provider/DocumentText.ts";
import * as PeerForwarding from "../../peer/PeerForwarding.ts";
import * as PeerLinkRequests from "../../peer/PeerLinkRequests.ts";
import * as PeerLinks from "../../peer/PeerLinks.ts";
import * as TailnetDevices from "../../peer/TailnetDevices.ts";
import * as ThreadLaunch from "../../orchestration-v2/ThreadLaunchService.ts";
import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts";
import * as ManagedProjectFolders from "../../project/ManagedProjectFolders.ts";
Expand Down Expand Up @@ -263,6 +264,7 @@ const makeLayer = (macbook: ReturnType<typeof makeMacBook>, record: TestRecord)
Layer.mock(PeerForwarding.PeerForwarding)({}),
Layer.mock(PeerLinkRequests.PeerLinkRequests)({}),
Layer.mock(PeerLinks.PeerLinks)({ get: () => Effect.succeedNone }),
Layer.mock(TailnetDevices.TailnetDevices)({}),
Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}),
NodeCrypto.layer,
NodeServices.layer,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/linkOrigin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import * as PeerForwarding from "../peer/PeerForwarding.ts";
import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts";
import * as PeerLinkRequests from "../peer/PeerLinkRequests.ts";
import * as PeerLinks from "../peer/PeerLinks.ts";
import * as TailnetDevices from "../peer/TailnetDevices.ts";

// A linked environment's session drives this one's real orchestrator through
// its real T3 tools. What the link starts carries its origin, and the link
Expand Down Expand Up @@ -161,6 +162,7 @@ const layerTools = Layer.mergeAll(
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provide(Layer.mock(TailnetDevices.TailnetDevices)({})),
Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})),
Layer.provide(
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import * as AttachmentHandlers from "./attachment/handlers.ts";
import * as PeerForwarding from "../../peer/PeerForwarding.ts";
import * as PeerLinkRequests from "../../peer/PeerLinkRequests.ts";
import * as PeerLinks from "../../peer/PeerLinks.ts";
import * as TailnetDevices from "../../peer/TailnetDevices.ts";

const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Expand All @@ -79,6 +80,7 @@ const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe(
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provide(Layer.mock(TailnetDevices.TailnetDevices)({})),
);

const threadId = ThreadId.make("mcp-core-thread");
Expand Down Expand Up @@ -608,6 +610,7 @@ it.effect("refuses act-as-caller tools to a client caller", () =>
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provide(Layer.mock(TailnetDevices.TailnetDevices)({})),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})),
Expand Down Expand Up @@ -669,6 +672,7 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provide(Layer.mock(TailnetDevices.TailnetDevices)({})),
Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})),
),
),
Expand Down Expand Up @@ -758,6 +762,7 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provide(Layer.mock(TailnetDevices.TailnetDevices)({})),
),
),
),
Expand Down Expand Up @@ -851,6 +856,7 @@ it.effect("discovers workflow stop targets and requires orchestration capability
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
Layer.provide(Layer.mock(TailnetDevices.TailnetDevices)({})),
Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})),
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/mcp/toolkits/orchestrator/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect";
import * as PeerForwarding from "../../../peer/PeerForwarding.ts";
import * as PeerLinkRequests from "../../../peer/PeerLinkRequests.ts";
import * as PeerLinks from "../../../peer/PeerLinks.ts";
import * as TailnetDevices from "../../../peer/TailnetDevices.ts";
import { readCaller } from "../../threadAccess.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
Expand Down Expand Up @@ -236,6 +237,26 @@ export const layer = McpToolAccess.toLayer(
return { environmentId, removed };
}),
),
// A device's access holds on every one of the user's machines, so it is
// an environment setting too.
t3_environment_device_access: McpToolAccess.writesEnvironment(({ environmentId, access }) =>
Effect.gen(function* () {
const devices = yield* TailnetDevices.TailnetDevices;
const entry = yield* devices.setAccess(environmentId, access).pipe(
Effect.mapError(
(error) =>
new OrchestratorMcpFailure({
code:
error.reason === "unknown_device" || error.reason === "self"
? "invalid_request"
: "orchestration_error",
message: error.message,
}),
),
);
return { environmentId: entry.environmentId, access: entry.access };
}),
),
t3_thread_interrupt: McpToolAccess.writesThreads(
(input) => [input.threadId],
(input) =>
Expand Down
22 changes: 21 additions & 1 deletion apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
OrchestratorMcpCapabilitiesInput,
OrchestratorMcpCapabilitiesResult,
OrchestrationV2StopWorkflowInput,
OrchestratorMcpEnvironmentDeviceAccessInput,
OrchestratorMcpEnvironmentDeviceAccessResult,
OrchestratorMcpEnvironmentLinkInput,
OrchestratorMcpEnvironmentLinkResult,
OrchestratorMcpEnvironmentLinksResult,
Expand Down Expand Up @@ -47,6 +49,7 @@ import * as ThreadManagementService from "../../../orchestration-v2/ThreadManage
import * as PeerForwarding from "../../../peer/PeerForwarding.ts";
import * as PeerLinkRequests from "../../../peer/PeerLinkRequests.ts";
import * as PeerLinks from "../../../peer/PeerLinks.ts";
import * as TailnetDevices from "../../../peer/TailnetDevices.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
Expand All @@ -66,6 +69,8 @@ const linkingDependencies = [
PeerLinkRequests.PeerLinkRequests,
PeerLinks.PeerLinks,
];
/** Setting what one of the user's tailnet devices may do. */
const deviceDependencies = [...dependencies, TailnetDevices.TailnetDevices];
const threadMetadataDependencies = [
McpInvocationContext.McpInvocationContext,
ThreadManagementService.ThreadManagementService,
Expand Down Expand Up @@ -316,7 +321,7 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", {

const EnvironmentLinksTool = Tool.make("t3_environment_links", {
description:
"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`.",
"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`. `devices` lists the user's own machines on their tailnet, linked or not, with what each may do (`revoked`: cut off everywhere).",
success: OrchestratorMcpEnvironmentLinksResult,
failure: OrchestratorMcpFailure,
failureMode: "return",
Expand Down Expand Up @@ -356,6 +361,20 @@ const EnvironmentUnlinkTool = Tool.make("t3_environment_unlink", {
.annotate(Tool.Idempotent, true)
.annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff);

const EnvironmentDeviceAccessTool = Tool.make("t3_environment_device_access", {
description:
"Set what one of the user's own machines may do on all of their machines. `revoked` cuts it off everywhere: ends its sessions and refuses its handshakes. Any other level restores it.",
parameters: OrchestratorMcpEnvironmentDeviceAccessInput,
success: OrchestratorMcpEnvironmentDeviceAccessResult,
failure: OrchestratorMcpFailure,
failureMode: "return",
dependencies: deviceDependencies,
})
.annotate(Tool.Title, "Set a device's access")
.annotate(Tool.Destructive, true)
.annotate(Tool.Idempotent, true)
.annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff);

export const OrchestratorToolkit = Toolkit.make(
OrchestratorCapabilitiesTool,
DelegateTaskTool,
Expand All @@ -377,4 +396,5 @@ export const OrchestratorToolkit = Toolkit.make(
EnvironmentLinksTool,
EnvironmentLinkTool,
EnvironmentUnlinkTool,
EnvironmentDeviceAccessTool,
);
Loading
Loading