Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
11 changes: 11 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ 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.pluginsUpdate]: "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