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
42 changes: 42 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
import * as Queue from "effect/Queue";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SqlClient from "effect/sql/SqlClient";

import * as ServerConfig from "../config.ts";
Expand Down Expand Up @@ -82,6 +84,46 @@ const requestMetadata = {
};

it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
// Fork: device people (toolboxmd/chromeria#170).
it.effect(
"persists device person, exposes it to non-admin sessions and publishes live changes",
() =>
Effect.gen(function* () {
const auth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const issued = yield* sessions.issue({ scopes: ["orchestration:read"] });
const request = makeCookieRequest(sessions.cookieName, issued.token);
expect((yield* auth.getSessionState(request)).person).toBeUndefined();
const changes = yield* Queue.unbounded<SessionStore.SessionCredentialChange>();
yield* sessions.streamChanges.pipe(
Stream.runForEach((change) => Queue.offer(changes, change)),
Effect.forkScoped({ startImmediately: true }),
);
expect(yield* auth.setClientSessionPerson(issued.sessionId, "Pauli")).toBe(true);
expect(yield* Queue.take(changes)).toMatchObject({
type: "clientUpserted",
clientSession: { person: "Pauli" },
});
const sql = yield* SqlClient.SqlClient;
expect(
yield* sql`SELECT person FROM auth_sessions WHERE session_id = ${issued.sessionId}`,
).toEqual([{ person: "Pauli" }]);
expect((yield* auth.getSessionState(request)).person).toBe("Pauli");
expect(
(yield* auth.listClientSessions(issued.sessionId)).find(
(session) => session.sessionId === issued.sessionId,
)?.person,
).toBe("Pauli");
expect(
(yield* auth.setClientSessionPerson(issued.sessionId, "Unknown").pipe(Effect.flip))._tag,
).toBe("ServerAuthForbiddenOperationError");
expect(yield* sessions.getPerson(issued.sessionId)).toBe("Pauli");
expect(yield* auth.setClientSessionPerson(issued.sessionId, null)).toBe(true);
expect((yield* auth.getSessionState(request)).person).toBeUndefined();
expect(yield* sessions.getPerson(issued.sessionId)).toBeNull();
}).pipe(Effect.provide(layerEnvironmentAuth()), Effect.scoped),
);

it.effect("uses the reusable dev cookie without overriding a normal scoped cookie", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
Expand Down
44 changes: 35 additions & 9 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type AuthBrowserSessionResult,
type AuthClientMetadata,
type AuthClientSession,
PEOPLE,
type AuthCreatePairingCredentialInput,
type AuthEnvironmentScope,
type AuthMcpClientAccess,
Expand Down Expand Up @@ -526,6 +527,10 @@ export class EnvironmentAuth extends Context.Service<
readonly listClientSessions: (
currentSessionId: AuthSessionId,
) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>;
readonly setClientSessionPerson: (
sessionId: AuthSessionId,
person: string | null,
) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>;
readonly revokeClientSession: (
currentSessionId: AuthSessionId,
targetSessionId: AuthSessionId,
Expand Down Expand Up @@ -585,6 +590,8 @@ const AUTHORIZATION_PREFIX = "Bearer ";
const DPOP_AUTHORIZATION_PREFIX = "DPoP ";
const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";

const isPerson = Schema.is(Schema.Literals(PEOPLE));

const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
const leftCanManage = left.scopes.includes(AuthAccessWriteScope);
const rightCanManage = right.scopes.includes(AuthAccessWriteScope);
Expand Down Expand Up @@ -769,15 +776,21 @@ export const make = Effect.gen(function* () {

const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) =>
authenticateRequest(request).pipe(
Effect.map(
(session) =>
({
authenticated: true,
auth: descriptor,
...authScopeResponse(session.scopes),
sessionMethod: session.method,
...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
}) satisfies AuthSessionState,
Effect.flatMap((session) =>
sessions.getPerson(session.sessionId).pipe(
Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })),
Effect.map(
(person) =>
({
...(person === null ? {} : { person }),
authenticated: true,
auth: descriptor,
...authScopeResponse(session.scopes),
sessionMethod: session.method,
...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
}) satisfies AuthSessionState,
),
),
),
Effect.catchIf(isServerAuthCredentialError, () =>
Effect.succeed({
Expand Down Expand Up @@ -1124,6 +1137,18 @@ export const make = Effect.gen(function* () {
Effect.withSpan("EnvironmentAuth.listClientSessions"),
);

const setClientSessionPerson: EnvironmentAuth["Service"]["setClientSessionPerson"] = (
sessionId,
person,
) => {
if (person !== null && !isPerson(person)) {
return Effect.fail(new ServerAuthForbiddenOperationError({}));
}
return sessions
.setPerson(sessionId, person)
.pipe(Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })));
};

const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn(
"EnvironmentAuth.revokeClientSession",
)(function* (currentSessionId, targetSessionId) {
Expand Down Expand Up @@ -1289,6 +1314,7 @@ export const make = Effect.gen(function* () {
revokeOtherSessionsExcept,
listClientSessions,
revokeClientSession,
setClientSessionPerson,
revokeOtherClientSessions,
authenticateHttpRequest,
authenticateWebSocketUpgrade,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/SessionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const repositoryFailure = new PersistenceSqlError({
});

const layerFailingSessionLookupRepository = Layer.succeed(AuthSessions.AuthSessionRepository, {
setPerson: () => Effect.void,
create: () => Effect.void,
createReplacingActive: () => Effect.succeed([]),
createIfAbsent: () => Effect.void,
Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,13 @@ export class SessionStore extends Context.Service<
readonly verifyWebSocketToken: (
token: string,
) => Effect.Effect<VerifiedSession, SessionCredentialError>;
readonly getPerson: (
sessionId: AuthSessionId,
) => Effect.Effect<string | null, SessionCredentialInternalError>;
readonly setPerson: (
sessionId: AuthSessionId,
person: string | null,
) => Effect.Effect<boolean, SessionCredentialInternalError>;
readonly listActive: () => Effect.Effect<
ReadonlyArray<AuthClientSession>,
SessionCredentialInternalError
Expand Down Expand Up @@ -560,6 +567,7 @@ export const make = Effect.gen(function* () {
toAuthClientSession({
sessionId: row.value.sessionId,
subject: row.value.subject,
...(row.value.person === null ? {} : { person: row.value.person }),
scopes: row.value.scopes,
method: row.value.method,
client: toClientMetadata(row.value.client),
Expand Down Expand Up @@ -979,6 +987,7 @@ export const make = Effect.gen(function* () {
toAuthClientSession({
sessionId: row.sessionId,
subject: row.subject,
...(row.person === null ? {} : { person: row.person }),
scopes: row.scopes,
method: row.method,
client: toClientMetadata(row.client),
Expand Down Expand Up @@ -1047,7 +1056,26 @@ export const make = Effect.gen(function* () {
return revokedSessionIds.length;
});

const getPerson: SessionStore["Service"]["getPerson"] = (sessionId) =>
authSessions.getById({ sessionId }).pipe(
Effect.map((row) => (Option.isSome(row) ? row.value.person : null)),
Effect.mapError((cause) => new ActiveSessionsListError({ cause })),
);
const setPerson: SessionStore["Service"]["setPerson"] = Effect.fn("SessionStore.setPerson")(
function* (sessionId, person) {
const current = yield* loadActiveSession(sessionId);
if (Option.isNone(current)) return false;
yield* authSessions.setPerson({ sessionId, person });
const updated = yield* loadActiveSession(sessionId);
if (Option.isSome(updated)) yield* emitUpsert(updated.value);
return true;
},
Effect.mapError((cause) => new ActiveSessionsListError({ cause })),
);

return SessionStore.of({
getPerson,
setPerson,
cookieName,
legacyCookieName,
issue,
Expand Down
26 changes: 25 additions & 1 deletion apps/server/src/auth/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope
);
}

function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") {
function failEnvironmentOperationForbidden(
reason: "current_session_revoke_not_allowed" | "unknown_person",
) {
return currentEnvironmentTraceId.pipe(
Effect.flatMap((traceId) =>
Effect.fail(
Expand Down Expand Up @@ -491,6 +493,28 @@ export const layer = HttpApiBuilder.group(
),
),
)
.handle(
"setClientPerson",
Effect.fn("environment.auth.setClientPerson")(
function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
yield* requireEnvironmentScope(AuthAccessWriteScope);
return {
updated: yield* serverAuth.setClientSessionPerson(
args.payload.sessionId,
args.payload.person,
),
};
},
Effect.catchTags({
ServerAuthForbiddenOperationError: () =>
failEnvironmentOperationForbidden("unknown_person"),
}),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("client_session_person_update_failed", error),
),
),
)
.handle(
"revokeClient",
Effect.fn("environment.auth.revokeClient")(
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
type ScheduledTaskUpsertInput,
type ServerProvider,
ThreadId,
threadOwner,
} from "@t3tools/contracts";
import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
import * as Clock from "effect/Clock";
Expand Down Expand Up @@ -2140,6 +2141,8 @@ const make = Effect.gen(function* () {
type: "thread.create",
createdBy: "agent",
creationSource: "mcp",
// Fork: an agent's thread belongs to its caller's owner (toolboxmd/chromeria#170).
owner: threadOwner(parent.thread),
commandId: stableCommandId({
scope,
requestKey: key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,8 @@ describe("orchestrator MCP toolkit", () => {
commandId: CommandId.make("command:mcp-parent:create"),
threadId: parentThreadId,
projectId,
// Fork: threads an agent creates belong to its owner (toolboxmd/chromeria#170).
owner: "Pauli",
title: "MCP parent",
modelSelection: codexSelection,
runtimeMode: "full-access",
Expand Down Expand Up @@ -2105,6 +2107,8 @@ describe("orchestrator MCP toolkit", () => {
senderThreadId: parentThreadId,
});
const emptyProjection = yield* orchestrator.getThreadProjection(emptyThread.threadId);
expect(emptyProjection.thread.owner).toBe("Pauli");
expect(createdSource.thread.owner).toBe("Pauli");
expect(emptyProjection.thread.lineage).toEqual({
parentThreadId: null,
relationshipToParent: null,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ it.effect("attributes a launched thread's first message to the calling thread",
activeRunId: "active-run",
archivedAt: null,
deletedAt: null,
owner: "Pauli",
} as OrchestrationV2ThreadShell;
let launchedSender: ThreadId | undefined;
let launchedOwner: string | undefined;
const layerDependencies = Layer.mergeAll(
NodeCrypto.layer,
Layer.succeed(McpInvocationContext.McpInvocationContext, {
Expand All @@ -66,6 +68,7 @@ it.effect("attributes a launched thread's first message to the calling thread",
Layer.mock(ThreadLaunch.ThreadLaunchService)({
launch: (input) => {
launchedSender = input.initialMessage?.senderThreadId;
launchedOwner = input.owner;
return Effect.succeed({
threadId: input.threadId,
projection: {
Expand Down Expand Up @@ -96,6 +99,8 @@ it.effect("attributes a launched thread's first message to the calling thread",
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));
expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection });
expect(launchedSender).toBe(sourceThreadId);
// Fork: a launched thread belongs to the caller's owner (toolboxmd/chromeria#170).
expect(launchedOwner).toBe("Pauli");
}),
);

Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
import {
MessageId,
ThreadId,
OrchestratorMcpFailure,
ProjectId,
threadOwner,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -135,6 +141,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
}),
createdBy: "agent",
creationSource: "mcp",
// Fork: an agent's thread belongs to its caller's owner (toolboxmd/chromeria#170).
...(caller === undefined ? {} : { owner: threadOwner(caller) }),
}).pipe(
Effect.mapError((error) =>
error._tag === "AttachmentClaimError"
Expand Down
Loading
Loading