Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe("ElectronProtocol", () => {
assert.equal(yield* Effect.promise(() => response.text()), "ok");
assert.include(
response.headers.get("content-security-policy") ?? "",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://clerk.t3.codes https://challenges.cloudflare.com",
);
assert.include(
response.headers.get("content-security-policy") ?? "",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu
"'self'",
"'unsafe-inline'",
"'wasm-unsafe-eval'",
// Web plugins run from object URLs: their bundles are fetched with the session's credentials.
"blob:",
...(clerkOrigin ? [clerkOrigin] : []),
"https://challenges.cloudflare.com",
];
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@effect/vitest": "catalog:",
"@opencode-ai/sdk": "^1.3.15",
"@t3tools/contracts": "workspace:*",
"@t3tools/plugin-sdk": "workspace:*",
"@t3tools/provider-testing": "workspace:*",
"@t3tools/secret-provider-core": "workspace:*",
"@t3tools/secret-provider-definitions": "workspace:*",
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ describe("RPC authorization scopes", () => {
}
});

it("keeps enabling, reloading and configuring plugins, which runs their code, behind settings write", () => {
for (const method of [
WS_METHODS.pluginsSetEnabled,
WS_METHODS.pluginsReload,
WS_METHODS.pluginsSettingsUpdate,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthSettingsWriteScope);
}
});

it("rejects unknown RPC method names", () => {
for (const method of ["server.notRegistered", "toString", "constructor"]) {
expect(() => requiredScopeForRpcMethod(method)).toThrow(
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.deviceDetail]: AuthOrchestrationReadScope,
[WS_METHODS.deviceAction]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
// `plugins.invoke` also needs the operate scope for methods a plugin registered as writes;
// PluginHost checks that against the plugin's own declaration. Channels are reads.
[WS_METHODS.pluginsList]: AuthOrchestrationReadScope,
[WS_METHODS.pluginsSubscribeStates]: AuthOrchestrationReadScope,
[WS_METHODS.pluginsSettingsGet]: AuthOrchestrationReadScope,
[WS_METHODS.pluginsInvoke]: AuthOrchestrationReadScope,
[WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export const make = Effect.gen(function* () {
threadSnooze: true,
threadForceStop: true,
environmentThemes: true,
plugins: true,
usageLimitSources: true,
providerWorkspaceSnapshotsOnDemand: true,
usagePriceOverrides: true,
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/environmentTheme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ it.layer(NodeServices.layer)("environment theme", (it) => {
),
);

it.effect("adds plugin themes after the environment's own, which win on the same id", () =>
withEnvironmentThemes(
{ "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
Effect.gen(function* () {
const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const pluginThemes = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" });
yield* fs.writeFileString(
path.join(pluginThemes, "nightfall.json"),
encodeThemeFile({ ...NIGHTFALL_THEME, name: "Plugin Nightfall" }),
);
yield* fs.writeFileString(
path.join(pluginThemes, "shared-light.json"),
encodeThemeFile(SHARED_THEME),
);

yield* environmentTheme.setPluginThemeDirectories([pluginThemes]);
assert.deepEqual(
(yield* currentThemes).map((theme) => [theme.id, theme.name]),
[
["nightfall", "Nightfall"],
["shared-light", "Shared Light"],
],
);

yield* environmentTheme.setPluginThemeDirectories([]);
assert.deepEqual(
(yield* currentThemes).map((theme) => theme.id),
["nightfall"],
);
}),
),
);

it.effect("never replays a set older than the snapshot it started from", () =>
withEnvironmentThemes(
{ "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
Expand Down
29 changes: 26 additions & 3 deletions apps/server/src/environmentTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export class EnvironmentThemeService extends Context.Service<

/** Subscribes before reading to retain changes during connection. */
readonly streamChanges: Stream.Stream<ReadonlyArray<EnvironmentTheme>>;

/**
* Theme folders of active plugins, read after the environment's own folder. The first theme
* with an id wins, so a plugin cannot replace a theme the user published.
*/
readonly setPluginThemeDirectories: (directories: ReadonlyArray<string>) => Effect.Effect<void>;
}
>()("t3/environmentTheme/EnvironmentThemeService") {}

Expand Down Expand Up @@ -158,16 +164,26 @@ const make = Effect.gen(function* () {
// Sequence numbers let subscribers drop queued events older than their initial snapshot.
const changes = yield* PubSub.sliding<PublishedThemes>(1);
const published = yield* Ref.make<PublishedThemes>({ seq: 0, themes: [] });
const pluginDirectories = yield* Ref.make<ReadonlyArray<string>>([]);
// Serialize read/compare/publish so slow older reads cannot publish with newer sequence numbers.
const refreshSemaphore = yield* Semaphore.make(1);
const watcherScope = yield* Scope.make("sequential");
yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));

const refresh = refreshSemaphore.withPermits(1)(
Effect.gen(function* () {
const themes = yield* readPublishedThemes(environmentThemesDir).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
);
const themes: Array<EnvironmentTheme> = [];
const ids = new Set<string>();
for (const directory of [environmentThemesDir, ...(yield* Ref.get(pluginDirectories))]) {
const found = yield* readPublishedThemes(directory).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
);
for (const theme of found) {
if (ids.has(theme.id)) continue;
ids.add(theme.id);
themes.push(theme);
}
}
// Compare the whole value so new fields cannot silently miss republishes.
const [changed, next] = yield* Ref.modify(
published,
Expand Down Expand Up @@ -198,6 +214,13 @@ const make = Effect.gen(function* () {

return {
current: Effect.map(refresh, (state) => state.themes),
setPluginThemeDirectories: Effect.fn("EnvironmentThemeService.setPluginThemeDirectories")(
function* (directories) {
const previous = yield* Ref.getAndSet(pluginDirectories, directories);
if (Equal.equals(previous, directories)) return;
yield* refresh;
},
),
get streamChanges() {
return Stream.unwrap(
Effect.gen(function* () {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
return redirectUrl.toString();
}

const authenticateRawRouteWithScope =
export const authenticateRawRouteWithScope =
/* Request instrumentation already owns the span; keep this helper in that context. */
Effect.fnUntraced(function* (
scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
Expand Down
54 changes: 52 additions & 2 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as HtmlRender from "../htmlRender/HtmlRender.ts";
import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as PluginMcpTools from "../plugins/PluginMcpTools.ts";
import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
import {
type AgentThread,
Expand Down Expand Up @@ -1140,6 +1141,53 @@ export const layerDeviceToolkit = Layer.mergeAll(
layerDeviceScreenshotRegistration,
);

const isJsonObject = Schema.is(Schema.JsonObject);

const pluginToolResult = (output: Schema.Json) =>
new McpSchema.CallToolResult({
isError: false,
...(isJsonObject(output) ? { structuredContent: output } : {}),
content: [{ type: "text", text: JSON.stringify(output) }],
});

const pluginToolFailure = (error: { readonly message: string }) =>
new McpSchema.CallToolResult({ isError: true, content: [{ type: "text", text: error.message }] });

/**
* Plugins add tools while the server runs, so they register here directly instead of through
* a toolkit. Kept out of `layerTools`: with no plugin installed there are none to measure.
*/
export const layerPluginTools = Layer.effectDiscard(
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const plugins = yield* PluginMcpTools.PluginMcpTools;
const threads = yield* ThreadManagementService.ThreadManagementService;
yield* plugins.attach((entry) => {
const call = McpToolAccess.dynamic(entry.access, entry.call);
return server.addTool({
tool: new McpSchema.Tool({
name: entry.name,
description: entry.description,
inputSchema: entry.inputSchema,
annotations: { readOnlyHint: entry.access === "read" },
}),
annotations: Context.empty(),
handle: (payload) =>
Effect.withFiber((fiber) =>
call(isJsonObject(payload) ? payload : {}).pipe(
Effect.provideService(
McpInvocationContext.McpInvocationContext,
Context.getUnsafe(fiber.context, McpInvocationContext.McpInvocationContext),
),
Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
Effect.match({ onFailure: pluginToolFailure, onSuccess: pluginToolResult }),
),
),
});
});
}),
).pipe(Layer.provide(McpServer.McpServer.layer));

export const layerMcpTransport = McpServer.layerHttp({
name: "T3 Code",
version: packageJson.version,
Expand All @@ -1148,7 +1196,7 @@ export const layerMcpTransport = McpServer.layerHttp({
allowSessionTermination: true,
}).pipe(Layer.provide(layerMcpAuthMiddleware));

/** Every tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */
/** Every built-in tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */
export const layerTools = Layer.mergeAll(
layerPreviewToolkit,
layerOrchestratorToolkit,
Expand All @@ -1163,4 +1211,6 @@ export const layerTools = Layer.mergeAll(
layerHtmlToolkit,
);

export const layer = layerTools.pipe(Layer.provideMerge(layerMcpTransport));
export const layer = Layer.merge(layerTools, layerPluginTools).pipe(
Layer.provideMerge(layerMcpTransport),
);
17 changes: 17 additions & 0 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,23 @@ export const writesWithCapability = <P, A, E, R>(handle: (params: P) => Effect.E
() => writingCaller,
);

/**
* For tools whose access is data, registered while the server runs (plugin tools): `read`
* checks like `reads`; `write` like `writesEnvironment`, because a plugin tool can do anything
* the plugin can, and its handler never sees the caller's limits. Returns the checked handler.
*/
export const dynamic = <P, A, E, R>(
access: "read" | "write",
handle: (params: P) => Effect.Effect<A, E, R>,
) => {
const declaration: Declaration<
(params: P) => Effect.Effect<A, E | OrchestratorMcpFailure, R | WritingServices>
> = access === "read" ? reads(handle) : writesEnvironment((params: P) => handle(params));
return checkedHandler(declaration);
};

type WritingServices = Effect.Services<typeof writingCaller>;

export const writesThreads = <P, A, E, R>(
threads: (params: P) => ReadonlyArray<ThreadId | undefined>,
handle: (params: P) => Effect.Effect<A, E, R>,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverRefreshUsageRates]: "server",
[WS_METHODS.serverSignalProcess]: "server",
[WS_METHODS.serverReportClientActivity]: "server",
[WS_METHODS.pluginsList]: "plugins",
[WS_METHODS.pluginsSubscribeStates]: "plugins",
[WS_METHODS.pluginsSetEnabled]: "plugins",
[WS_METHODS.pluginsReload]: "plugins",
[WS_METHODS.pluginsInstall]: "plugins",
[WS_METHODS.pluginsUninstall]: "plugins",
[WS_METHODS.pluginsSettingsGet]: "plugins",
[WS_METHODS.pluginsSettingsUpdate]: "plugins",
[WS_METHODS.pluginsInvoke]: "plugins",
[WS_METHODS.pluginsSubscribe]: "plugins",
[WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks",
[WS_METHODS.scheduledTasksUpsert]: "scheduledTasks",
[WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks",
Expand Down
57 changes: 56 additions & 1 deletion apps/server/src/orchestration-v2/ThreadManagementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
EnvironmentId,
MessageId,
type OrchestrationV2Command,
type OrchestrationV2Notification,
type OrchestrationV2Run,
type OrchestrationV2ServerCommand,
type OrchestrationV2ProviderThread,
ProviderThreadId,
ProviderDriverKind,
Expand All @@ -29,6 +31,7 @@ import * as TestClock from "effect/testing/TestClock";

import { AgentScope } from "@t3tools/shared/AgentScope";

import * as PluginHooks from "../plugins/PluginHooks.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as CommandReceiptStore from "./CommandReceiptStore.ts";
Expand Down Expand Up @@ -81,7 +84,8 @@ const layerReceiptsTest = CommandReceiptStore.layer.pipe(
);

const layerDispatchChokepointTest =
(settingsLayer: ReturnType<typeof ServerSettings.layerTest>) => (dispatched: Array<string>) =>
(settingsLayer: ReturnType<typeof ServerSettings.layerTest>) =>
(dispatched: Array<string>, texts: Array<string> = []) =>
ThreadManagementService.layer.pipe(
Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
Layer.provide(
Expand All @@ -101,6 +105,7 @@ const layerDispatchChokepointTest =
dispatch: (command) =>
Effect.sync(() => {
dispatched.push(command.type);
if (command.type === "message.dispatch") texts.push(command.text);
return { sequence: 1, storedEvents: [] } as never;
}),
}),
Expand Down Expand Up @@ -151,6 +156,56 @@ it.effect(
},
);

it.effect("plugin hooks append to user messages but not to notifications or slash commands", () => {
const threadId = ThreadId.make("thread:thread-management:chokepoint");
const dispatched: Array<string> = [];
const texts: Array<string> = [];
const instanceId = ProviderInstanceId.make("codex_work");
const layerTest = layerDispatchChokepointTest(
ServerSettings.layerTest({
providerInstances: { [instanceId]: { driver: "codex", enabled: true } },
}),
)(dispatched, texts).pipe(Layer.provideMerge(PluginHooks.layer));
const message = (
id: string,
notification?: OrchestrationV2Notification,
text = "Fix the build.",
): OrchestrationV2ServerCommand => ({
type: "message.dispatch",
commandId: CommandId.make(`command:thread-management:hooks-${id}`),
threadId,
messageId: MessageId.make(`message:thread-management:hooks-${id}`),
text,
attachments: [],
dispatchMode: { type: "queue_after_active" },
createdBy: "user",
creationSource: "web",
...(notification === undefined ? {} : { notification }),
});

return Effect.gen(function* () {
const hooks = yield* PluginHooks.PluginHooks;
yield* hooks.addBeforeDispatch(() => Effect.succeed("Project uses pnpm.")).pipe(Effect.asVoid);
const threads = yield* ThreadManagementService.ThreadManagementService;
yield* threads.dispatch(message("user"));
yield* threads.dispatch(
message("notification", {
source: { kind: "system" },
outcome: "updated",
summary: "Restarted",
}),
);

yield* threads.dispatch(message("command", undefined, "/goal clear"));

expect(texts).toEqual([
"Fix the build.\n\nProject uses pnpm.",
"Fix the build.",
"/goal clear",
]);
}).pipe(Effect.provide(layerTest));
});

it.effect(
"a replay of an already-accepted command id dispatches despite the instance now being disabled",
() => {
Expand Down
Loading