From 9e41774ec8e6d8d070f27b02ba9491b096bc0547 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 03:07:15 -0400 Subject: [PATCH 01/75] Add thread ownership to projection threads Threads carry an owner ("user" by default, or "plugin:") so future runtime plugins can create system threads that stay out of user-facing thread lists and relay publishes while remaining fetchable by id. - Migration 033: projection_threads.owner TEXT NOT NULL DEFAULT 'user' - ThreadCreatedPayload/OrchestrationThread gain optional owner (decoding default "user"; wire-backward-compatible) - Decider/projector/projection pipeline propagate owner - ProjectionSnapshotQuery user-facing list/aggregate/count queries filter owner = 'user'; id-keyed lookups intentionally unfiltered - AgentAwarenessRelay skips publishing non-user threads - Equivalence + exclusion tests across migration, projector, snapshot query, and relay paths Implemented by GPT-5.5 via codex exec (assembly-line slice 1). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionPipeline.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 231 ++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 46 +++- .../Services/ProjectionSnapshotQuery.ts | 8 + .../decider.projectScripts.test.ts | 52 ++++ apps/server/src/orchestration/decider.ts | 1 + .../src/orchestration/projector.test.ts | 215 ++++++++++------ apps/server/src/orchestration/projector.ts | 1 + .../Layers/ProjectionRepositories.test.ts | 1 + .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/033_ThreadOwner.test.ts | 78 ++++++ .../persistence/Migrations/033_ThreadOwner.ts | 11 + .../persistence/Services/ProjectionThreads.ts | 2 + .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/relay/AgentAwarenessRelay.test.ts | 121 +++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 8 + apps/server/src/server.test.ts | 1 + apps/server/src/serverRuntimeStartup.test.ts | 4 + .../src/state/threadReducer.test.ts | 3 + .../client-runtime/src/state/threadReducer.ts | 1 + packages/contracts/src/orchestration.test.ts | 79 ++++++ packages/contracts/src/orchestration.ts | 17 ++ 26 files changed, 819 insertions(+), 77 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/033_ThreadOwner.test.ts create mode 100644 apps/server/src/persistence/Migrations/033_ThreadOwner.ts diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index c1dbc833718..6da80a6e77d 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -88,6 +88,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.sync(() => { getThreadCheckpointContextCalls += 1; @@ -195,6 +196,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), @@ -277,6 +279,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), @@ -344,6 +347,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), @@ -396,6 +400,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b2ef0fed0f9..f073fb48d47 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -196,6 +196,7 @@ describe("OrchestrationEngine", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..e7c933ea314 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -598,6 +598,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + owner: event.payload.owner ?? "user", modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9a136b06872..dc76435d35b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -285,6 +285,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", + owner: "user", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -696,6 +697,236 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect( + "excludes plugin-owned threads from user-facing lists and startup selection while keeping by-id lookups", + () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-owner-filter', + 'Owner Filter', + '/tmp/owner-filter', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-07T00:00:00.000Z', + '2026-04-07T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + owner, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-plugin-active', + 'project-owner-filter', + 'Plugin Active', + 'plugin:test', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + '/tmp/plugin-worktree', + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:02.000Z', + '2026-04-07T00:00:03.000Z', + NULL, + NULL + ), + ( + 'thread-user-active', + 'project-owner-filter', + 'User Active', + 'user', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:04.000Z', + '2026-04-07T00:00:05.000Z', + NULL, + NULL + ), + ( + 'thread-plugin-archived', + 'project-owner-filter', + 'Plugin Archived', + 'plugin:test', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:06.000Z', + '2026-04-07T00:00:07.000Z', + '2026-04-07T00:00:08.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + 'thread-plugin-active', + 'turn-plugin-1', + NULL, + NULL, + NULL, + NULL, + 'completed', + '2026-04-07T00:00:09.000Z', + '2026-04-07T00:00:09.000Z', + '2026-04-07T00:00:09.000Z', + 1, + 'checkpoint-plugin-1', + 'ready', + '[]' + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 6, '2026-04-07T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 6, '2026-04-07T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 6, '2026-04-07T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 6, '2026-04-07T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 6, '2026-04-07T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 6, '2026-04-07T00:00:10.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 6, '2026-04-07T00:00:10.000Z') + `; + + const counts = yield* snapshotQuery.getCounts(); + assert.deepEqual(counts, { + projectCount: 1, + threadCount: 1, + }); + + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + assert.deepEqual( + commandReadModel.threads.map((thread) => thread.id), + [ThreadId.make("thread-user-active")], + ); + + const fullSnapshot = yield* snapshotQuery.getSnapshot(); + assert.deepEqual( + fullSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-user-active")], + ); + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual( + shellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-user-active")], + ); + + const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); + assert.deepEqual( + archivedShellSnapshot.threads.map((thread) => thread.id), + [], + ); + + const firstThreadId = yield* snapshotQuery.getFirstActiveThreadIdByProjectId( + asProjectId("project-owner-filter"), + ); + assert.equal(firstThreadId._tag, "Some"); + if (firstThreadId._tag === "Some") { + assert.equal(firstThreadId.value, ThreadId.make("thread-user-active")); + } + + const pluginShell = yield* snapshotQuery.getThreadShellById( + ThreadId.make("thread-plugin-active"), + ); + assert.equal(pluginShell._tag, "Some"); + + const pluginDetail = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-plugin-active"), + ); + assert.equal(pluginDetail._tag, "Some"); + if (pluginDetail._tag === "Some") { + assert.equal(pluginDetail.value.owner, "plugin:test"); + } + + const checkpointContext = yield* snapshotQuery.getThreadCheckpointContext( + ThreadId.make("thread-plugin-active"), + ); + assert.equal(checkpointContext._tag, "Some"); + + const fullDiffContext = yield* snapshotQuery.getFullThreadDiffContext( + ThreadId.make("thread-plugin-active"), + 1, + ); + assert.equal(fullDiffContext._tag, "Some"); + }), + ); + it.effect("reads single-thread checkpoint context without hydrating unrelated threads", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e36db35b107..444088ddbd0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,6 +1,7 @@ import { ChatAttachment, CheckpointRef, + DEFAULT_THREAD_OWNER, IsoDateTime, MessageId, NonNegativeInt, @@ -120,6 +121,9 @@ const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, }); +const ProjectionThreadOwnerLookupRowSchema = Schema.Struct({ + owner: ProjectionThread.fields.owner, +}); const ProjectionThreadCheckpointContextThreadRowSchema = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, @@ -324,6 +328,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + owner, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -339,6 +344,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads + WHERE owner = ${DEFAULT_THREAD_OWNER} ORDER BY created_at ASC, thread_id ASC `, }); @@ -352,6 +358,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + owner, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -369,6 +376,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL + AND owner = ${DEFAULT_THREAD_OWNER} ORDER BY project_id ASC, created_at ASC, thread_id ASC `, }); @@ -382,6 +390,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + owner, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -399,6 +408,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NOT NULL + AND owner = ${DEFAULT_THREAD_OWNER} ORDER BY project_id ASC, archived_at DESC, thread_id DESC `, }); @@ -508,6 +518,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + AND threads.owner = ${DEFAULT_THREAD_OWNER} ORDER BY sessions.thread_id ASC `, }); @@ -533,6 +544,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NOT NULL + AND threads.owner = ${DEFAULT_THREAD_OWNER} ORDER BY sessions.thread_id ASC `, }); @@ -577,6 +589,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON turns.thread_id = threads.thread_id AND turns.turn_id = threads.latest_turn_id WHERE threads.latest_turn_id IS NOT NULL + AND threads.owner = ${DEFAULT_THREAD_OWNER} ORDER BY turns.thread_id ASC `, }); @@ -603,6 +616,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL AND threads.latest_turn_id IS NOT NULL + AND threads.owner = ${DEFAULT_THREAD_OWNER} ORDER BY turns.thread_id ASC `, }); @@ -629,6 +643,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE threads.deleted_at IS NULL AND threads.archived_at IS NOT NULL AND threads.latest_turn_id IS NOT NULL + AND threads.owner = ${DEFAULT_THREAD_OWNER} ORDER BY turns.thread_id ASC `, }); @@ -653,7 +668,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT (SELECT COUNT(*) FROM projection_projects) AS "projectCount", - (SELECT COUNT(*) FROM projection_threads) AS "threadCount" + (SELECT COUNT(*) FROM projection_threads WHERE owner = ${DEFAULT_THREAD_OWNER}) AS "threadCount" `, }); @@ -711,11 +726,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE project_id = ${projectId} AND deleted_at IS NULL AND archived_at IS NULL + AND owner = ${DEFAULT_THREAD_OWNER} ORDER BY created_at ASC, thread_id ASC LIMIT 1 `, }); + const getThreadOwnerRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadOwnerLookupRowSchema, + execute: ({ threadId }) => + sql` + SELECT owner + FROM projection_threads + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadCheckpointContextThreadRowSchema, @@ -744,6 +772,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + owner, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -1176,6 +1205,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, + owner: row.owner, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, @@ -1374,6 +1404,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, + owner: row.owner, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, @@ -1767,6 +1798,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.map(Option.map((row) => row.threadId)), ); + const getThreadOwnerById: ProjectionSnapshotQueryShape["getThreadOwnerById"] = (threadId) => + getThreadOwnerRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadOwnerById:query", + "ProjectionSnapshotQuery.getThreadOwnerById:decodeRow", + ), + ), + Effect.map(Option.map((row) => row.owner)), + ); + const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( threadId, ) => @@ -1971,6 +2013,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: threadRow.value.threadId, projectId: threadRow.value.projectId, title: threadRow.value.title, + owner: threadRow.value.owner, modelSelection: threadRow.value.modelSelection, runtimeMode: threadRow.value.runtimeMode, interactionMode: threadRow.value.interactionMode, @@ -2043,6 +2086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getActiveProjectByWorkspaceRoot, getProjectShellById, getFirstActiveThreadIdByProjectId, + getThreadOwnerById, getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 7d85f0240f7..7ff140f00e4 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -16,6 +16,7 @@ import type { OrchestrationThread, OrchestrationThreadShell, ProjectId, + ThreadOwner, ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -128,6 +129,13 @@ export interface ProjectionSnapshotQueryShape { projectId: ProjectId, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read a thread owner by id without applying user-facing visibility filters. + */ + readonly getThreadOwnerById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the checkpoint context needed to resolve a single thread diff. */ diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 64ba159c740..e040a614611 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,58 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("carries thread.create owner into thread.created", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const readModel = yield* projectEvent(createEmptyReadModel(now), { + sequence: 1, + eventId: asEventId("evt-project-create-owner"), + aggregateKind: "project", + aggregateId: asProjectId("project-owner"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-owner"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-owner"), + metadata: {}, + payload: { + projectId: asProjectId("project-owner"), + title: "Project", + workspaceRoot: "/tmp/project-owner", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-owner"), + threadId: ThreadId.make("thread-plugin"), + projectId: asProjectId("project-owner"), + title: "Plugin thread", + owner: "plugin:test", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + } as never, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("thread.created"); + expect((event.payload as { owner?: unknown }).owner).toBe("plugin:test"); + }), + ); + it.effect("emits user message and turn-start-requested events for thread.turn.start", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8..58e77eaec7c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -234,6 +234,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, + owner: command.owner ?? "user", modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..6d5f81cd317 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -6,8 +6,8 @@ import { ThreadId, type OrchestrationEvent, } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { describe, expect, it } from "vite-plus/test"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; @@ -77,6 +77,7 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", + owner: "user", modelSelection: { instanceId: "codex", model: "gpt-5-codex", @@ -99,6 +100,74 @@ describe("orchestration projector", () => { ]); }); + it("projects thread owner and defaults legacy thread.created events to user", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const model = createEmptyReadModel(now); + + const pluginOwned = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-plugin", + occurredAt: now, + commandId: "cmd-thread-create-plugin", + payload: { + threadId: "thread-plugin", + projectId: "project-1", + title: "plugin", + owner: "plugin:test", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + + expect((pluginOwned.threads[0] as { owner?: unknown } | undefined)?.owner).toBe("plugin:test"); + + const legacy = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-legacy", + occurredAt: now, + commandId: "cmd-thread-create-legacy", + payload: { + threadId: "thread-legacy", + projectId: "project-1", + title: "legacy", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + + expect((legacy.threads[0] as { owner?: unknown } | undefined)?.owner).toBe("user"); + }); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); @@ -852,12 +921,12 @@ describe("orchestration projector", () => { ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); }); - it("caps message and checkpoint retention for long-lived threads", async () => { - const createdAt = "2026-03-01T10:00:00.000Z"; - const model = createEmptyReadModel(createdAt); + it.effect("caps message and checkpoint retention for long-lived threads", () => + Effect.gen(function* () { + const createdAt = "2026-03-01T10:00:00.000Z"; + const model = createEmptyReadModel(createdAt); - const afterCreate = await Effect.runPromise( - projectEvent( + const afterCreate = yield* projectEvent( model, makeEvent({ sequence: 1, @@ -881,75 +950,69 @@ describe("orchestration projector", () => { updatedAt: createdAt, }, }), - ), - ); + ); - const messageEvents: ReadonlyArray = Array.from( - { length: 2_100 }, - (_, index) => - makeEvent({ - sequence: index + 2, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-message-${index}`, - payload: { - threadId: "thread-capped", - messageId: `msg-${index}`, - role: "assistant", - text: `message-${index}`, - turnId: `turn-${index}`, - streaming: false, - createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - const afterMessages = await messageEvents.reduce< - Promise> - >( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterCreate), - ); - - const checkpointEvents: ReadonlyArray = Array.from( - { length: 600 }, - (_, index) => - makeEvent({ - sequence: index + 2_102, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-checkpoint-${index}`, - payload: { - threadId: "thread-capped", - turnId: `turn-${index}`, - checkpointTurnCount: index + 1, - checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, - status: "ready", - files: [], - assistantMessageId: `msg-${index}`, - completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - const finalState = await checkpointEvents.reduce< - Promise> - >( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterMessages), - ); - - const thread = finalState.threads[0]; - expect(thread?.messages).toHaveLength(2_000); - expect(thread?.messages[0]?.id).toBe("msg-100"); - expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); - expect(thread?.checkpoints).toHaveLength(500); - expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); - expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); - }); + const messageEvents: ReadonlyArray = Array.from( + { length: 2_100 }, + (_, index) => + makeEvent({ + sequence: index + 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-message-${index}`, + payload: { + threadId: "thread-capped", + messageId: `msg-${index}`, + role: "assistant", + text: `message-${index}`, + turnId: `turn-${index}`, + streaming: false, + createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + let afterMessages = afterCreate; + for (const event of messageEvents) { + afterMessages = yield* projectEvent(afterMessages, event); + } + + const checkpointEvents: ReadonlyArray = Array.from( + { length: 600 }, + (_, index) => + makeEvent({ + sequence: index + 2_102, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-checkpoint-${index}`, + payload: { + threadId: "thread-capped", + turnId: `turn-${index}`, + checkpointTurnCount: index + 1, + checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, + status: "ready", + files: [], + assistantMessageId: `msg-${index}`, + completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + let finalState = afterMessages; + for (const event of checkpointEvents) { + finalState = yield* projectEvent(finalState, event); + } + + const thread = finalState.threads[0]; + expect(thread?.messages).toHaveLength(2_000); + expect(thread?.messages[0]?.id).toBe("msg-100"); + expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); + expect(thread?.checkpoints).toHaveLength(500); + expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); + expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); + }), + ); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..6cb6528d642 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -277,6 +277,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, + owner: payload.owner ?? "user", modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..e5f4f23c83a 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -79,6 +79,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { threadId: ThreadId.make("thread-null-options"), projectId: ProjectId.make("project-null-options"), title: "Null options thread", + owner: "user", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-4-6", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..24018552a7c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -34,6 +34,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, + owner, model_selection_json, runtime_mode, interaction_mode, @@ -53,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, + ${row.owner}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, @@ -72,6 +74,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, + owner = excluded.owner, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -98,6 +101,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + owner, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -126,6 +130,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + owner, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..782910a5548 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ThreadOwner.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ThreadOwner", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ThreadOwner.test.ts b/apps/server/src/persistence/Migrations/033_ThreadOwner.test.ts new file mode 100644 index 00000000000..88dbe60abdf --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ThreadOwner.test.ts @@ -0,0 +1,78 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("033_ThreadOwner", (it) => { + it.effect("adds a non-null user owner default to projection_threads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 32 }); + yield* runMigrations({ toMigrationInclusive: 33 }); + + const columns = yield* sql<{ + readonly name: string; + readonly notnull: number; + readonly dflt_value: string | null; + }>` + PRAGMA table_info(projection_threads) + `; + const ownerColumn = columns.find((column) => column.name === "owner"); + assert.ok(ownerColumn); + assert.equal(ownerColumn.notnull, 1); + assert.equal(ownerColumn.dflt_value, "'user'"); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES ( + 'thread-default-owner', + 'project-1', + 'Default owner', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-06-01T00:00:00.000Z', + '2026-06-01T00:00:00.000Z', + NULL, + NULL + ) + `; + + const rows = yield* sql<{ readonly owner: string }>` + SELECT owner FROM projection_threads WHERE thread_id = 'thread-default-owner' + `; + assert.equal(rows[0]?.owner, "user"); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/033_ThreadOwner.ts b/apps/server/src/persistence/Migrations/033_ThreadOwner.ts new file mode 100644 index 00000000000..e7239ebca86 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ThreadOwner.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN owner TEXT NOT NULL DEFAULT 'user' + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..3791dd855d4 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -13,6 +13,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadOwner, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -27,6 +28,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + owner: ThreadOwner, modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index fdf95df0b99..49b3a9d42c3 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -39,6 +39,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadOwnerById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index e976c183a43..3c3a209175a 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -200,6 +200,7 @@ describe("ProviderSessionReaper", () => { getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadOwnerById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: (threadId) => diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 40ed694723d..75ddc1a2c9d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -464,6 +464,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } satisfies OrchestrationEngineShape; const snapshotQuery = { + getThreadOwnerById: () => Effect.succeed(Option.some("user")), getShellSnapshot: () => Effect.succeed({ snapshotSequence: 1, @@ -538,6 +539,125 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ), ); + it.effect("skips publishing plugin-owned threads to the relay", () => + Effect.scoped( + Effect.gen(function* () { + const originalFetch = globalThis.fetch; + const events = yield* Queue.unbounded(); + let fetchCalls = 0; + const secrets = makeMemorySecretStore(); + const now = "2026-05-25T00:00:00.000Z"; + const projectId = "project-1" as ProjectId; + const threadId = "thread-plugin" as ThreadId; + const environmentId = "env-1" as EnvironmentId; + + const project = { + id: projectId, + title: "T3 Code", + workspaceRoot: "/workspace", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + } satisfies OrchestrationProjectShell; + + const thread = { + id: threadId, + projectId, + title: "Plugin worker", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + assistantMessageId: null, + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: now, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies OrchestrationThreadShell; + + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + + const descriptor = { + environmentId, + label: "Test Desktop", + platform: { + os: "darwin", + arch: "arm64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + } satisfies ExecutionEnvironmentDescriptor; + + const layer = Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + Layer.succeed(OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.fromQueue(events), + } satisfies OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getThreadOwnerById: () => Effect.succeed(Option.some("plugin:test")), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: [project], + threads: [thread], + updatedAt: now, + } satisfies OrchestrationShellSnapshot), + getThreadShellById: () => Effect.succeed(Option.some(thread)), + getProjectShellById: () => Effect.succeed(Option.some(project)), + } as unknown as ProjectionSnapshotQueryShape), + ); + + yield* Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* secrets.setString(RELAY_URL_SECRET, "https://transport.example.test"); + yield* secrets.setString(RELAY_ISSUER_SECRET, "https://issuer.example.test"); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + yield* relay.publishThread(threadId); + + expect(fetchCalls).toBe(0); + }).pipe( + Effect.provide( + AgentAwarenessRelay.layer.pipe( + Layer.provide(layer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ), + ); + it.effect("publishes agent activity to the relay transport URL, not the relay issuer", () => Effect.scoped( Effect.gen(function* () { @@ -652,6 +772,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { streamDomainEvents: Stream.fromQueue(events), } satisfies OrchestrationEngineShape), Layer.succeed(ProjectionSnapshotQuery, { + getThreadOwnerById: () => Effect.succeed(Option.some("user")), getShellSnapshot: () => Effect.succeed({ snapshotSequence: 1, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 4e036e3ea0e..2f2b88d3ee3 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -323,6 +323,14 @@ export const make = Effect.gen(function* () { }); return; } + const threadOwner = yield* snapshotQuery.getThreadOwnerById(threadId); + if (Option.isSome(threadOwner) && threadOwner.value !== "user") { + yield* Effect.logDebug("agent activity publish skipped; thread is not user-owned", { + threadId, + owner: threadOwner.value, + }); + return; + } const relayClient = yield* makeRelayClient(relayConfig); const environmentId = yield* serverEnvironment.getEnvironmentId; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 26528c84d34..466e443afd7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -705,6 +705,7 @@ const buildAppUnderTest = (options?: { getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e331f0cd4d6..0bdce52916b 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -92,6 +92,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), @@ -154,6 +155,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getThreadOwnerById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), @@ -196,6 +198,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), @@ -244,6 +247,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadOwnerById: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..e8bdf7d7d27 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -26,6 +26,7 @@ const baseThread: OrchestrationThread = { projectId: ProjectId.make("project-1"), title: "Test Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + owner: "user", runtimeMode: "full-access", interactionMode: "default", branch: null, @@ -82,6 +83,7 @@ describe("applyThreadDetailEvent", () => { projectId: ProjectId.make("project-1"), title: "New Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + owner: "plugin:test", runtimeMode: "full-access", interactionMode: "default", branch: "main", @@ -95,6 +97,7 @@ describe("applyThreadDetailEvent", () => { if (result.kind === "updated") { expect(result.thread.id).toBe("thread-2"); expect(result.thread.title).toBe("New Thread"); + expect(result.thread.owner).toBe("plugin:test"); expect(result.thread.branch).toBe("main"); expect(result.thread.messages).toEqual([]); expect(result.thread.session).toBeNull(); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..f1afc7708ce 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -64,6 +64,7 @@ export function applyThreadDetailEvent( projectId: event.payload.projectId, title: event.payload.title, modelSelection: event.payload.modelSelection, + owner: event.payload.owner ?? "user", runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, branch: event.payload.branch, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29a732ca69b..2b03c503e28 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -11,12 +11,14 @@ import { OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, + OrchestrationThread, ProjectCreatedPayload, ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, ProjectCreateCommand, ThreadMetaUpdatedPayload, + ThreadOwner, ThreadTurnStartCommand, ThreadCreatedPayload, ThreadTurnDiff, @@ -34,7 +36,9 @@ const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartC const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( ThreadTurnStartRequestedPayload, ); +const decodeThreadOwner = Schema.decodeUnknownEffect(ThreadOwner); const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); +const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); @@ -312,6 +316,81 @@ it.effect("decodes thread.created runtime mode for historical events", () => }), ); +it.effect("decodes thread ownership with legacy user defaults and plugin-owned ids", () => + Effect.gen(function* () { + assert.strictEqual(yield* decodeThreadOwner("user"), "user"); + assert.strictEqual(yield* decodeThreadOwner("plugin:test"), "plugin:test"); + + const invalidOwner = yield* Effect.exit(decodeThreadOwner("plugin:")); + assert.strictEqual(invalidOwner._tag, "Failure"); + + const payload = yield* decodeThreadCreatedPayload({ + threadId: "thread-1", + projectId: "project-1", + title: "Thread title", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(payload.owner, "user"); + + const thread = yield* decodeOrchestrationThread({ + id: "thread-1", + projectId: "project-1", + title: "Thread title", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }); + assert.strictEqual(thread.owner, "user"); + + const command = yield* decodeOrchestrationCommand({ + type: "thread.create", + commandId: "cmd-thread-create", + threadId: "thread-plugin", + projectId: "project-1", + title: "Plugin thread", + owner: "plugin:test", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(command.type, "thread.create"); + if (command.type !== "thread.create") { + assert.fail(`Expected thread.create command, received ${command.type}.`); + } + assert.strictEqual(command.owner, "plugin:test"); + }), +); + it.effect("decodes thread.meta-updated payloads with explicit provider", () => Effect.gen(function* () { const parsed = yield* decodeThreadMetaUpdatedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917b..1c861e35e47 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -124,6 +124,16 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; +const THREAD_PLUGIN_OWNER_PATTERN = /^plugin:[a-zA-Z][a-zA-Z0-9_-]*$/; +export const ThreadOwner = Schema.Union([ + Schema.Literal("user"), + TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(THREAD_PLUGIN_OWNER_PATTERN), + ), +]); +export type ThreadOwner = typeof ThreadOwner.Type; +export const DEFAULT_THREAD_OWNER: ThreadOwner = "user"; export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); @@ -345,6 +355,9 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + owner: Schema.optionalKey( + ThreadOwner.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_THREAD_OWNER))), + ), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -496,6 +509,7 @@ const ThreadCreateCommand = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + owner: Schema.optional(ThreadOwner), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -840,6 +854,9 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + owner: Schema.optionalKey( + ThreadOwner.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_THREAD_OWNER))), + ), modelSelection: ModelSelection, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), interactionMode: ProviderInteractionMode.pipe( From 9fcfc718cc7061033274343673914f476c85ce20 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 03:16:29 -0400 Subject: [PATCH 02/75] Address slice-1 code review findings - getCommandReadModel now reads an unfiltered thread list: the decider must see plugin-owned threads or commands/events on them fail after a restart (Grok review MUST; getSnapshot stays owner-filtered) - Tighten ThreadOwner plugin-id grammar to the manifest id grammar (lowercase/digits/hyphens, 2-41 chars) - Pin encode direction: decoded legacy payloads re-encode with explicit owner "user" - Document relay behavior when the owner lookup returns no row - Revert unrelated projector.test.ts refactor to keep the diff reviewable Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .../Layers/ProjectionSnapshotQuery.test.ts | 8 +- .../Layers/ProjectionSnapshotQuery.ts | 35 ++++- .../src/orchestration/projector.test.ts | 148 +++++++++--------- apps/server/src/relay/AgentAwarenessRelay.ts | 2 + packages/contracts/src/orchestration.test.ts | 28 ++++ packages/contracts/src/orchestration.ts | 2 +- 6 files changed, 148 insertions(+), 75 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index dc76435d35b..89b841246d1 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -869,10 +869,16 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { threadCount: 1, }); + // The decider's read model keeps plugin-owned threads: commands and + // events on them must still validate after a restart. const commandReadModel = yield* snapshotQuery.getCommandReadModel(); assert.deepEqual( commandReadModel.threads.map((thread) => thread.id), - [ThreadId.make("thread-user-active")], + [ + ThreadId.make("thread-plugin-active"), + ThreadId.make("thread-user-active"), + ThreadId.make("thread-plugin-archived"), + ], ); const fullSnapshot = yield* snapshotQuery.getSnapshot(); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 444088ddbd0..3819b9aeb31 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -349,6 +349,39 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // The command read model must see EVERY thread regardless of owner: the + // decider validates commands against it, and events for a thread missing + // from it are rejected. Non-user (plugin-owned) threads are hidden from + // user-facing views only. + const listAllThreadRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + owner, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + ORDER BY created_at ASC, thread_id ASC + `, + }); + const listActiveThreadRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadDbRowSchema, @@ -1257,7 +1290,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadRows(undefined).pipe( + listAllThreadRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getCommandReadModel:listThreads:query", diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 6d5f81cd317..e633c683638 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -6,8 +6,8 @@ import { ThreadId, type OrchestrationEvent, } from "@t3tools/contracts"; -import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vite-plus/test"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; @@ -133,7 +133,6 @@ describe("orchestration projector", () => { }), ), ); - expect((pluginOwned.threads[0] as { owner?: unknown } | undefined)?.owner).toBe("plugin:test"); const legacy = await Effect.runPromise( @@ -164,7 +163,6 @@ describe("orchestration projector", () => { }), ), ); - expect((legacy.threads[0] as { owner?: unknown } | undefined)?.owner).toBe("user"); }); @@ -921,12 +919,12 @@ describe("orchestration projector", () => { ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); }); - it.effect("caps message and checkpoint retention for long-lived threads", () => - Effect.gen(function* () { - const createdAt = "2026-03-01T10:00:00.000Z"; - const model = createEmptyReadModel(createdAt); + it("caps message and checkpoint retention for long-lived threads", async () => { + const createdAt = "2026-03-01T10:00:00.000Z"; + const model = createEmptyReadModel(createdAt); - const afterCreate = yield* projectEvent( + const afterCreate = await Effect.runPromise( + projectEvent( model, makeEvent({ sequence: 1, @@ -950,69 +948,75 @@ describe("orchestration projector", () => { updatedAt: createdAt, }, }), - ); + ), + ); - const messageEvents: ReadonlyArray = Array.from( - { length: 2_100 }, - (_, index) => - makeEvent({ - sequence: index + 2, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-message-${index}`, - payload: { - threadId: "thread-capped", - messageId: `msg-${index}`, - role: "assistant", - text: `message-${index}`, - turnId: `turn-${index}`, - streaming: false, - createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - let afterMessages = afterCreate; - for (const event of messageEvents) { - afterMessages = yield* projectEvent(afterMessages, event); - } - - const checkpointEvents: ReadonlyArray = Array.from( - { length: 600 }, - (_, index) => - makeEvent({ - sequence: index + 2_102, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-checkpoint-${index}`, - payload: { - threadId: "thread-capped", - turnId: `turn-${index}`, - checkpointTurnCount: index + 1, - checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, - status: "ready", - files: [], - assistantMessageId: `msg-${index}`, - completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - let finalState = afterMessages; - for (const event of checkpointEvents) { - finalState = yield* projectEvent(finalState, event); - } - - const thread = finalState.threads[0]; - expect(thread?.messages).toHaveLength(2_000); - expect(thread?.messages[0]?.id).toBe("msg-100"); - expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); - expect(thread?.checkpoints).toHaveLength(500); - expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); - expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); - }), - ); + const messageEvents: ReadonlyArray = Array.from( + { length: 2_100 }, + (_, index) => + makeEvent({ + sequence: index + 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-message-${index}`, + payload: { + threadId: "thread-capped", + messageId: `msg-${index}`, + role: "assistant", + text: `message-${index}`, + turnId: `turn-${index}`, + streaming: false, + createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + const afterMessages = await messageEvents.reduce< + Promise> + >( + (statePromise, event) => + statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), + Promise.resolve(afterCreate), + ); + + const checkpointEvents: ReadonlyArray = Array.from( + { length: 600 }, + (_, index) => + makeEvent({ + sequence: index + 2_102, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-checkpoint-${index}`, + payload: { + threadId: "thread-capped", + turnId: `turn-${index}`, + checkpointTurnCount: index + 1, + checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, + status: "ready", + files: [], + assistantMessageId: `msg-${index}`, + completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + const finalState = await checkpointEvents.reduce< + Promise> + >( + (statePromise, event) => + statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), + Promise.resolve(afterMessages), + ); + + const thread = finalState.threads[0]; + expect(thread?.messages).toHaveLength(2_000); + expect(thread?.messages[0]?.id).toBe("msg-100"); + expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); + expect(thread?.checkpoints).toHaveLength(500); + expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); + expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); + }); }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 2f2b88d3ee3..fd62c2f8fcc 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -323,6 +323,8 @@ export const make = Effect.gen(function* () { }); return; } + // A missing row (None) proceeds: deleted/unknown threads follow the same + // downstream not-found handling as before this check existed. const threadOwner = yield* snapshotQuery.getThreadOwnerById(threadId); if (Option.isSome(threadOwner) && threadOwner.value !== "user") { yield* Effect.logDebug("agent activity publish skipped; thread is not user-owned", { diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 2b03c503e28..d51ed3446a6 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -324,6 +324,34 @@ it.effect("decodes thread ownership with legacy user defaults and plugin-owned i const invalidOwner = yield* Effect.exit(decodeThreadOwner("plugin:")); assert.strictEqual(invalidOwner._tag, "Failure"); + // Owner ids follow the plugin manifest id grammar: lowercase, digits, + // hyphens only. + const uppercaseOwner = yield* Effect.exit(decodeThreadOwner("plugin:Test")); + assert.strictEqual(uppercaseOwner._tag, "Failure"); + const underscoreOwner = yield* Effect.exit(decodeThreadOwner("plugin:my_plugin")); + assert.strictEqual(underscoreOwner._tag, "Failure"); + + // Encode direction: a decoded legacy payload re-encodes WITH an explicit + // owner — new serializations of old events are self-describing. + const encodedLegacy = yield* encodeThreadCreatedPayload( + yield* decodeThreadCreatedPayload({ + threadId: "thread-legacy-encode", + projectId: "project-1", + title: "Legacy encode", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + assert.strictEqual((encodedLegacy as { owner?: unknown }).owner, "user"); + const payload = yield* decodeThreadCreatedPayload({ threadId: "thread-1", projectId: "project-1", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1c861e35e47..3eca543fced 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -124,7 +124,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; -const THREAD_PLUGIN_OWNER_PATTERN = /^plugin:[a-zA-Z][a-zA-Z0-9_-]*$/; +const THREAD_PLUGIN_OWNER_PATTERN = /^plugin:[a-z][a-z0-9-]{1,40}$/; export const ThreadOwner = Schema.Union([ Schema.Literal("user"), TrimmedNonEmptyString.check( From 581ae60bff9420edb59a361153f6e7867dab4d65 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 03:56:58 -0400 Subject: [PATCH 03/75] Add plugin host foundation Runtime plugin infrastructure for the server, behind a lockfile at /plugins/plugins.json. No transport or capability facades yet. - @t3tools/contracts/plugin: manifest schema (fail-closed), plugin id grammar, capability literals, hostApi version + range matcher, lockfile schema with staged-upgrade + safe-mode fields - @t3tools/plugin-sdk: definePlugin, PluginHostApi capability interfaces, registration descriptor types - apps/server/src/plugins/: lockfile store (atomic writes, in-process semaphore + advisory file lock), plugin migrator (per-plugin versioning in plugin_migrations, sqlite_master prefix gate incl. trigger/view body checks, downgrade refusal), module loader (containment-checked dynamic import, module.register resolve hook sharing the host effect instance), runtime registry, PluginHost lifecycle (pending-state application, crash-loop safe mode, hostApi gating, per-plugin Scope, T3_NO_PLUGINS) - Migration 034: plugin_migrations table - Host starts after signalCommandReady; zero-plugin boot is a single missing-lockfile check Implemented by GPT-5.5 via codex exec (assembly-line slice 2a-1). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/package.json | 1 + apps/server/src/config.ts | 3 + apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/034_PluginMigrations.test.ts | 37 ++ .../Migrations/034_PluginMigrations.ts | 16 + apps/server/src/plugins/PluginHost.test.ts | 294 ++++++++++++ apps/server/src/plugins/PluginHost.ts | 434 ++++++++++++++++++ .../src/plugins/PluginLockfileStore.test.ts | 143 ++++++ .../server/src/plugins/PluginLockfileStore.ts | 316 +++++++++++++ .../server/src/plugins/PluginMigrator.test.ts | 175 +++++++ apps/server/src/plugins/PluginMigrator.ts | 224 +++++++++ apps/server/src/plugins/PluginModuleLoader.ts | 133 ++++++ apps/server/src/plugins/PluginPaths.ts | 32 ++ .../src/plugins/PluginRuntimeRegistry.ts | 55 +++ apps/server/src/plugins/pluginResolveHooks.ts | 36 ++ apps/server/src/server.ts | 18 +- apps/server/src/serverRuntimeStartup.ts | 4 + packages/contracts/package.json | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/plugin.test.ts | 128 ++++++ packages/contracts/src/plugin.ts | 204 ++++++++ packages/plugin-sdk/package.json | 19 + packages/plugin-sdk/src/index.test.ts | 15 + packages/plugin-sdk/src/index.ts | 160 +++++++ packages/plugin-sdk/tsconfig.json | 5 + 25 files changed, 2458 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/persistence/Migrations/034_PluginMigrations.test.ts create mode 100644 apps/server/src/persistence/Migrations/034_PluginMigrations.ts create mode 100644 apps/server/src/plugins/PluginHost.test.ts create mode 100644 apps/server/src/plugins/PluginHost.ts create mode 100644 apps/server/src/plugins/PluginLockfileStore.test.ts create mode 100644 apps/server/src/plugins/PluginLockfileStore.ts create mode 100644 apps/server/src/plugins/PluginMigrator.test.ts create mode 100644 apps/server/src/plugins/PluginMigrator.ts create mode 100644 apps/server/src/plugins/PluginModuleLoader.ts create mode 100644 apps/server/src/plugins/PluginPaths.ts create mode 100644 apps/server/src/plugins/PluginRuntimeRegistry.ts create mode 100644 apps/server/src/plugins/pluginResolveHooks.ts create mode 100644 packages/contracts/src/plugin.test.ts create mode 100644 packages/contracts/src/plugin.ts create mode 100644 packages/plugin-sdk/package.json create mode 100644 packages/plugin-sdk/src/index.test.ts create mode 100644 packages/plugin-sdk/src/index.ts create mode 100644 packages/plugin-sdk/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index d0903c77d75..f89b32a20b9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,6 +27,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@t3tools/plugin-sdk": "workspace:*", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 2608ccc16ae..dde1feeb349 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -28,6 +28,7 @@ export type StartupPresentation = typeof StartupPresentation.Type; export interface ServerDerivedPaths { readonly stateDir: string; readonly dbPath: string; + readonly pluginsDir: string; readonly keybindingsConfigPath: string; readonly settingsPath: string; readonly providerStatusCacheDir: string; @@ -95,6 +96,7 @@ export const deriveServerPaths = Effect.fn(function* ( const { join } = yield* Path.Path; const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata"); const dbPath = join(stateDir, "state.sqlite"); + const pluginsDir = join(stateDir, "plugins"); const attachmentsDir = join(stateDir, "attachments"); const logsDir = join(stateDir, "logs"); const providerLogsDir = join(logsDir, "provider"); @@ -102,6 +104,7 @@ export const deriveServerPaths = Effect.fn(function* ( return { stateDir, dbPath, + pluginsDir, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), providerStatusCacheDir, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 782910a5548..c17105e2f8b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -46,6 +46,7 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ThreadOwner.ts"; +import Migration0034 from "./Migrations/034_PluginMigrations.ts"; /** * Migration loader with all migrations defined inline. @@ -91,6 +92,7 @@ export const migrationEntries = [ [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ThreadOwner", Migration0033], + [34, "PluginMigrations", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/034_PluginMigrations.test.ts b/apps/server/src/persistence/Migrations/034_PluginMigrations.test.ts new file mode 100644 index 00000000000..a67a02b0579 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_PluginMigrations.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("034_PluginMigrations", (it) => { + it.effect("creates plugin migration tracking table", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 34 }); + + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name = 'plugin_migrations' + `; + assert.equal(tables.length, 1); + + yield* sql` + INSERT INTO plugin_migrations (plugin_id, version, name, applied_at) + VALUES ('test-plugin', 1, 'Init', '2026-07-03T00:00:00.000Z') + `; + + const rows = yield* sql<{ readonly version: number; readonly name: string }>` + SELECT version, name + FROM plugin_migrations + WHERE plugin_id = 'test-plugin' + `; + assert.deepEqual(rows, [{ version: 1, name: "Init" }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/034_PluginMigrations.ts b/apps/server/src/persistence/Migrations/034_PluginMigrations.ts new file mode 100644 index 00000000000..d09c074c2af --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_PluginMigrations.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE plugin_migrations ( + plugin_id TEXT NOT NULL, + version INTEGER NOT NULL, + name TEXT NOT NULL, + applied_at TEXT NOT NULL, + PRIMARY KEY (plugin_id, version) + ) + `; +}); diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts new file mode 100644 index 00000000000..42a12a08c8c --- /dev/null +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -0,0 +1,294 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId, PluginManifest, type PluginLockfilePlugin } from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { pathToFileURL } from "node:url"; + +import * as ServerConfig from "../config.ts"; +import { runMigrations } from "../persistence/Migrations.ts"; +import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import * as PluginHostModule from "./PluginHost.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import * as PluginMigrator from "./PluginMigrator.ts"; +import * as PluginModuleLoaderLayer from "./PluginModuleLoader.ts"; +import { pluginDataDir, pluginVersionDir } from "./PluginPaths.ts"; +import * as PluginRuntimeRegistryLayer from "./PluginRuntimeRegistry.ts"; + +const encodeManifestJson = Schema.encodeEffect(Schema.fromJsonString(PluginManifest)); + +const testLayer = PluginHostModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayer.layer), + Layer.provideMerge(PluginModuleLoaderLayer.layer), + Layer.provideMerge(PluginMigrator.layer), + Layer.provideMerge(PluginRuntimeRegistryLayer.layer), + Layer.provideMerge(NodeSqliteClient.layerMemory()), + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-host-" })), + ), + Layer.provideMerge(NodeServices.layer), +); + +const layer = it.layer(testLayer); + +const now = "2026-07-03T00:00:00.000Z"; + +const makeLockEntry = (overrides: Partial = {}): PluginLockfilePlugin => ({ + version: "1.0.0", + sha256: "sha", + sourceId: "local", + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: now, + lastError: null, + ...overrides, +}); + +const pluginEntrySource = () => ` +import { createRequire } from "node:module"; +const require = createRequire(${JSON.stringify(pathToFileURL(import.meta.url).href)}); +const Effect = require("effect/Effect"); +const SqlClient = require("effect/unstable/sql/SqlClient"); +const NodeFs = require("node:fs"); + +export default { + register(hostApi) { + return { + migrations: [ + { + version: 1, + name: "Init", + up: Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql\`CREATE TABLE p_test_plugin_items (id TEXT PRIMARY KEY)\`; + }), + }, + ], + services: [ + { + name: "marker", + run: () => + Effect.sync(() => { + NodeFs.writeFileSync(hostApi.config.dataDir + "/service-ran", "1"); + }).pipe(Effect.andThen(Effect.never)), + }, + ], + }; + }, +}; +`; + +const installPlugin = (input: { + readonly pluginId: PluginId; + readonly manifestHostApi?: string; + readonly entrySource?: string; + readonly lockEntry?: Partial; +}) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + const entry = makeLockEntry(input.lockEntry); + const pluginDir = pluginVersionDir(config.pluginsDir, input.pluginId, entry.version, path.join); + + yield* fs.makeDirectory(pluginDir, { recursive: true }); + const encodedManifest = yield* encodeManifestJson({ + id: input.pluginId, + name: "Test Plugin", + version: entry.version, + hostApi: input.manifestHostApi ?? "^1.0.0", + capabilities: [], + entries: { server: "server.js" }, + }); + yield* fs.writeFileString(path.join(pluginDir, "manifest.json"), encodedManifest); + yield* fs.writeFileString(path.join(pluginDir, "server.js"), input.entrySource ?? pluginEntrySource()); + yield* store.updatePlugin(input.pluginId, () => Effect.succeed(entry)); + return { pluginDir, entry }; + }); + +layer("PluginModuleLoader", (it) => { + it.effect("loads a definePlugin-shaped default export from inside the plugin dir", () => + Effect.gen(function* () { + const pluginId = PluginId.make("loader-plugin"); + const loader = yield* PluginModuleLoaderLayer.PluginModuleLoader; + const { pluginDir } = yield* installPlugin({ + pluginId, + entrySource: "export default { register() { return {}; } };", + }); + + const definition = yield* loader.loadServerEntry(pluginDir, "server.js"); + + assert.equal(typeof definition.register, "function"); + }), + ); + + it.effect("rejects entries that resolve outside the plugin dir", () => + Effect.gen(function* () { + const pluginId = PluginId.make("loader-escape"); + const loader = yield* PluginModuleLoaderLayer.PluginModuleLoader; + const { pluginDir } = yield* installPlugin({ + pluginId, + entrySource: "export default { register() { return {}; } };", + }); + + const result = yield* Effect.result(loader.loadServerEntry(pluginDir, "../server.js")); + + assert.isTrue(Result.isFailure(result)); + }), + ); +}); + +layer("PluginHost", (it) => { + it.effect("activates a plugin, records migrations, starts services, and clears activation", () => + Effect.gen(function* () { + const pluginId = PluginId.make("test-plugin"); + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const host = yield* PluginHostModule.PluginHost; + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + const previousHealthyDelay = process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + + yield* runMigrations({ toMigrationInclusive: 34 }); + yield* installPlugin({ pluginId }); + + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + try { + yield* host.start; + yield* Effect.yieldNow; + } finally { + if (previousHealthyDelay === undefined) { + delete process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + } else { + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = previousHealthyDelay; + } + } + + const runtimes = yield* registry.list; + assert.equal(runtimes.length, 1); + const migrationRows = yield* sql<{ readonly version: number }>` + SELECT version FROM plugin_migrations WHERE plugin_id = ${pluginId} + `; + assert.deepEqual(migrationRows, [{ version: 1 }]); + assert.isTrue( + yield* fs.exists(path.join(pluginDataDir(config.pluginsDir, pluginId, path.join), "service-ran")), + ); + + let lockfile = yield* store.readLockfile; + for (let attempt = 0; attempt < 5; attempt++) { + if (lockfile.plugins[pluginId]?.activation.activatingSince === null) break; + yield* Effect.yieldNow; + lockfile = yield* store.readLockfile; + } + assert.equal(lockfile.plugins[pluginId]?.activation.activatingSince, null); + assert.equal(lockfile.plugins[pluginId]?.activation.crashCount, 0); + }), + ); + + it.effect("marks failed imports without failing host startup", () => + Effect.gen(function* () { + const pluginId = PluginId.make("failed-plugin"); + const host = yield* PluginHostModule.PluginHost; + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + + yield* runMigrations({ toMigrationInclusive: 34 }); + yield* installPlugin({ pluginId, entrySource: "throw new Error('boom');" }); + + yield* host.start; + + const runtimes = yield* registry.list; + const lockfile = yield* store.readLockfile; + assert.isFalse(runtimes.some((runtime) => runtime.manifest.id === pluginId)); + assert.equal(lockfile.plugins[pluginId]?.state, "failed"); + }), + ); + + it.effect("disables crash-looping plugins before import", () => + Effect.gen(function* () { + const pluginId = PluginId.make("crash-plugin"); + const host = yield* PluginHostModule.PluginHost; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + + yield* installPlugin({ + pluginId, + lockEntry: { + activation: { activatingSince: now, crashCount: 1 }, + }, + }); + + yield* host.start; + + const lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.state, "failed"); + assert.equal(lockfile.plugins[pluginId]?.lastError, "disabled after repeated crashes"); + }), + ); + + it.effect("does not load anything when T3_NO_PLUGINS is set", () => + Effect.gen(function* () { + const pluginId = PluginId.make("disabled-env"); + const host = yield* PluginHostModule.PluginHost; + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + const previous = process.env.T3_NO_PLUGINS; + + yield* installPlugin({ pluginId }); + process.env.T3_NO_PLUGINS = "1"; + try { + yield* host.start; + } finally { + if (previous === undefined) { + delete process.env.T3_NO_PLUGINS; + } else { + process.env.T3_NO_PLUGINS = previous; + } + } + + const runtimes = yield* registry.list; + assert.isFalse(runtimes.some((runtime) => runtime.manifest.id === pluginId)); + }), + ); + + it.effect("sets disabled-by-host when hostApi range is not satisfied", () => + Effect.gen(function* () { + const pluginId = PluginId.make("host-mismatch"); + const host = yield* PluginHostModule.PluginHost; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + + yield* installPlugin({ pluginId, manifestHostApi: "^2.0.0" }); + + yield* host.start; + + const lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.state, "disabled-by-host"); + }), + ); + + it.effect("applies pending-remove before loading plugins", () => + Effect.gen(function* () { + const pluginId = PluginId.make("remove-plugin"); + const host = yield* PluginHostModule.PluginHost; + const fs = yield* FileSystem.FileSystem; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + const { pluginDir } = yield* installPlugin({ + pluginId, + lockEntry: { state: "pending-remove" }, + }); + + yield* host.start; + + const lockfile = yield* store.readLockfile; + assert.isUndefined(lockfile.plugins[pluginId]); + assert.isFalse(yield* fs.exists(pluginDir)); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts new file mode 100644 index 00000000000..4969546f598 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.ts @@ -0,0 +1,434 @@ +import { + HOST_API_VERSION, + PluginManifest, + hostApiSatisfies, + type PluginId, + type PluginLockfile, + type PluginLockfilePlugin, +} from "@t3tools/contracts/plugin"; +import type { + PluginDefinition, + PluginHostApi, + PluginLogger, + PluginRegistration, + PluginServiceDescriptor, +} from "@t3tools/plugin-sdk"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import * as ServerConfig from "../config.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { PluginMigrator } from "./PluginMigrator.ts"; +import { PluginModuleLoader } from "./PluginModuleLoader.ts"; +import { + pluginDataDir, + pluginManifestPath, + pluginVersionDir, +} from "./PluginPaths.ts"; +import { PluginRuntimeRegistry } from "./PluginRuntimeRegistry.ts"; + +const APP_VERSION = "0.0.28"; +const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + +const healthyActivationDelay = () => { + const overrideMs = Number.parseInt(process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS ?? "", 10); + return Number.isFinite(overrideMs) && overrideMs >= 0 + ? Duration.millis(overrideMs) + : Duration.seconds(30); +}; + +export class PluginRegistrationError extends Schema.TaggedErrorClass()( + "PluginRegistrationError", + { pluginId: Schema.String, detail: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} returned an invalid registration: ${this.detail}`; + } +} + +export class PluginCapabilityUnavailable extends Schema.TaggedErrorClass()( + "PluginCapabilityUnavailable", + { capability: Schema.String }, +) { + override get message(): string { + return `Capability ${this.capability} is not available in this host build.`; + } +} + +export class PluginHost extends Context.Service< + PluginHost, + { + readonly start: Effect.Effect; + } +>()("t3/plugins/PluginHost") {} + +function isPromiseLike(value: unknown): value is Promise { + return typeof value === "object" && value !== null && "then" in value; +} + +const resolveRegistration = ( + pluginId: PluginId, + definition: PluginDefinition, + hostApi: PluginHostApi, +) => + Effect.suspend(() => { + const value = definition.register(hostApi); + if (Effect.isEffect(value)) return value; + if (isPromiseLike(value)) return Effect.promise(() => value as Promise); + return Effect.succeed(value); + }).pipe( + Effect.catchCause((cause) => + Effect.fail( + new PluginRegistrationError({ + pluginId, + detail: Cause.pretty(cause), + }), + ), + ), + ); + +function validateRegistration( + pluginId: PluginId, + registration: PluginRegistration, +): Effect.Effect { + const methods = new Set(); + for (const rpc of registration.rpc ?? []) { + if (rpc.scope !== "read" && rpc.scope !== "operate") { + return Effect.fail( + new PluginRegistrationError({ pluginId, detail: `invalid RPC scope ${rpc.scope}` }), + ); + } + if (methods.has(rpc.method)) { + return Effect.fail( + new PluginRegistrationError({ pluginId, detail: `duplicate RPC method ${rpc.method}` }), + ); + } + methods.add(rpc.method); + } + return Effect.void; +} + +const makeLogger = (pluginId: PluginId): PluginLogger => ({ + debug: (message, attributes) => Effect.logDebug(message, { ...attributes, pluginId }), + info: (message, attributes) => Effect.logInfo(message, { ...attributes, pluginId }), + warn: (message, attributes) => Effect.logWarning(message, { ...attributes, pluginId }), + error: (message, attributes) => Effect.logError(message, { ...attributes, pluginId }), +}); + +const unavailable = (capability: string) => + Effect.die(new PluginCapabilityUnavailable({ capability })); + +const makeHostApi = (input: { + readonly pluginId: PluginId; + readonly dataDir: string; + readonly logger: PluginLogger; +}): PluginHostApi => ({ + hostApiVersion: HOST_API_VERSION, + config: { + appVersion: APP_VERSION, + hostApiVersion: HOST_API_VERSION, + dataDir: input.dataDir, + logger: input.logger, + }, + agents: unavailable("agents"), + vcs: unavailable("vcs"), + terminals: unavailable("terminals"), + database: unavailable("database"), + projectionsRead: unavailable("projections.read"), + environmentsRead: unavailable("environments.read"), + secrets: unavailable("secrets"), + http: unavailable("http"), + sourceControl: unavailable("sourceControl"), + textGeneration: unavailable("textGeneration"), +}); + +const upgradeLockfileEntry = ( + entry: PluginLockfilePlugin, + staged: NonNullable, +): PluginLockfilePlugin => ({ + version: staged.version, + sha256: staged.sha256, + sourceId: entry.sourceId, + enabled: entry.enabled, + state: "active", + activation: entry.activation, + installedAt: entry.installedAt, + lastError: entry.lastError, +}); + +const getLockfilePlugin = (lockfile: PluginLockfile, pluginId: PluginId) => + (lockfile.plugins as Readonly>)[pluginId]; + +const updateFailure = ( + store: PluginLockfileStore["Service"], + pluginId: PluginId, + message: string, +) => + store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + state: "failed", + lastError: message, + activation: { + ...current.activation, + activatingSince: null, + }, + } + : undefined, + ), + ); + +const startService = (input: { + readonly pluginId: PluginId; + readonly logger: PluginLogger; + readonly service: PluginServiceDescriptor; +}) => + input + .service + .run({ pluginId: input.pluginId, logger: input.logger }) + .pipe( + Effect.catchCause((cause) => + input.logger.error("plugin service failed; restarting", { + service: input.service.name, + cause: Cause.pretty(cause), + }), + ), + Effect.repeat(Schedule.exponential("250 millis")), + ); + +export const make = Effect.fn("PluginHost.make")(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const store = yield* PluginLockfileStore; + const loader = yield* PluginModuleLoader; + const migrator = yield* PluginMigrator; + const registry = yield* PluginRuntimeRegistry; + const clock = yield* Clock.Clock; + + const readManifest = (pluginDir: string) => + fs.readFileString(pluginManifestPath(pluginDir, path.join)).pipe( + Effect.flatMap(decodeManifest), + ); + + const loadPlugin = (pluginId: PluginId, entry: PluginLockfilePlugin) => + Effect.gen(function* () { + const pluginDir = pluginVersionDir(config.pluginsDir, pluginId, entry.version, path.join); + const manifest = yield* readManifest(pluginDir); + if (manifest.id !== pluginId) { + return yield* new PluginRegistrationError({ + pluginId, + detail: `manifest id ${manifest.id} does not match lockfile id`, + }); + } + if (!hostApiSatisfies(manifest.hostApi, HOST_API_VERSION)) { + yield* store.updatePlugin(pluginId, ({ current }) => + Effect.succeed(current ? { ...current, state: "disabled-by-host" } : undefined), + ); + yield* Effect.logWarning("Plugin disabled by host API version mismatch", { + pluginId, + requested: manifest.hostApi, + hostApiVersion: HOST_API_VERSION, + }); + return; + } + if (!manifest.entries.server) { + yield* Effect.logDebug("Skipping web-only plugin in server plugin host", { pluginId }); + return; + } + + const serverEntry = manifest.entries.server; + const serverEntryPath = path.join(pluginDir, serverEntry); + if (!(yield* fs.exists(pluginDir)) || !(yield* fs.exists(serverEntryPath))) { + yield* updateFailure(store, pluginId, "plugin directory or server entry is missing"); + return; + } + + const activatingSince = DateTime.formatIso(yield* DateTime.now); + yield* store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + activation: { + ...current.activation, + activatingSince, + }, + } + : undefined, + ), + ); + + const scope = yield* Scope.make("sequential"); + const readiness = yield* Deferred.make(); + const logger = makeLogger(pluginId); + const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const hostApi = makeHostApi({ pluginId, dataDir, logger }); + + const activation = Effect.gen(function* () { + yield* fs.makeDirectory(dataDir, { recursive: true }); + const definition = yield* loader.loadServerEntry(pluginDir, serverEntry); + const registration = yield* resolveRegistration(pluginId, definition, hostApi); + yield* validateRegistration(pluginId, registration); + yield* migrator.run(pluginId, registration.migrations ?? []); + if (registration.recover) { + yield* registration.recover(); + } + yield* registry.put(pluginId, { manifest, registration, readiness, scope }); + for (const service of registration.services ?? []) { + yield* startService({ pluginId, logger, service }).pipe( + Effect.forkScoped, + Scope.provide(scope), + ); + } + yield* Deferred.succeed(readiness, undefined).pipe(Effect.orDie); + const clearHealthyActivation = store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + activation: { activatingSince: null, crashCount: 0 }, + lastError: null, + } + : undefined, + ), + ); + const healthyDelay = healthyActivationDelay(); + if (Duration.toMillis(healthyDelay) === 0) { + yield* clearHealthyActivation; + } else { + yield* clock.sleep(healthyDelay).pipe( + Effect.flatMap(() => clearHealthyActivation), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + Scope.provide(scope), + ); + } + }); + + const exit = yield* activation.pipe(Scope.provide(scope), Effect.exit); + if (Exit.isFailure(exit)) { + yield* Scope.close(scope, exit); + const message = Cause.pretty(exit.cause); + yield* updateFailure(store, pluginId, message); + yield* Effect.logWarning("Plugin activation failed", { pluginId, cause: message }); + } + }); + + const reconcilePendingState = (pluginId: PluginId, entry: PluginLockfilePlugin) => + Effect.gen(function* () { + if (entry.state === "pending-remove") { + yield* fs.remove(path.join(config.pluginsDir, pluginId), { recursive: true, force: true }); + yield* store.removePlugin(pluginId); + return false; + } + if (entry.state === "pending-upgrade") { + if (!entry.staged) { + yield* updateFailure(store, pluginId, "pending upgrade is missing staged plugin metadata"); + return false; + } + const staged = entry.staged; + yield* store.updatePlugin(pluginId, ({ current }) => + Effect.succeed(current ? upgradeLockfileEntry(current, staged) : undefined), + ); + return true; + } + if (entry.activation.activatingSince !== null) { + const crashCount = entry.activation.crashCount + 1; + if (crashCount >= 2) { + yield* store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + state: "failed", + lastError: "disabled after repeated crashes", + activation: { activatingSince: null, crashCount }, + } + : undefined, + ), + ); + return false; + } + yield* store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + activation: { activatingSince: null, crashCount }, + } + : undefined, + ), + ); + } + return true; + }); + + const start = Effect.gen(function* () { + if (process.env.T3_NO_PLUGINS === "1") { + yield* Effect.logInfo("Plugin host disabled by T3_NO_PLUGINS"); + return; + } + if (!(yield* fs.exists(store.lockfilePath).pipe(Effect.orElseSucceed(() => false)))) { + return; + } + yield* loader.ensureHostSingletonResolution; + const lockfile = yield* store.readLockfile.pipe( + Effect.catch((error) => + Effect.logWarning("Plugin host could not read lockfile", { + path: store.lockfilePath, + error: error.message, + }).pipe(Effect.as({ plugins: {}, sources: [] })), + ), + ); + + for (const [rawPluginId, entry] of Object.entries(lockfile.plugins)) { + const pluginId = rawPluginId as PluginId; + const shouldContinue = yield* reconcilePendingState(pluginId, entry).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Plugin pending-state reconciliation failed", { + pluginId, + cause: Cause.pretty(cause), + }).pipe(Effect.as(false)), + ), + ); + if (!shouldContinue || !entry.enabled) continue; + const currentLockfile = yield* store.readLockfile.pipe( + Effect.orElseSucceed(() => lockfile), + ); + const currentEntry = getLockfilePlugin(currentLockfile, pluginId); + if (!currentEntry?.enabled || currentEntry.state !== "active") continue; + yield* loadPlugin(pluginId, currentEntry).pipe( + Effect.catchCause((cause) => + updateFailure(store, pluginId, Cause.pretty(cause)).pipe( + Effect.andThen( + Effect.logWarning("Plugin activation failed before scope acquisition", { + pluginId, + cause: Cause.pretty(cause), + }), + ), + Effect.ignore, + ), + ), + ); + } + }).pipe(Effect.ignoreCause({ log: true })); + + return PluginHost.of({ start }); +}); + +export const layer = Layer.effect(PluginHost, make()); diff --git a/apps/server/src/plugins/PluginLockfileStore.test.ts b/apps/server/src/plugins/PluginLockfileStore.test.ts new file mode 100644 index 00000000000..1fee7b4ed16 --- /dev/null +++ b/apps/server/src/plugins/PluginLockfileStore.test.ts @@ -0,0 +1,143 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId, type PluginLockfilePlugin } from "@t3tools/contracts/plugin"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import * as PluginLockfileStoreModule from "./PluginLockfileStore.ts"; +import { PluginLockfileCorruptError, PluginLockfileTransitionError } from "./PluginLockfileStore.ts"; + +const layer = it.layer( + PluginLockfileStoreModule.layer.pipe( + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-lockfile-" })), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(TestClock.layer()), + ), +); + +const pluginId = PluginId.make("test-plugin"); + +const makePlugin = (overrides: Partial = {}): PluginLockfilePlugin => ({ + version: "1.0.0", + sha256: "sha", + sourceId: "local", + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + ...overrides, +}); + +layer("PluginLockfileStore", (it) => { + it.effect("returns an empty lockfile when plugins.json is missing", () => + Effect.gen(function* () { + const store = yield* PluginLockfileStoreModule.PluginLockfileStore; + + const lockfile = yield* store.readLockfile; + + assert.deepEqual(lockfile, { sources: [], plugins: {} }); + }), + ); + + it.effect("returns a typed error for corrupt lockfile JSON", () => + Effect.gen(function* () { + const store = yield* PluginLockfileStoreModule.PluginLockfileStore; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* fs.makeDirectory(path.dirname(store.lockfilePath), { recursive: true }); + yield* fs.writeFileString(store.lockfilePath, "{not-json"); + + const result = yield* Effect.result(store.readLockfile); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginLockfileCorruptError); + } + yield* fs.remove(store.lockfilePath, { force: true }); + }), + ); + + it.effect("serializes concurrent mutations so both updates apply", () => + Effect.gen(function* () { + const store = yield* PluginLockfileStoreModule.PluginLockfileStore; + + yield* store.updatePlugin(pluginId, () => Effect.succeed(makePlugin())); + yield* Effect.all( + [ + store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + activation: { + ...current.activation, + crashCount: current.activation.crashCount + 1, + }, + } + : undefined, + ), + ), + store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + activation: { + ...current.activation, + crashCount: current.activation.crashCount + 1, + }, + } + : undefined, + ), + ), + ], + { concurrency: "unbounded" }, + ); + + const lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.activation.crashCount, 2); + }), + ); + + it.effect("reclaims stale advisory locks", () => + Effect.gen(function* () { + const store = yield* PluginLockfileStoreModule.PluginLockfileStore; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* fs.makeDirectory(path.dirname(store.advisoryLockPath), { recursive: true }); + yield* fs.writeFileString(store.advisoryLockPath, "stale"); + const stale = DateTime.toDateUtc(DateTime.makeUnsafe("1970-01-01T00:00:00.000Z")); + yield* fs.utimes(store.advisoryLockPath, stale, stale); + yield* TestClock.setTime(120_000); + + yield* store.updatePlugin(pluginId, () => Effect.succeed(makePlugin())); + + const lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.version, "1.0.0"); + }), + ); + + it.effect("rejects invalid state transitions", () => + Effect.gen(function* () { + const store = yield* PluginLockfileStoreModule.PluginLockfileStore; + + yield* store.updatePlugin(pluginId, () => Effect.succeed(makePlugin({ state: "disabled" }))); + const result = yield* Effect.result(store.transition(pluginId, ["active"], "failed")); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginLockfileTransitionError); + } + }), + ); +}); diff --git a/apps/server/src/plugins/PluginLockfileStore.ts b/apps/server/src/plugins/PluginLockfileStore.ts new file mode 100644 index 00000000000..16686b51c9e --- /dev/null +++ b/apps/server/src/plugins/PluginLockfileStore.ts @@ -0,0 +1,316 @@ +import { + EMPTY_PLUGIN_LOCKFILE, + PluginId, + PluginLockfile, + PluginState, + type PluginLockfilePlugin, +} from "@t3tools/contracts/plugin"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as ServerConfig from "../config.ts"; +import { pluginAdvisoryLockPath, pluginLockfilePath } from "./PluginPaths.ts"; + +const STALE_LOCK_MS = 60_000; +const PluginLockfileJson = Schema.fromJsonString(PluginLockfile); +const decodePluginLockfileJson = Schema.decodeUnknownEffect(PluginLockfileJson); +const encodePluginLockfileJson = Schema.encodeEffect(PluginLockfileJson); + +export class PluginLockfileReadError extends Schema.TaggedErrorClass()( + "PluginLockfileReadError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not read plugin lockfile at ${this.path}.`; + } +} + +export class PluginLockfileCorruptError extends Schema.TaggedErrorClass()( + "PluginLockfileCorruptError", + { path: Schema.String, detail: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin lockfile at ${this.path} is corrupt: ${this.detail}`; + } +} + +export class PluginLockfileWriteError extends Schema.TaggedErrorClass()( + "PluginLockfileWriteError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not write plugin lockfile at ${this.path}.`; + } +} + +export class PluginLockfileLockError extends Schema.TaggedErrorClass()( + "PluginLockfileLockError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not acquire plugin lockfile advisory lock at ${this.path}.`; + } +} + +export class PluginLockfileTransitionError extends Schema.TaggedErrorClass()( + "PluginLockfileTransitionError", + { + pluginId: PluginId, + from: Schema.Array(PluginState), + to: PluginState, + actual: Schema.NullOr(PluginState), + }, +) { + override get message(): string { + return `Cannot transition plugin ${this.pluginId} from ${this.actual ?? "missing"} to ${this.to}.`; + } +} + +export type PluginLockfileStoreError = + | PluginLockfileReadError + | PluginLockfileCorruptError + | PluginLockfileWriteError + | PluginLockfileLockError + | PluginLockfileTransitionError; + +export interface PluginLockfileMutationContext { + readonly lockfile: PluginLockfile; + readonly current: PluginLockfilePlugin | undefined; +} + +export class PluginLockfileStore extends Context.Service< + PluginLockfileStore, + { + readonly lockfilePath: string; + readonly advisoryLockPath: string; + readonly readLockfile: Effect.Effect< + PluginLockfile, + PluginLockfileReadError | PluginLockfileCorruptError + >; + readonly updatePlugin: ( + id: PluginId, + fn: ( + context: PluginLockfileMutationContext, + ) => Effect.Effect, + ) => Effect.Effect; + readonly removePlugin: (id: PluginId) => Effect.Effect; + readonly transition: ( + id: PluginId, + from: ReadonlyArray, + to: PluginState, + ) => Effect.Effect; + } +>()("t3/plugins/PluginLockfileStore") {} + +const isNotFound = (cause: { readonly reason?: { readonly _tag?: string } }) => + cause.reason?._tag === "NotFound"; + +const readLockfileFromPath = (lockfilePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const raw = yield* fs.readFileString(lockfilePath).pipe( + Effect.catch((cause) => + isNotFound(cause) + ? Effect.succeed(null) + : Effect.fail(new PluginLockfileReadError({ path: lockfilePath, cause })), + ), + ); + if (raw === null) return EMPTY_PLUGIN_LOCKFILE; + return yield* decodePluginLockfileJson(raw).pipe( + Effect.mapError( + (cause) => + new PluginLockfileCorruptError({ + path: lockfilePath, + detail: String(cause), + cause, + }), + ), + ); + }); + +const writeLockfileToPath = (input: { + readonly pluginsDir: string; + readonly lockfilePath: string; + readonly lockfile: PluginLockfile; +}) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const encoded = yield* encodePluginLockfileJson(input.lockfile); + const bytes = new TextEncoder().encode(`${encoded}\n`); + + yield* fs.makeDirectory(input.pluginsDir, { recursive: true }); + const tempDir = yield* fs.makeTempDirectoryScoped({ + directory: input.pluginsDir, + prefix: `${path.basename(input.lockfilePath)}.`, + }); + const tempPath = path.join(tempDir, "contents.tmp"); + const file = yield* fs.open(tempPath, { flag: "w", mode: 0o600 }); + yield* file.writeAll(bytes); + yield* file.sync; + yield* fs.rename(tempPath, input.lockfilePath); + yield* fs + .open(input.pluginsDir, { flag: "r" }) + .pipe(Effect.flatMap((directory) => directory.sync), Effect.ignore); + }), + ).pipe( + Effect.mapError( + (cause) => new PluginLockfileWriteError({ path: input.lockfilePath, cause }), + ), + ); + +const acquireAdvisoryLock = (input: { + readonly pluginsDir: string; + readonly advisoryLockPath: string; +}) => + Effect.acquireRelease( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(input.pluginsDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); + + const openLock = Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(input.advisoryLockPath, { flag: "wx", mode: 0o600 }); + yield* file.writeAll( + new TextEncoder().encode(`${process.pid}:${yield* Clock.currentTimeMillis}\n`), + ); + yield* file.sync; + }), + ); + + const opened = yield* openLock.pipe(Effect.result); + if (Result.isSuccess(opened)) return input.advisoryLockPath; + + const stat = yield* fs.stat(input.advisoryLockPath).pipe( + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); + const mtime = Option.getOrUndefined(stat.mtime); + const ageMs = mtime ? (yield* Clock.currentTimeMillis) - mtime.getTime() : 0; + if (ageMs <= STALE_LOCK_MS) { + return yield* new PluginLockfileLockError({ + path: input.advisoryLockPath, + cause: opened.failure, + }); + } + + yield* Effect.logWarning("Reclaiming stale plugin lockfile advisory lock", { + path: input.advisoryLockPath, + ageMs, + }); + yield* fs.remove(input.advisoryLockPath, { force: true }).pipe( + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); + yield* openLock.pipe( + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); + return input.advisoryLockPath; + }), + (lockPath) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.remove(lockPath, { force: true })), + Effect.ignore, + ), + ); + +export const make = Effect.fn("PluginLockfileStore.make")(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const semaphore = yield* Semaphore.make(1); + const lockfilePath = pluginLockfilePath(config.pluginsDir, path.join); + const advisoryLockPath = pluginAdvisoryLockPath(config.pluginsDir, path.join); + const provideLocalServices = ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const readLockfile = provideLocalServices(readLockfileFromPath(lockfilePath)); + + const mutate = ( + update: (lockfile: PluginLockfile) => Effect.Effect, + ) => + provideLocalServices(semaphore.withPermits(1)( + Effect.scoped( + acquireAdvisoryLock({ pluginsDir: config.pluginsDir, advisoryLockPath }).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const current = yield* readLockfile; + const next = yield* update(current); + yield* writeLockfileToPath({ + pluginsDir: config.pluginsDir, + lockfilePath, + lockfile: next, + }); + return next; + }), + ), + ), + ), + )); + + const updatePlugin: PluginLockfileStore["Service"]["updatePlugin"] = (id, fn) => + mutate((lockfile) => + Effect.gen(function* () { + const current = lockfile.plugins[id]; + const nextPlugin = yield* fn({ lockfile, current }); + const plugins = { ...lockfile.plugins }; + if (nextPlugin === undefined) { + delete plugins[id]; + } else { + plugins[id] = nextPlugin; + } + return { ...lockfile, plugins }; + }), + ); + + const removePlugin: PluginLockfileStore["Service"]["removePlugin"] = (id) => + updatePlugin(id, () => Effect.succeed(undefined as PluginLockfilePlugin | undefined)); + + const transition: PluginLockfileStore["Service"]["transition"] = (id, from, to) => + updatePlugin(id, ({ current }) => { + if (!current || !from.includes(current.state)) { + return Effect.fail( + new PluginLockfileTransitionError({ + pluginId: id, + from: Array.from(from), + to, + actual: current?.state ?? null, + }), + ); + } + return Effect.succeed({ ...current, state: to }); + }); + + return PluginLockfileStore.of({ + lockfilePath, + advisoryLockPath, + readLockfile, + updatePlugin, + removePlugin, + transition, + }); +}); + +export const layer = Layer.effect(PluginLockfileStore, make()); diff --git a/apps/server/src/plugins/PluginMigrator.test.ts b/apps/server/src/plugins/PluginMigrator.test.ts new file mode 100644 index 00000000000..a23dee3f0c9 --- /dev/null +++ b/apps/server/src/plugins/PluginMigrator.test.ts @@ -0,0 +1,175 @@ +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts/plugin"; +import type { PluginMigration } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../persistence/Migrations.ts"; +import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import * as PluginMigratorModule from "./PluginMigrator.ts"; +import { PluginMigrationDowngradeError, PluginMigrationViolation } from "./PluginMigrator.ts"; + +const layer = it.layer( + PluginMigratorModule.layer.pipe(Layer.provideMerge(NodeSqliteClient.layerMemory())), +); + +const pluginPrefix = (pluginId: PluginId) => `p_${pluginId.replaceAll("-", "_")}_`; + +const migration = ( + version: number, + name: string, + statements: string | ReadonlyArray, +): PluginMigration => ({ + version, + name, + up: Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + for (const statement of Array.isArray(statements) ? statements : [statements]) { + yield* sql.unsafe(statement).unprepared; + } + }), +}); + +const setup = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 34 }); + return yield* PluginMigratorModule.PluginMigrator; +}); + +layer("PluginMigrator", (it) => { + it.effect("runs migrations once and records applied rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const migrator = yield* setup; + const pluginId = PluginId.make("test-plugin"); + const prefix = pluginPrefix(pluginId); + const migrations = [ + migration(1, "Init", `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`), + ]; + + yield* migrator.run(pluginId, migrations); + yield* migrator.run(pluginId, migrations); + + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_migrations WHERE plugin_id = ${pluginId} + `; + assert.equal(rows[0]?.count, 1); + }), + ); + + it.effect("refuses downgrades when recorded head exceeds provided migrations", () => + Effect.gen(function* () { + const migrator = yield* setup; + const pluginId = PluginId.make("downgrade-plugin"); + const prefix = pluginPrefix(pluginId); + + yield* migrator.run(pluginId, [ + migration(1, "Init", `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`), + migration(2, "Next", `CREATE TABLE ${prefix}more (id TEXT PRIMARY KEY)`), + ]); + + const result = yield* Effect.result( + migrator.run(pluginId, [ + migration(1, "Init", `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`), + ]), + ); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginMigrationDowngradeError); + } + }), + ); + + it.effect("rolls back non-prefixed tables and does not record the row", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const migrator = yield* setup; + const pluginId = PluginId.make("bad-table-plugin"); + + const result = yield* Effect.result( + migrator.run(pluginId, [migration(1, "Bad", "CREATE TABLE bad_items (id TEXT)")]), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginMigrationViolation); + } + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE name = 'bad_items' + `; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_migrations WHERE plugin_id = ${pluginId} + `; + assert.deepEqual(tables, []); + assert.equal(rows[0]?.count, 0); + }), + ); + + it.effect("rejects triggers that reference pre-existing core tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const migrator = yield* setup; + const pluginId = PluginId.make("trigger-plugin"); + const prefix = pluginPrefix(pluginId); + yield* sql`CREATE TABLE core_items (id TEXT PRIMARY KEY)`; + + const result = yield* Effect.result( + migrator.run(pluginId, [ + migration( + 1, + "Trigger", + [ + `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`, + ` + CREATE TRIGGER ${prefix}items_ai + AFTER INSERT ON ${prefix}items + BEGIN + INSERT INTO core_items (id) VALUES (NEW.id); + END + `, + ], + ), + ]), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginMigrationViolation); + } + }), + ); + + it.effect("enforces prefixes for indexes and views", () => + Effect.gen(function* () { + const migrator = yield* setup; + const pluginId = PluginId.make("view-plugin"); + const prefix = pluginPrefix(pluginId); + + yield* migrator.run(pluginId, [ + migration( + 1, + "Index", + [ + `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`, + `CREATE INDEX ${prefix}items_id_idx ON ${prefix}items (id)`, + ], + ), + ]); + + const result = yield* Effect.result( + migrator.run(pluginId, [ + migration( + 2, + "BadView", + `CREATE VIEW bad_items_view AS SELECT id FROM ${prefix}items`, + ), + ]), + ); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginMigrationViolation); + } + }), + ); +}); diff --git a/apps/server/src/plugins/PluginMigrator.ts b/apps/server/src/plugins/PluginMigrator.ts new file mode 100644 index 00000000000..bee4f18ac7a --- /dev/null +++ b/apps/server/src/plugins/PluginMigrator.ts @@ -0,0 +1,224 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { PluginMigration } from "@t3tools/plugin-sdk"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +export class PluginMigrationDowngradeError extends Schema.TaggedErrorClass()( + "PluginMigrationDowngradeError", + { + pluginId: Schema.String, + recordedHead: Schema.Number, + providedHead: Schema.Number, + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has migration head ${this.recordedHead}, but only migrations through ${this.providedHead} were provided.`; + } +} + +export class PluginMigrationViolation extends Schema.TaggedErrorClass()( + "PluginMigrationViolation", + { + pluginId: Schema.String, + version: Schema.Number, + objectName: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} migration ${this.version} violated database namespace rules for ${this.objectName}: ${this.detail}`; + } +} + +export class PluginMigrationOrderError extends Schema.TaggedErrorClass()( + "PluginMigrationOrderError", + { pluginId: Schema.String, detail: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} migration list is invalid: ${this.detail}`; + } +} + +export class PluginMigrationExecutionError extends Schema.TaggedErrorClass()( + "PluginMigrationExecutionError", + { + pluginId: Schema.String, + version: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} migration ${this.version} failed.`; + } +} + +export type PluginMigratorError = + | PluginMigrationDowngradeError + | PluginMigrationViolation + | PluginMigrationOrderError + | PluginMigrationExecutionError + | SqlError; + +interface SqliteMasterObject { + readonly name: string; + readonly type: string; + readonly sql: string | null; +} + +export class PluginMigrator extends Context.Service< + PluginMigrator, + { + readonly run: ( + pluginId: PluginId, + migrations: ReadonlyArray, + ) => Effect.Effect; + } +>()("t3/plugins/PluginMigrator") {} + +const pluginSqlPrefix = (pluginId: string) => `p_${pluginId.replaceAll("-", "_")}_`; + +const sqliteMasterSnapshot = (sql: SqlClient.SqlClient) => + sql` + SELECT name, type, sql + FROM sqlite_master + WHERE type IN ('table', 'index', 'trigger', 'view') + AND name NOT LIKE 'sqlite_%' + ORDER BY type, name + `; + +const objectKey = (entry: SqliteMasterObject) => `${entry.type}:${entry.name}`; + +const changedObjects = ( + before: ReadonlyArray, + after: ReadonlyArray, +) => { + const beforeByKey = new Map(before.map((entry) => [objectKey(entry), entry])); + return after.filter((entry) => beforeByKey.get(objectKey(entry))?.sql !== entry.sql); +}; + +const validateMigrationObjects = (input: { + readonly pluginId: PluginId; + readonly version: number; + readonly prefix: string; + readonly before: ReadonlyArray; + readonly after: ReadonlyArray; +}) => + Effect.gen(function* () { + const preMigrationCoreTables = input.before + .filter((entry) => entry.type === "table" && !entry.name.startsWith(input.prefix)) + .map((entry) => entry.name); + + for (const entry of changedObjects(input.before, input.after)) { + if (!entry.name.startsWith(input.prefix)) { + return yield* new PluginMigrationViolation({ + pluginId: input.pluginId, + version: input.version, + objectName: entry.name, + detail: `object name must start with ${input.prefix}`, + }); + } + if (entry.type !== "trigger" && entry.type !== "view") continue; + const body = entry.sql ?? ""; + for (const tableName of preMigrationCoreTables) { + if (new RegExp(`\\b${tableName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(body)) { + return yield* new PluginMigrationViolation({ + pluginId: input.pluginId, + version: input.version, + objectName: entry.name, + detail: `trigger/view body references core table ${tableName}`, + }); + } + } + } + }); + +const validateMigrationList = (pluginId: PluginId, migrations: ReadonlyArray) => + Effect.gen(function* () { + const versions = new Set(); + for (const migration of migrations) { + if (!Number.isInteger(migration.version) || migration.version <= 0) { + return yield* new PluginMigrationOrderError({ + pluginId, + detail: `version ${migration.version} must be a positive integer`, + }); + } + if (versions.has(migration.version)) { + return yield* new PluginMigrationOrderError({ + pluginId, + detail: `duplicate version ${migration.version}`, + }); + } + versions.add(migration.version); + } + }); + +export const make = Effect.fn("PluginMigrator.make")(function* () { + const sql = yield* SqlClient.SqlClient; + + const run: PluginMigrator["Service"]["run"] = (pluginId, migrations) => + Effect.gen(function* () { + yield* validateMigrationList(pluginId, migrations); + const sorted = Array.from(migrations).sort((left, right) => left.version - right.version); + const providedHead = sorted.at(-1)?.version ?? 0; + const rows = yield* sql<{ readonly version: number | null }>` + SELECT MAX(version) AS version + FROM plugin_migrations + WHERE plugin_id = ${pluginId} + `; + const recordedHead = rows[0]?.version ?? 0; + if (recordedHead > providedHead) { + return yield* new PluginMigrationDowngradeError({ + pluginId, + recordedHead, + providedHead, + }); + } + + const prefix = pluginSqlPrefix(pluginId); + for (const migration of sorted) { + if (migration.version <= recordedHead) continue; + yield* sql.withTransaction( + Effect.gen(function* () { + const before = yield* sqliteMasterSnapshot(sql); + yield* migration.up.pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.mapError( + (cause) => + new PluginMigrationExecutionError({ + pluginId, + version: migration.version, + cause, + }), + ), + ); + const after = yield* sqliteMasterSnapshot(sql); + yield* validateMigrationObjects({ + pluginId, + version: migration.version, + prefix, + before, + after, + }); + yield* sql` + INSERT INTO plugin_migrations (plugin_id, version, name, applied_at) + VALUES ( + ${pluginId}, + ${migration.version}, + ${migration.name}, + ${DateTime.formatIso(yield* DateTime.now)} + ) + `; + }), + ); + } + }); + + return PluginMigrator.of({ run }); +}); + +export const layer = Layer.effect(PluginMigrator, make()); diff --git a/apps/server/src/plugins/PluginModuleLoader.ts b/apps/server/src/plugins/PluginModuleLoader.ts new file mode 100644 index 00000000000..6cc09f5b137 --- /dev/null +++ b/apps/server/src/plugins/PluginModuleLoader.ts @@ -0,0 +1,133 @@ +import type { PluginDefinition } from "@t3tools/plugin-sdk"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { pathToFileURL } from "node:url"; + +import * as ServerConfig from "../config.ts"; + +export class PluginModuleLoadError extends Schema.TaggedErrorClass()( + "PluginModuleLoadError", + { pluginDir: Schema.String, entry: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not load plugin server entry ${this.entry} from ${this.pluginDir}.`; + } +} + +export class PluginModulePathError extends Schema.TaggedErrorClass()( + "PluginModulePathError", + { pluginDir: Schema.String, entry: Schema.String, resolvedPath: Schema.String }, +) { + override get message(): string { + return `Plugin server entry ${this.entry} resolves outside ${this.pluginDir}.`; + } +} + +export class PluginModuleShapeError extends Schema.TaggedErrorClass()( + "PluginModuleShapeError", + { pluginDir: Schema.String, entry: Schema.String }, +) { + override get message(): string { + return `Plugin server entry ${this.entry} does not default-export a definePlugin-shaped object.`; + } +} + +export type PluginModuleLoaderError = + | PluginModuleLoadError + | PluginModulePathError + | PluginModuleShapeError; + +export class PluginModuleLoader extends Context.Service< + PluginModuleLoader, + { + readonly ensureHostSingletonResolution: Effect.Effect; + readonly loadServerEntry: ( + pluginDir: string, + entryRelPath: string, + ) => Effect.Effect; + } +>()("t3/plugins/PluginModuleLoader") {} + +let hostResolutionHookRegistered = false; + +function isPluginDefinition(value: unknown): value is PluginDefinition { + return ( + typeof value === "object" && + value !== null && + "register" in value && + typeof (value as { readonly register?: unknown }).register === "function" + ); +} + +function isInside(parent: string, child: string, separator: string): boolean { + return child === parent || child.startsWith(parent.endsWith(separator) ? parent : `${parent}${separator}`); +} + +export const make = Effect.fn("PluginModuleLoader.make")(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const ensureHostSingletonResolution = Effect.gen(function* () { + if (hostResolutionHookRegistered) return; + hostResolutionHookRegistered = true; + const nodeModule = yield* Effect.promise(() => import("node:module")); + if (typeof nodeModule.register !== "function") { + yield* Effect.logWarning( + "Node module.register is unavailable; plugin host singleton resolution is disabled", + ); + return; + } + yield* Effect.sync(() => + nodeModule.register(new URL("./pluginResolveHooks.ts", import.meta.url), { + parentURL: import.meta.url, + data: { + pluginsRootUrl: pathToFileURL(config.pluginsDir).href, + }, + }), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to register plugin host singleton resolution hook", { + cause, + }), + ), + ); + }); + + const loadServerEntry: PluginModuleLoader["Service"]["loadServerEntry"] = ( + pluginDir, + entryRelPath, + ) => + Effect.gen(function* () { + const realPluginDir = yield* fs.realPath(pluginDir).pipe( + Effect.mapError((cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause })), + ); + const resolvedEntry = path.resolve(pluginDir, entryRelPath); + const realEntry = yield* fs.realPath(resolvedEntry).pipe( + Effect.mapError((cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause })), + ); + if (!isInside(realPluginDir, realEntry, path.sep)) { + return yield* new PluginModulePathError({ + pluginDir, + entry: entryRelPath, + resolvedPath: realEntry, + }); + } + const imported = yield* Effect.tryPromise({ + try: () => import(pathToFileURL(realEntry).href), + catch: (cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause }), + }); + if (!isPluginDefinition(imported.default)) { + return yield* new PluginModuleShapeError({ pluginDir, entry: entryRelPath }); + } + return imported.default; + }); + + return PluginModuleLoader.of({ ensureHostSingletonResolution, loadServerEntry }); +}); + +export const layer = Layer.effect(PluginModuleLoader, make()); diff --git a/apps/server/src/plugins/PluginPaths.ts b/apps/server/src/plugins/PluginPaths.ts new file mode 100644 index 00000000000..5db2886d6f8 --- /dev/null +++ b/apps/server/src/plugins/PluginPaths.ts @@ -0,0 +1,32 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; + +export const pluginsRoot = (stateDir: string, join: (...segments: ReadonlyArray) => string) => + join(stateDir, "plugins"); + +export const pluginVersionDir = ( + root: string, + id: PluginId | string, + version: string, + join: (...segments: ReadonlyArray) => string, +) => join(root, id, version); + +export const pluginDataDir = ( + root: string, + id: PluginId | string, + join: (...segments: ReadonlyArray) => string, +) => join(root, id, "data"); + +export const pluginManifestPath = ( + pluginDir: string, + join: (...segments: ReadonlyArray) => string, +) => join(pluginDir, "manifest.json"); + +export const pluginLockfilePath = ( + root: string, + join: (...segments: ReadonlyArray) => string, +) => join(root, "plugins.json"); + +export const pluginAdvisoryLockPath = ( + root: string, + join: (...segments: ReadonlyArray) => string, +) => join(root, "plugins.json.lock"); diff --git a/apps/server/src/plugins/PluginRuntimeRegistry.ts b/apps/server/src/plugins/PluginRuntimeRegistry.ts new file mode 100644 index 00000000000..73fe01aab60 --- /dev/null +++ b/apps/server/src/plugins/PluginRuntimeRegistry.ts @@ -0,0 +1,55 @@ +import type { PluginId, PluginManifest } from "@t3tools/contracts/plugin"; +import type { PluginRegistration } from "@t3tools/plugin-sdk"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; + +export interface ActivePluginRuntime { + readonly manifest: PluginManifest; + readonly registration: PluginRegistration; + readonly readiness: Deferred.Deferred; + readonly scope: Scope.Scope; +} + +export class PluginRuntimeRegistry extends Context.Service< + PluginRuntimeRegistry, + { + readonly put: ( + pluginId: PluginId, + runtime: ActivePluginRuntime, + ) => Effect.Effect; + readonly remove: (pluginId: PluginId) => Effect.Effect; + readonly list: Effect.Effect>; + readonly get: (pluginId: PluginId) => Effect.Effect>; + } +>()("t3/plugins/PluginRuntimeRegistry") {} + +export const make = Effect.fn("PluginRuntimeRegistry.make")(function* () { + const runtimes = yield* Ref.make(new Map()); + + return PluginRuntimeRegistry.of({ + put: (pluginId, runtime) => + Ref.update(runtimes, (current) => { + const next = new Map(current); + next.set(pluginId, runtime); + return next; + }), + remove: (pluginId) => + Ref.update(runtimes, (current) => { + const next = new Map(current); + next.delete(pluginId); + return next; + }), + list: Ref.get(runtimes).pipe(Effect.map((current) => Array.from(current.values()))), + get: (pluginId) => + Ref.get(runtimes).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(pluginId))), + ), + }); +}); + +export const layer = Layer.effect(PluginRuntimeRegistry, make()); diff --git a/apps/server/src/plugins/pluginResolveHooks.ts b/apps/server/src/plugins/pluginResolveHooks.ts new file mode 100644 index 00000000000..b3a1f9de368 --- /dev/null +++ b/apps/server/src/plugins/pluginResolveHooks.ts @@ -0,0 +1,36 @@ +let pluginsRootUrl = ""; + +export function initialize(data: unknown) { + if (data && typeof data === "object" && "pluginsRootUrl" in data) { + const value = (data as { readonly pluginsRootUrl?: unknown }).pluginsRootUrl; + if (typeof value === "string") { + pluginsRootUrl = value.endsWith("/") ? value : `${value}/`; + } + } +} + +function shouldResolveFromHost(specifier: string, parentURL: string | undefined): boolean { + if (!parentURL || !pluginsRootUrl || !parentURL.startsWith(pluginsRootUrl)) return false; + return ( + specifier === "effect" || + specifier.startsWith("effect/") || + specifier === "@t3tools/plugin-sdk" + ); +} + +export async function resolve( + specifier: string, + context: { readonly parentURL?: string | undefined }, + nextResolve: ( + specifier: string, + context: { readonly parentURL?: string | undefined }, + ) => Promise, +) { + if (shouldResolveFromHost(specifier, context.parentURL)) { + return { + shortCircuit: true, + url: import.meta.resolve(specifier), + }; + } + return nextResolve(specifier, context); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c632d8486c..a20d859c09a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -32,6 +32,11 @@ import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; +import * as PluginMigrator from "./plugins/PluginMigrator.ts"; +import * as PluginModuleLoader from "./plugins/PluginModuleLoader.ts"; +import * as PluginRuntimeRegistry from "./plugins/PluginRuntimeRegistry.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; @@ -183,6 +188,13 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const PluginLayerLive = PluginHost.layer.pipe( + Layer.provideMerge(PluginLockfileStore.layer), + Layer.provideMerge(PluginModuleLoader.layer), + Layer.provideMerge(PluginMigrator.layer), + Layer.provideMerge(PluginRuntimeRegistry.layer), +); + const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -284,7 +296,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), @@ -293,6 +305,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), + Layer.provideMerge(PluginLayerLive), +); + +const RuntimeCoreDependenciesLive = RuntimeCoreBaseDependenciesLive.pipe( Layer.provideMerge(Keybindings.layer), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5..0f4b8dee939 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -293,6 +294,7 @@ export const make = Effect.gen(function* () { const keybindings = yield* Keybindings.Keybindings; const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; + const pluginHost = yield* PluginHost.PluginHost; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; @@ -430,6 +432,8 @@ export const make = Effect.gen(function* () { yield* Effect.logDebug("Accepting commands"); yield* commandGate.signalCommandReady; + yield* Effect.logDebug("startup phase: starting plugin host"); + yield* runStartupPhase("plugins.start", pluginHost.start); yield* Effect.logDebug("startup phase: waiting for http listener"); yield* runStartupPhase("http.wait", Deferred.await(httpListening)); yield* Effect.logDebug("startup phase: publishing ready event"); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e1acf1e948e..0d8850a2674 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -18,6 +18,10 @@ "./relay": { "types": "./src/relay.ts", "import": "./src/relay.ts" + }, + "./plugin": { + "types": "./src/plugin.ts", + "import": "./src/plugin.ts" } }, "scripts": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..b08debc2559 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -26,3 +26,4 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./rpc.ts"; +export * from "./plugin.ts"; diff --git a/packages/contracts/src/plugin.test.ts b/packages/contracts/src/plugin.test.ts new file mode 100644 index 00000000000..d41ae6cc1f2 --- /dev/null +++ b/packages/contracts/src/plugin.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + HOST_API_VERSION, + PluginLockfile, + PluginManifest, + hostApiSatisfies, +} from "./plugin.ts"; + +const decodeManifest = Schema.decodeUnknownSync(PluginManifest); +const decodeLockfile = Schema.decodeUnknownSync(PluginLockfile); +const encodeLockfile = Schema.encodeSync(PluginLockfile); + +const minimalManifest = { + id: "test-plugin", + name: "Test Plugin", + version: "1.2.3", + hostApi: "^1.0.0", + entries: { server: "server.js" }, +}; + +describe("PluginManifest", () => { + it("decodes a minimal server manifest and defaults capabilities", () => { + const decoded = decodeManifest(minimalManifest); + expect(decoded.id).toBe("test-plugin"); + expect(decoded.capabilities).toEqual([]); + }); + + it("decodes a full manifest", () => { + const decoded = decodeManifest({ + ...minimalManifest, + name: " Test Plugin ", + description: "Adds test plugin behavior.", + author: { name: "T3", url: "https://example.test" }, + homepage: "https://example.test/plugin", + license: "MIT", + minAppVersion: "1.0.0", + capabilities: ["agents", "database"], + entries: { server: "dist/server.js", web: "dist/web.js" }, + }); + expect(decoded.name).toBe("Test Plugin"); + expect(decoded.capabilities).toEqual(["agents", "database"]); + }); + + it.each(["x", "1test-plugin", "test_plugin", "Test-Plugin", "a".repeat(42)])( + "rejects invalid plugin id %s", + (id) => { + expect(() => decodeManifest({ ...minimalManifest, id })).toThrow(); + }, + ); + + it("rejects unknown capabilities", () => { + expect(() => + decodeManifest({ ...minimalManifest, capabilities: ["not-real"] }), + ).toThrow(); + }); + + it("rejects duplicate capabilities", () => { + expect(() => + decodeManifest({ ...minimalManifest, capabilities: ["agents", "agents"] }), + ).toThrow(); + }); + + it("rejects unknown top-level fields", () => { + expect(() => decodeManifest({ ...minimalManifest, surprise: true })).toThrow(); + }); + + it("rejects manifests without server or web entries", () => { + expect(() => decodeManifest({ ...minimalManifest, entries: {} })).toThrow(); + }); + + it("rejects web-only manifests with server capabilities", () => { + expect(() => + decodeManifest({ + ...minimalManifest, + capabilities: ["agents"], + entries: { web: "web.js" }, + }), + ).toThrow(); + }); + + it("rejects unsafe entry paths", () => { + expect(() => decodeManifest({ ...minimalManifest, entries: { server: "../server.js" } })).toThrow(); + expect(() => decodeManifest({ ...minimalManifest, entries: { server: "/server.js" } })).toThrow(); + }); + + it("rejects bad hostApi ranges", () => { + expect(() => decodeManifest({ ...minimalManifest, hostApi: ">=1.0.0" })).toThrow(); + }); +}); + +describe("hostApiSatisfies", () => { + it("matches exact, caret, and tilde ranges", () => { + expect(hostApiSatisfies("1.0.0", HOST_API_VERSION)).toBe(true); + expect(hostApiSatisfies("^1.0.0", "1.9.9")).toBe(true); + expect(hostApiSatisfies("~1.0.0", "1.0.5")).toBe(true); + }); + + it("rejects versions outside the supported range", () => { + expect(hostApiSatisfies("1.0.0", "1.0.1")).toBe(false); + expect(hostApiSatisfies("^1.0.0", "2.0.0")).toBe(false); + expect(hostApiSatisfies("~1.0.0", "1.1.0")).toBe(false); + expect(hostApiSatisfies("^1.2.0", "1.1.9")).toBe(false); + }); +}); + +describe("PluginLockfile", () => { + it("round-trips a decoded lockfile", () => { + const decoded = decodeLockfile({ + sources: [{ id: "local", url: "file:///plugins", addedAt: "2026-07-03T00:00:00.000Z" }], + plugins: { + "test-plugin": { + version: "1.2.3", + sha256: "abc123", + sourceId: "local", + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }, + }, + }); + + expect(encodeLockfile(decoded)).toEqual(decoded); + }); +}); diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts new file mode 100644 index 00000000000..8111288528a --- /dev/null +++ b/packages/contracts/src/plugin.ts @@ -0,0 +1,204 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { IsoDateTime, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; + +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const HOST_API_RANGE_PATTERN = /^[~^]?\d+\.\d+\.\d+$/; +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]{1,40}$/; + +export const HOST_API_VERSION = "1.0.0"; + +export const PluginId = TrimmedString.check(Schema.isPattern(PLUGIN_ID_PATTERN)).pipe( + Schema.brand("PluginId"), +); +export type PluginId = typeof PluginId.Type; + +export const PluginCapability = Schema.Literals([ + "agents", + "vcs", + "terminals", + "database", + "projections.read", + "environments.read", + "secrets", + "http", + "sourceControl", + "textGeneration", +]); +export type PluginCapability = typeof PluginCapability.Type; + +const SemverString = TrimmedNonEmptyString.check(Schema.isPattern(SEMVER_PATTERN)); +const HostApiRange = TrimmedNonEmptyString.check(Schema.isPattern(HOST_API_RANGE_PATTERN)); +const OptionalUrl = Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(2048))); + +const RelativeEntryPath = TrimmedNonEmptyString.check( + Schema.makeFilter((entryPath) => { + if (entryPath.startsWith("/") || entryPath.startsWith("\\")) { + return "entry paths must be relative"; + } + if (entryPath.split(/[\\/]/).includes("..")) { + return "entry paths may not contain '..' segments"; + } + return true; + }), +); + +const ManifestEntries = Schema.Struct({ + server: Schema.optionalKey(RelativeEntryPath), + web: Schema.optionalKey(RelativeEntryPath), +}).check( + Schema.makeFilter<{ readonly server?: string; readonly web?: string }>((entries) => + entries.server || entries.web ? true : "manifest entries must include server or web", + ), +); +export type PluginManifestEntries = typeof ManifestEntries.Type; + +const PluginAuthor = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + url: OptionalUrl, +}); +export type PluginAuthor = typeof PluginAuthor.Type; + +const PluginCapabilities = Schema.Array(PluginCapability) + .check( + Schema.makeFilter>((capabilities) => + new Set(capabilities).size === capabilities.length ? true : "capabilities must be unique", + ), + ) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))); + +interface PluginManifestShape { + readonly id: PluginId; + readonly name: string; + readonly version: string; + readonly description?: string | undefined; + readonly author?: PluginAuthor | undefined; + readonly homepage?: string | undefined; + readonly license?: string | undefined; + readonly hostApi: string; + readonly minAppVersion?: string | undefined; + readonly capabilities: ReadonlyArray; + readonly entries: PluginManifestEntries; +} + +export const PluginManifest = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + version: SemverString, + description: Schema.optionalKey(TrimmedString.check(Schema.isMaxLength(500))), + author: Schema.optionalKey(PluginAuthor), + homepage: OptionalUrl, + license: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(128))), + hostApi: HostApiRange, + minAppVersion: Schema.optionalKey(SemverString), + capabilities: PluginCapabilities, + entries: ManifestEntries, +}) + .check( + Schema.makeFilter((manifest) => { + if (!manifest.entries.server && manifest.capabilities.length > 0) { + return { + path: ["capabilities"], + issue: "web-only plugins may not declare server capabilities", + }; + } + return true; + }), + ) + .annotate({ parseOptions: { onExcessProperty: "error" } }); +export type PluginManifest = typeof PluginManifest.Type; + +export const PluginState = Schema.Literals([ + "active", + "pending-remove", + "pending-upgrade", + "failed", + "disabled", + "disabled-by-host", +]); +export type PluginState = typeof PluginState.Type; + +const LockfileSource = Schema.Struct({ + id: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + addedAt: IsoDateTime, +}); +export type PluginLockfileSource = typeof LockfileSource.Type; + +const LockfilePlugin = Schema.Struct({ + version: SemverString, + sha256: TrimmedNonEmptyString, + sourceId: TrimmedNonEmptyString, + enabled: Schema.Boolean, + state: PluginState, + staged: Schema.optionalKey( + Schema.Struct({ + version: SemverString, + sha256: TrimmedNonEmptyString, + stagedAt: IsoDateTime, + }), + ), + activation: Schema.Struct({ + activatingSince: Schema.NullOr(IsoDateTime), + crashCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + }), + installedAt: IsoDateTime, + lastError: Schema.NullOr(Schema.String), +}); +export type PluginLockfilePlugin = typeof LockfilePlugin.Type; + +export const PluginLockfile = Schema.Struct({ + sources: Schema.Array(LockfileSource), + plugins: Schema.Record(PluginId, LockfilePlugin), +}); +export type PluginLockfile = typeof PluginLockfile.Type; + +export const EMPTY_PLUGIN_LOCKFILE: PluginLockfile = { + sources: [], + plugins: {}, +}; + +interface ParsedSemver { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +function parseStrictSemver(value: string): ParsedSemver | null { + const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)$/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function compareSemver(left: ParsedSemver, right: ParsedSemver): number { + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + return left.patch - right.patch; +} + +export function hostApiSatisfies(range: string, version: string): boolean { + const trimmedRange = range.trim(); + const operator = trimmedRange.startsWith("^") || trimmedRange.startsWith("~") + ? trimmedRange[0] + : ""; + const target = parseStrictSemver(operator ? trimmedRange.slice(1) : trimmedRange); + const actual = parseStrictSemver(version); + if (!target || !actual) return false; + + const compared = compareSemver(actual, target); + if (operator === "") return compared === 0; + if (compared < 0) return false; + + if (operator === "^") { + if (target.major > 0) return actual.major === target.major; + if (target.minor > 0) return actual.major === 0 && actual.minor === target.minor; + return actual.major === 0 && actual.minor === 0 && actual.patch === target.patch; + } + + return actual.major === target.major && actual.minor === target.minor; +} diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json new file mode 100644 index 00000000000..a6d915a09f0 --- /dev/null +++ b/packages/plugin-sdk/package.json @@ -0,0 +1,19 @@ +{ + "name": "@t3tools/plugin-sdk", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "vp test run" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "effect": "catalog:" + } +} diff --git a/packages/plugin-sdk/src/index.test.ts b/packages/plugin-sdk/src/index.test.ts new file mode 100644 index 00000000000..36521a15e0e --- /dev/null +++ b/packages/plugin-sdk/src/index.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Effect from "effect/Effect"; + +import { definePlugin, HOST_API_VERSION } from "./index.ts"; + +describe("definePlugin", () => { + it("preserves the plugin definition shape", () => { + const definition = definePlugin({ + register: () => Effect.succeed({ rpc: [] }), + }); + + expect(typeof definition.register).toBe("function"); + expect(HOST_API_VERSION).toBe("1.0.0"); + }); +}); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts new file mode 100644 index 00000000000..55adc2bb1d9 --- /dev/null +++ b/packages/plugin-sdk/src/index.ts @@ -0,0 +1,160 @@ +import type * as Effect from "effect/Effect"; +import type * as SqlClient from "effect/unstable/sql/SqlClient"; + +export type { + PluginCapability, + PluginId, + PluginLockfile, + PluginLockfilePlugin, + PluginLockfileSource, + PluginManifest, + PluginManifestEntries, + PluginState, +} from "@t3tools/contracts/plugin"; +export { HOST_API_VERSION, hostApiSatisfies } from "@t3tools/contracts/plugin"; + +export type PluginRpcScope = "read" | "operate"; +export type PluginReadiness = "requires-ready" | "always"; + +export interface PluginLogger { + readonly debug: (message: string, attributes?: Record) => Effect.Effect; + readonly info: (message: string, attributes?: Record) => Effect.Effect; + readonly warn: (message: string, attributes?: Record) => Effect.Effect; + readonly error: (message: string, attributes?: Record) => Effect.Effect; +} + +export interface PluginHostConfig { + readonly appVersion: string; + readonly hostApiVersion: string; + readonly dataDir: string; + readonly logger: PluginLogger; +} + +export interface PluginCapabilityUnavailable { + readonly _tag: "PluginCapabilityUnavailable"; + readonly capability: string; + readonly message: string; +} + +export interface AgentsCapability { + readonly list: Effect.Effect>; +} + +export interface VcsCapability { + readonly status: (input: { readonly cwd: string }) => Effect.Effect; +} + +export interface TerminalsCapability { + readonly open: (input: unknown) => Effect.Effect; +} + +export interface DatabaseCapability { + readonly sql: SqlClient.SqlClient; +} + +export interface ProjectionsReadCapability { + readonly getSnapshot: (input: unknown) => Effect.Effect; +} + +export interface EnvironmentsReadCapability { + readonly list: Effect.Effect>; +} + +export interface SecretsCapability { + readonly get: (name: string) => Effect.Effect; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; +} + +export interface HttpCapability { + readonly baseUrl: string | null; +} + +export interface SourceControlCapability { + readonly listPullRequests: (input: unknown) => Effect.Effect>; +} + +export interface TextGenerationCapability { + readonly generateText: (input: unknown) => Effect.Effect; +} + +export interface PluginHostApi { + readonly hostApiVersion: string; + readonly config: PluginHostConfig; + readonly agents: Effect.Effect; + readonly vcs: Effect.Effect; + readonly terminals: Effect.Effect; + readonly database: Effect.Effect; + readonly projectionsRead: Effect.Effect; + readonly environmentsRead: Effect.Effect< + EnvironmentsReadCapability, + PluginCapabilityUnavailable + >; + readonly secrets: Effect.Effect; + readonly http: Effect.Effect; + readonly sourceControl: Effect.Effect; + readonly textGeneration: Effect.Effect; +} + +export interface PluginRpcContext { + readonly pluginId: string; + readonly logger: PluginLogger; +} + +export interface PluginRpcDescriptor { + readonly method: string; + readonly scope: PluginRpcScope; + readonly readiness?: PluginReadiness | undefined; + readonly handler: (payload: unknown, ctx: PluginRpcContext) => Effect.Effect; +} + +export interface PluginStreamDescriptor { + readonly method: string; + readonly scope: PluginRpcScope; + readonly readiness?: PluginReadiness | undefined; + readonly handler: (payload: unknown, ctx: PluginRpcContext) => Effect.Effect; +} + +export interface PluginHttpDescriptor { + readonly method: string; + readonly path: string; + readonly auth: "public" | "token"; + readonly handler: (request: unknown, ctx: PluginRpcContext) => Effect.Effect; +} + +export interface PluginServiceContext { + readonly pluginId: string; + readonly logger: PluginLogger; +} + +export interface PluginServiceDescriptor { + readonly name: string; + readonly run: (ctx: PluginServiceContext) => Effect.Effect; +} + +export interface PluginMigration { + readonly version: number; + readonly name: string; + readonly up: Effect.Effect; +} + +export interface PluginRegistration { + readonly migrations?: ReadonlyArray | undefined; + readonly recover?: (() => Effect.Effect) | undefined; + readonly rpc?: ReadonlyArray | undefined; + readonly streams?: ReadonlyArray | undefined; + readonly http?: ReadonlyArray | undefined; + readonly services?: ReadonlyArray | undefined; +} + +export interface PluginDefinition { + readonly register: + | ((hostApi: PluginHostApi) => Effect.Effect) + | ((hostApi: PluginHostApi) => Promise) + | ((hostApi: PluginHostApi) => PluginRegistration); +} + +export function definePlugin( + definition: Definition, +): Definition { + return definition; +} diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json new file mode 100644 index 00000000000..73a306f847a --- /dev/null +++ b/packages/plugin-sdk/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {}, + "include": ["src"] +} From 3e9c289de8821c5d4fc3ec07ac10bc625905946f Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 04:06:50 -0400 Subject: [PATCH 04/75] Address slice-2a-1 code review findings Migration gate hardening (Claude + Grok reviews): - Detect DROPPED objects: a migration removing any object outside the plugin namespace is now a violation (previously invisible - the diff only inspected the after-snapshot) - Reject ATTACH DATABASE (PRAGMA database_list, ignoring built-in main/temp) with best-effort DETACH so a rogue attach cannot persist on the shared connection - Reject newly created TEMP objects (before/after sqlite_temp_master diff so pre-existing temp state cannot false-positive) - Empty migration list is a no-op, not a downgrade - Tests: drop-core, rename-out-of-namespace, ATTACH, TEMP, empty-list Also: - Loader resolves entries against the realpath'd plugin dir - APP_VERSION from package.json (was a hardcoded literal) - Service restart backoff capped at 30s (Schedule.either) Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/plugins/PluginHost.ts | 9 +- .../server/src/plugins/PluginMigrator.test.ts | 104 ++++++++++++++++++ apps/server/src/plugins/PluginMigrator.ts | 62 +++++++++++ apps/server/src/plugins/PluginModuleLoader.ts | 2 +- 4 files changed, 174 insertions(+), 3 deletions(-) diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 4969546f598..a772303c817 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -28,6 +28,7 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; import { PluginMigrator } from "./PluginMigrator.ts"; @@ -39,7 +40,7 @@ import { } from "./PluginPaths.ts"; import { PluginRuntimeRegistry } from "./PluginRuntimeRegistry.ts"; -const APP_VERSION = "0.0.28"; +const APP_VERSION = packageJson.version; const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); const healthyActivationDelay = () => { @@ -207,7 +208,11 @@ const startService = (input: { cause: Cause.pretty(cause), }), ), - Effect.repeat(Schedule.exponential("250 millis")), + // Exponential backoff capped at 30s so a flapping service keeps + // retrying at a bounded cadence instead of backing off forever. + Effect.repeat( + Schedule.either(Schedule.exponential("250 millis"), Schedule.spaced("30 seconds")), + ), ); export const make = Effect.fn("PluginHost.make")(function* () { diff --git a/apps/server/src/plugins/PluginMigrator.test.ts b/apps/server/src/plugins/PluginMigrator.test.ts index a23dee3f0c9..92a6699450b 100644 --- a/apps/server/src/plugins/PluginMigrator.test.ts +++ b/apps/server/src/plugins/PluginMigrator.test.ts @@ -140,6 +140,110 @@ layer("PluginMigrator", (it) => { }), ); + it.effect("rejects migrations that drop objects outside the plugin namespace", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const migrator = yield* setup; + const pluginId = PluginId.make("drop-plugin"); + yield* sql`CREATE TABLE core_victim (id TEXT PRIMARY KEY)`; + + const result = yield* Effect.result( + migrator.run(pluginId, [migration(1, "Drop", "DROP TABLE core_victim")]), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginMigrationViolation); + } + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE name = 'core_victim' + `; + assert.equal(tables.length, 1); + }), + ); + + it.effect("rejects migrations that rename a core table into the plugin namespace", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const migrator = yield* setup; + const pluginId = PluginId.make("rename-plugin"); + const prefix = pluginPrefix(pluginId); + yield* sql`CREATE TABLE core_renamed (id TEXT PRIMARY KEY)`; + + const result = yield* Effect.result( + migrator.run(pluginId, [ + migration(1, "Rename", `ALTER TABLE core_renamed RENAME TO ${prefix}stolen`), + ]), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginMigrationViolation); + } + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE name = 'core_renamed' + `; + assert.equal(tables.length, 1); + }), + ); + + it.effect("rejects ATTACH DATABASE in plugin migrations", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const migrator = yield* setup; + const pluginId = PluginId.make("attach-plugin"); + + // SQLite itself refuses ATTACH inside the migration transaction, so + // this surfaces as a migration failure; the PRAGMA database_list gate + // additionally covers a migration that breaks out of the transaction. + const result = yield* Effect.result( + migrator.run(pluginId, [ + migration(1, "Attach", [ + "ATTACH DATABASE ':memory:' AS escape_hatch", + "CREATE TABLE escape_hatch.evil (id TEXT)", + ]), + ]), + ); + + assert.isTrue(Result.isFailure(result)); + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_migrations WHERE plugin_id = ${pluginId} + `; + assert.equal(rows[0]?.count, 0); + }), + ); + + it.effect("rejects TEMP objects in plugin migrations", () => + Effect.gen(function* () { + const migrator = yield* setup; + const pluginId = PluginId.make("temp-plugin"); + + const result = yield* Effect.result( + migrator.run(pluginId, [ + migration(1, "Temp", "CREATE TEMP TABLE sneaky (id TEXT)"), + ]), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginMigrationViolation); + } + }), + ); + + it.effect("treats an empty migration list as a no-op even with recorded history", () => + Effect.gen(function* () { + const migrator = yield* setup; + const pluginId = PluginId.make("empty-plugin"); + const prefix = pluginPrefix(pluginId); + + yield* migrator.run(pluginId, [ + migration(1, "Init", `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`), + ]); + yield* migrator.run(pluginId, []); + }), + ); + it.effect("enforces prefixes for indexes and views", () => Effect.gen(function* () { const migrator = yield* setup; diff --git a/apps/server/src/plugins/PluginMigrator.ts b/apps/server/src/plugins/PluginMigrator.ts index bee4f18ac7a..dcbe7ed02d0 100644 --- a/apps/server/src/plugins/PluginMigrator.ts +++ b/apps/server/src/plugins/PluginMigrator.ts @@ -101,6 +101,14 @@ const changedObjects = ( return after.filter((entry) => beforeByKey.get(objectKey(entry))?.sql !== entry.sql); }; +const removedObjects = ( + before: ReadonlyArray, + after: ReadonlyArray, +) => { + const afterKeys = new Set(after.map(objectKey)); + return before.filter((entry) => !afterKeys.has(objectKey(entry))); +}; + const validateMigrationObjects = (input: { readonly pluginId: PluginId; readonly version: number; @@ -113,6 +121,20 @@ const validateMigrationObjects = (input: { .filter((entry) => entry.type === "table" && !entry.name.startsWith(input.prefix)) .map((entry) => entry.name); + // Dropping (or renaming away) an object the plugin does not own is a + // violation: a dropped object never appears in the after-snapshot, so it + // must be detected from the before side. + for (const entry of removedObjects(input.before, input.after)) { + if (!entry.name.startsWith(input.prefix)) { + return yield* new PluginMigrationViolation({ + pluginId: input.pluginId, + version: input.version, + objectName: entry.name, + detail: "migration removed an object outside the plugin namespace", + }); + } + } + for (const entry of changedObjects(input.before, input.after)) { if (!entry.name.startsWith(input.prefix)) { return yield* new PluginMigrationViolation({ @@ -162,6 +184,9 @@ export const make = Effect.fn("PluginMigrator.make")(function* () { const run: PluginMigrator["Service"]["run"] = (pluginId, migrations) => Effect.gen(function* () { + // No migrations means nothing to run — not a downgrade (a plugin may + // legitimately ship no migrations even after earlier versions did). + if (migrations.length === 0) return; yield* validateMigrationList(pluginId, migrations); const sorted = Array.from(migrations).sort((left, right) => left.version - right.version); const providedHead = sorted.at(-1)?.version ?? 0; @@ -185,6 +210,9 @@ export const make = Effect.fn("PluginMigrator.make")(function* () { yield* sql.withTransaction( Effect.gen(function* () { const before = yield* sqliteMasterSnapshot(sql); + const tempBefore = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_temp_master + `.pipe(Effect.orElseSucceed(() => [])); yield* migration.up.pipe( Effect.provideService(SqlClient.SqlClient, sql), Effect.mapError( @@ -197,6 +225,40 @@ export const make = Effect.fn("PluginMigrator.make")(function* () { ), ); const after = yield* sqliteMasterSnapshot(sql); + // ATTACH and TEMP objects live outside the main sqlite_master + // snapshot, so the diff gate cannot see them — forbid them + // outright rather than pretend they are covered. + // database_list always reports "main" (and "temp" once the temp + // schema exists); anything else is an ATTACHed database. + const databases = yield* sql<{ readonly name: string }>`PRAGMA database_list`; + const attached = databases.find( + (database) => database.name !== "main" && database.name !== "temp", + ); + if (attached) { + // Best-effort DETACH so a rogue attach cannot persist on the + // shared connection past this violation. + yield* sql.unsafe(`DETACH DATABASE "${attached.name.replaceAll('"', '""')}"`) + .unprepared.pipe(Effect.ignore); + return yield* new PluginMigrationViolation({ + pluginId, + version: migration.version, + objectName: attached.name, + detail: "ATTACH DATABASE is not permitted in plugin migrations", + }); + } + const tempAfter = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_temp_master + `.pipe(Effect.orElseSucceed(() => [])); + const tempBeforeNames = new Set(tempBefore.map((row) => row.name)); + const newTempObject = tempAfter.find((row) => !tempBeforeNames.has(row.name)); + if (newTempObject) { + return yield* new PluginMigrationViolation({ + pluginId, + version: migration.version, + objectName: newTempObject.name, + detail: "TEMP objects are not permitted in plugin migrations", + }); + } yield* validateMigrationObjects({ pluginId, version: migration.version, diff --git a/apps/server/src/plugins/PluginModuleLoader.ts b/apps/server/src/plugins/PluginModuleLoader.ts index 6cc09f5b137..407707c28c4 100644 --- a/apps/server/src/plugins/PluginModuleLoader.ts +++ b/apps/server/src/plugins/PluginModuleLoader.ts @@ -106,7 +106,7 @@ export const make = Effect.fn("PluginModuleLoader.make")(function* () { const realPluginDir = yield* fs.realPath(pluginDir).pipe( Effect.mapError((cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause })), ); - const resolvedEntry = path.resolve(pluginDir, entryRelPath); + const resolvedEntry = path.resolve(realPluginDir, entryRelPath); const realEntry = yield* fs.realPath(resolvedEntry).pipe( Effect.mapError((cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause })), ); From 8fd2056399c942bc304008664b231b156c8d76f8 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 04:43:08 -0400 Subject: [PATCH 05/75] Add plugin RPC transport and plugin-aware auth scopes Generic transport for runtime-plugin methods over the existing WS RPC stack; no management RPCs, web changes, or capability facades yet. - Contracts: plugins.list/call/subscribe in WsRpcGroup; PluginRpcError (not-found/not-ready/unauthorized/invalid-method/internal) and PluginInfo; new core scope plugins:manage; PluginScope pattern (plugin::read|operate) + satisfiesScope with the documented implicit-standard rule (a full standard-client grant set satisfies any plugin scope; restricted tokens need explicit grants) - Server: PluginRpcDispatcher enforcing per-plugin scope + readiness from registered descriptors (static RPC_REQUIRED_SCOPE entries stay orchestration:read as the transport baseline - two-layer check); defects contained and attributed, socket survives plugin bugs; ws.ts authorize helpers now use satisfiesScope; token-exchange, session store, and pairing grants accept AuthScope; public access-management DTOs keep exposing core scopes only - Client runtime: listPlugins/callPlugin/subscribePlugin helpers Implemented by GPT-5.5 via codex exec (assembly-line slice 2a-2). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/package.json | 2 +- apps/server/src/auth/EnvironmentAuth.test.ts | 21 +- apps/server/src/auth/EnvironmentAuth.ts | 16 +- .../src/auth/EnvironmentAuthAdmin.test.ts | 23 +- .../server/src/auth/PairingGrantStore.test.ts | 20 +- apps/server/src/auth/PairingGrantStore.ts | 13 +- apps/server/src/auth/SessionStore.test.ts | 9 +- apps/server/src/auth/SessionStore.ts | 20 +- apps/server/src/auth/http.ts | 50 ++- apps/server/src/bin.test.ts | 24 +- .../src/persistence/AuthPairingLinks.ts | 6 +- apps/server/src/persistence/AuthSessions.ts | 8 +- apps/server/src/plugins/PluginCatalog.ts | 119 +++++++ apps/server/src/plugins/PluginHost.test.ts | 27 +- apps/server/src/plugins/PluginHost.ts | 61 ++-- .../src/plugins/PluginLockfileStore.test.ts | 5 +- .../server/src/plugins/PluginLockfileStore.ts | 99 +++--- apps/server/src/plugins/PluginLogger.ts | 10 + .../server/src/plugins/PluginMigrator.test.ts | 34 +- apps/server/src/plugins/PluginMigrator.ts | 7 +- apps/server/src/plugins/PluginModuleLoader.ts | 31 +- apps/server/src/plugins/PluginPaths.ts | 6 +- .../src/plugins/PluginRpcDispatcher.test.ts | 314 ++++++++++++++++++ .../server/src/plugins/PluginRpcDispatcher.ts | 187 +++++++++++ .../src/plugins/PluginRuntimeRegistry.ts | 7 +- apps/server/src/plugins/pluginResolveHooks.ts | 4 +- apps/server/src/server.test.ts | 41 ++- apps/server/src/server.ts | 22 +- apps/server/src/ws.ts | 46 ++- .../src/authorization/remote.ts | 6 +- .../src/platform/capabilities.ts | 4 +- .../client-runtime/src/rpc/client.test.ts | 74 ++++- packages/client-runtime/src/rpc/client.ts | 40 ++- packages/contracts/src/auth.test.ts | 69 ++++ packages/contracts/src/auth.ts | 42 ++- packages/contracts/src/environmentHttp.ts | 6 +- packages/contracts/src/plugin.test.ts | 19 +- packages/contracts/src/plugin.ts | 44 ++- packages/contracts/src/rpc.ts | 33 ++ packages/plugin-sdk/src/index.ts | 8 +- 40 files changed, 1255 insertions(+), 322 deletions(-) create mode 100644 apps/server/src/plugins/PluginCatalog.ts create mode 100644 apps/server/src/plugins/PluginLogger.ts create mode 100644 apps/server/src/plugins/PluginRpcDispatcher.test.ts create mode 100644 apps/server/src/plugins/PluginRpcDispatcher.ts create mode 100644 packages/contracts/src/auth.test.ts diff --git a/apps/server/package.json b/apps/server/package.json index f89b32a20b9..8e5f35f4b89 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,10 +27,10 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@t3tools/plugin-sdk": "workspace:*", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", + "@t3tools/plugin-sdk": "workspace:*", "effect": "catalog:", "node-pty": "^1.1.0" }, diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..898d9d8f0ab 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthAdministrativeScopes } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -89,13 +89,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); expect(verified.sessionId.length).toBeGreaterThan(0); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual(AuthStandardClientScopes); expect(verified.subject).toBe("one-time-token"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); @@ -167,16 +161,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { makeCookieRequest(exchanged.sessionToken), ); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual(AuthAdministrativeScopes); expect(verified.subject).toBe("administrative-bootstrap"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..d9ac670879e 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -8,9 +8,9 @@ import { type AuthClientMetadata, type AuthClientSession, type AuthCreatePairingCredentialInput, - type AuthEnvironmentScope, type AuthPairingLink, type AuthPairingCredentialResult, + type AuthScope, type AuthSessionId, type AuthSessionState, type ServerAuthDescriptor, @@ -41,7 +41,7 @@ export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstr export interface IssuedPairingLink { readonly id: string; readonly credential: string; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; readonly createdAt: DateTime.Utc; @@ -52,7 +52,7 @@ export interface IssuedBearerSession { readonly sessionId: AuthSessionId; readonly token: string; readonly method: "bearer-access-token"; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly subject: string; readonly client: AuthClientMetadata; readonly expiresAt: DateTime.Utc; @@ -62,7 +62,7 @@ export interface AuthenticatedSession { readonly sessionId: AuthSessionId; readonly subject: string; readonly method: ServerAuthSessionMethod; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly proofKeyThumbprint?: string; readonly expiresAt?: DateTime.DateTime; } @@ -423,7 +423,7 @@ export class EnvironmentAuth extends Context.Service< >; readonly exchangeBootstrapCredentialForAccessToken: ( credential: string, - requestedScopes: ReadonlyArray | undefined, + requestedScopes: ReadonlyArray | undefined, requestMetadata: AuthClientMetadata, input?: { readonly proofKeyThumbprint?: string; @@ -435,7 +435,7 @@ export class EnvironmentAuth extends Context.Service< readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; readonly label?: string; - readonly scopes?: ReadonlyArray; + readonly scopes?: ReadonlyArray; readonly subject?: string; readonly proofKeyThumbprint?: string; }) => Effect.Effect; @@ -453,7 +453,7 @@ export class EnvironmentAuth extends Context.Service< readonly issueSession: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; - readonly scopes?: ReadonlyArray; + readonly scopes?: ReadonlyArray; readonly label?: string; }) => Effect.Effect; readonly listSessions: () => Effect.Effect< @@ -743,7 +743,7 @@ export const make = Effect.gen(function* () { ); const issuePairingCredentialForSubject = (input: { - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; }) => diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 03009270e15..188e16afa82 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthAdministrativeScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -77,29 +78,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const listedAfterRevoke = yield* environmentAuth.listSessions(); expect(issued.method).toBe("bearer-access-token"); - expect(issued.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(issued.scopes).toEqual(AuthAdministrativeScopes); expect(issued.client.deviceType).toBe("bot"); expect(issued.client.label).toBe("deploy-bot"); expect(verified.sessionId).toBe(issued.sessionId); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual(AuthAdministrativeScopes); expect(verified.method).toBe("bearer-access-token"); expect(listedBeforeRevoke).toHaveLength(1); expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 5242dd738b8..52a8a348845 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -74,13 +75,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(first.scopes).toEqual(AuthStandardClientScopes); expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); @@ -145,16 +140,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); expect(first.method).toBe("desktop-bootstrap"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(first.scopes).toEqual(AuthAdministrativeScopes); expect(first.subject).toBe("desktop-bootstrap"); expect(second.method).toBe("desktop-bootstrap"); expect(third.method).toBe("desktop-bootstrap"); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 588d5e3775f..f28a53ce869 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -1,7 +1,8 @@ import { AuthAdministrativeScopes, AuthStandardClientScopes, - type AuthEnvironmentScope, + authEnvironmentScopes, + type AuthScope, type AuthPairingLink, type ServerAuthBootstrapMethod, } from "@t3tools/contracts"; @@ -22,7 +23,7 @@ import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { readonly method: ServerAuthBootstrapMethod; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; readonly proofKeyThumbprint?: string; @@ -198,7 +199,7 @@ export class PairingGrantStore extends Context.Service< { readonly issueOneTimeToken: (input?: { readonly ttl?: Duration.Duration; - readonly scopes?: ReadonlyArray; + readonly scopes?: ReadonlyArray; readonly subject?: string; readonly label?: string; readonly proofKeyThumbprint?: string; @@ -327,7 +328,7 @@ export const make = Effect.gen(function* () { ? ({ id: row.id, credential: row.credential, - scopes: row.scopes, + scopes: authEnvironmentScopes(row.scopes), subject: row.subject, label: row.label, createdAt: row.createdAt, @@ -336,7 +337,7 @@ export const make = Effect.gen(function* () { : ({ id: row.id, credential: row.credential, - scopes: row.scopes, + scopes: authEnvironmentScopes(row.scopes), subject: row.subject, createdAt: row.createdAt, expiresAt: row.expiresAt, @@ -408,7 +409,7 @@ export const make = Effect.gen(function* () { yield* emitUpsert({ id, credential, - scopes: input?.scopes ?? AuthStandardClientScopes, + scopes: authEnvironmentScopes(input?.scopes ?? AuthStandardClientScopes), subject: input?.subject ?? "one-time-token", ...(input?.label ? { label: input.label } : {}), createdAt: now, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 334c24ef52f..e1d9595564f 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -140,13 +141,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(verified.method).toBe("bearer-access-token"); expect(verified.subject).toBe("test-clock"); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual(AuthStandardClientScopes); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 12ecb7dba4d..49133b5e762 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1,10 +1,11 @@ import { AuthSessionId, AuthStandardClientScopes, - AuthEnvironmentScopes, + AuthScopes, + authEnvironmentScopes, type AuthClientMetadata, type AuthClientSession, - type AuthEnvironmentScope, + type AuthScope, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -36,7 +37,7 @@ export interface IssuedSession { readonly method: ServerAuthSessionMethod; readonly client: AuthClientMetadata; readonly expiresAt: DateTime.DateTime; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly proofKeyThumbprint?: string; } @@ -47,7 +48,7 @@ export interface VerifiedSession { readonly client: AuthClientMetadata; readonly expiresAt?: DateTime.DateTime; readonly subject: string; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly proofKeyThumbprint?: string; } @@ -363,7 +364,7 @@ export class SessionStore extends Context.Service< readonly ttl?: Duration.Duration; readonly subject?: string; readonly method?: ServerAuthSessionMethod; - readonly scopes?: ReadonlyArray; + readonly scopes?: ReadonlyArray; readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; }) => Effect.Effect; @@ -408,7 +409,7 @@ const SessionClaims = Schema.Struct({ kind: Schema.Literal("session"), sid: AuthSessionId, sub: Schema.String, - scopes: AuthEnvironmentScopes, + scopes: AuthScopes, method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), jkt: Schema.optionalKey(Schema.String), iat: Schema.Number, @@ -452,9 +453,14 @@ function toClientMetadata(record: { }; } -function toAuthClientSession(input: Omit): AuthClientSession { +function toAuthClientSession( + input: Omit & { + readonly scopes: ReadonlyArray; + }, +): AuthClientSession { return { ...input, + scopes: authEnvironmentScopes(input.scopes), current: false, }; } diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 71fb00b970a..185527abccb 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -1,6 +1,7 @@ import { AuthAccessReadScope, AuthAccessWriteScope, + AuthPluginsManageScope, AuthStandardClientScopes, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -19,9 +20,10 @@ import { EnvironmentScopeRequiredError, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, + isPluginScope, } from "@t3tools/contracts"; -import type { AuthEnvironmentScope } from "@t3tools/contracts"; -import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; +import type { AuthEnvironmentScope, AuthScope } from "@t3tools/contracts"; +import { parseOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -109,7 +111,7 @@ export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidR ); } -export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) { +export function failEnvironmentScopeRequired(requiredScope: AuthScope) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => Effect.fail( @@ -161,6 +163,32 @@ export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope" return session; }); +const TOKEN_EXCHANGE_CORE_SCOPES = new Set([ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthTerminalOperateScope, + AuthReviewWriteScope, + AuthAccessReadScope, + AuthAccessWriteScope, + AuthRelayReadScope, + AuthRelayWriteScope, + AuthPluginsManageScope, +]); + +function parseTokenExchangeScope(value: string): ReadonlyArray | null { + const scopes = parseOAuthScope(value); + if (scopes === null) return null; + if ( + !scopes.every( + (scope): scope is AuthScope => + TOKEN_EXCHANGE_CORE_SCOPES.has(scope as AuthEnvironmentScope) || isPluginScope(scope), + ) + ) { + return null; + } + return scopes; +} + export const environmentAuthenticatedAuthLayer = Layer.effect( EnvironmentAuthenticatedAuth, Effect.gen(function* () { @@ -250,19 +278,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const requestedScopes = args.payload.scope === undefined ? undefined - : parseAllowedOAuthScope({ - value: args.payload.scope, - allowedScopes: new Set([ - AuthOrchestrationReadScope, - AuthOrchestrationOperateScope, - AuthTerminalOperateScope, - AuthReviewWriteScope, - AuthAccessReadScope, - AuthAccessWriteScope, - AuthRelayReadScope, - AuthRelayWriteScope, - ]), - }); + : parseTokenExchangeScope(args.payload.scope); if (requestedScopes === null) { return yield* failEnvironmentInvalidRequest("invalid_scope"); } @@ -330,7 +346,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const delegatedScopes = args.payload.scopes ?? AuthStandardClientScopes; if ( delegatedScopes.length === 0 || - new Set(delegatedScopes).size !== delegatedScopes.length + new Set(delegatedScopes).size !== delegatedScopes.length ) { return yield* failEnvironmentInvalidRequest("invalid_scope"); } diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5c713ff2be7..6111429e41f 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -6,7 +6,7 @@ import * as NodePath from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -362,28 +362,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(typeof issued.sessionId, "string"); assert.equal(typeof issued.token, "string"); - assert.deepEqual(issued.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(issued.scopes, AuthAdministrativeScopes); assert.equal(listed.length, 1); assert.equal(listed[0]?.sessionId, issued.sessionId); - assert.deepEqual(listed[0]?.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(listed[0]?.scopes, AuthAdministrativeScopes); assert.equal("token" in (listed[0] ?? {}), false); }), ); diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index e54c977e7ab..9f7575a3f29 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -6,7 +6,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { AuthEnvironmentScopes } from "@t3tools/contracts"; +import { AuthScopes } from "@t3tools/contracts"; import { type AuthPairingLinkRepositoryError, @@ -19,7 +19,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ id: Schema.String, credential: Schema.String, method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), - scopes: Schema.fromJsonString(AuthEnvironmentScopes), + scopes: Schema.fromJsonString(AuthScopes), subject: Schema.String, label: Schema.NullOr(Schema.String), proofKeyThumbprint: Schema.NullOr(Schema.String), @@ -34,7 +34,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ id: Schema.String, credential: Schema.String, method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), - scopes: AuthEnvironmentScopes, + scopes: AuthScopes, subject: Schema.String, label: Schema.NullOr(Schema.String), proofKeyThumbprint: Schema.NullOr(Schema.String), diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 545688e3822..7623f0f6149 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -8,7 +8,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { AuthClientMetadataDeviceType, - AuthEnvironmentScopes, + AuthScopes, AuthSessionId, ServerAuthSessionMethod, } from "@t3tools/contracts"; @@ -33,7 +33,7 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe export const AuthSessionRecord = Schema.Struct({ sessionId: AuthSessionId, subject: Schema.String, - scopes: AuthEnvironmentScopes, + scopes: AuthScopes, method: ServerAuthSessionMethod, client: AuthSessionClientMetadataRecord, issuedAt: Schema.DateTimeUtcFromString, @@ -46,7 +46,7 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type; export const CreateAuthSessionInput = Schema.Struct({ sessionId: AuthSessionId, subject: Schema.String, - scopes: AuthEnvironmentScopes, + scopes: AuthScopes, method: ServerAuthSessionMethod, client: AuthSessionClientMetadataRecord, issuedAt: Schema.DateTimeUtcFromString, @@ -109,7 +109,7 @@ export class AuthSessionRepository extends Context.Service< const AuthSessionDbRow = Schema.Struct({ sessionId: AuthSessionId, subject: Schema.String, - scopes: Schema.fromJsonString(AuthEnvironmentScopes), + scopes: Schema.fromJsonString(AuthScopes), method: ServerAuthSessionMethod, clientLabel: Schema.NullOr(Schema.String), clientIpAddress: Schema.NullOr(Schema.String), diff --git a/apps/server/src/plugins/PluginCatalog.ts b/apps/server/src/plugins/PluginCatalog.ts new file mode 100644 index 00000000000..afce5a648b6 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.ts @@ -0,0 +1,119 @@ +import { + EMPTY_PLUGIN_LOCKFILE, + PluginId, + PluginManifest, + type PluginInfo, + type PluginLockfile, + type PluginLockfilePlugin, +} from "@t3tools/contracts/plugin"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../config.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { pluginManifestPath, pluginVersionDir } from "./PluginPaths.ts"; +import { PluginRuntimeRegistry, type ActivePluginRuntime } from "./PluginRuntimeRegistry.ts"; + +export class PluginCatalog extends Context.Service< + PluginCatalog, + { + readonly list: Effect.Effect>; + } +>()("t3/plugins/PluginCatalog") {} + +const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + +const pluginInfoFromRuntime = ( + runtime: ActivePluginRuntime, + lockfile: PluginLockfile, +): PluginInfo => { + const entry = lockfile.plugins[runtime.manifest.id]; + return { + id: runtime.manifest.id, + name: runtime.manifest.name, + version: runtime.manifest.version, + state: entry?.state ?? "active", + capabilities: Array.from(runtime.manifest.capabilities), + hasWeb: runtime.manifest.entries.web !== undefined, + lastError: entry?.lastError ?? null, + }; +}; + +const fallbackPluginInfo = (pluginId: string, entry: PluginLockfilePlugin): PluginInfo => ({ + id: PluginId.make(pluginId), + name: pluginId, + version: entry.version, + state: entry.state, + capabilities: [], + hasWeb: false, + lastError: entry.lastError, +}); + +export const make = Effect.fn("PluginCatalog.make")(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const registry = yield* PluginRuntimeRegistry; + const lockfileStore = yield* PluginLockfileStore; + + const readInstalledManifest = (pluginId: string, entry: PluginLockfilePlugin) => + fs + .readFileString( + pluginManifestPath( + pluginVersionDir(config.pluginsDir, pluginId, entry.version, path.join), + path.join, + ), + ) + .pipe(Effect.flatMap(decodeManifestJson)); + + const pluginInfoFromLockfileEntry = (pluginId: string, entry: PluginLockfilePlugin) => + readInstalledManifest(pluginId, entry).pipe( + Effect.map( + (manifest): PluginInfo => ({ + id: manifest.id, + name: manifest.name, + version: manifest.version, + state: entry.state, + capabilities: Array.from(manifest.capabilities), + hasWeb: manifest.entries.web !== undefined, + lastError: entry.lastError, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to read installed plugin manifest for plugin list", { + pluginId, + cause: Cause.pretty(cause), + }).pipe(Effect.as(fallbackPluginInfo(pluginId, entry))), + ), + ); + + const list = Effect.gen(function* () { + const activeRuntimes = yield* registry.list; + const lockfile = yield* lockfileStore.readLockfile.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to read plugin lockfile for plugin list", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(EMPTY_PLUGIN_LOCKFILE)), + ), + ); + const activePluginIds = new Set(activeRuntimes.map((runtime) => runtime.manifest.id)); + const activeInfos = activeRuntimes.map((runtime) => pluginInfoFromRuntime(runtime, lockfile)); + const inactiveInfos = yield* Effect.forEach( + Object.entries(lockfile.plugins).filter(([pluginId]) => !activePluginIds.has(pluginId)), + ([pluginId, entry]) => pluginInfoFromLockfileEntry(pluginId, entry), + { concurrency: 4 }, + ); + return [...activeInfos, ...inactiveInfos].toSorted((left, right) => + left.id.localeCompare(right.id), + ); + }); + + return PluginCatalog.of({ list }); +}); + +export const layer = Layer.effect(PluginCatalog, make()); diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index 42a12a08c8c..d969841ed93 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -8,7 +8,7 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { pathToFileURL } from "node:url"; +import * as NodeURL from "node:url"; import * as ServerConfig from "../config.ts"; import { runMigrations } from "../persistence/Migrations.ts"; @@ -52,7 +52,7 @@ const makeLockEntry = (overrides: Partial = {}): PluginLoc const pluginEntrySource = () => ` import { createRequire } from "node:module"; -const require = createRequire(${JSON.stringify(pathToFileURL(import.meta.url).href)}); +const require = createRequire(${JSON.stringify(NodeURL.pathToFileURL(import.meta.url).href)}); const Effect = require("effect/Effect"); const SqlClient = require("effect/unstable/sql/SqlClient"); const NodeFs = require("node:fs"); @@ -100,15 +100,18 @@ const installPlugin = (input: { yield* fs.makeDirectory(pluginDir, { recursive: true }); const encodedManifest = yield* encodeManifestJson({ - id: input.pluginId, - name: "Test Plugin", - version: entry.version, - hostApi: input.manifestHostApi ?? "^1.0.0", - capabilities: [], - entries: { server: "server.js" }, - }); + id: input.pluginId, + name: "Test Plugin", + version: entry.version, + hostApi: input.manifestHostApi ?? "^1.0.0", + capabilities: [], + entries: { server: "server.js" }, + }); yield* fs.writeFileString(path.join(pluginDir, "manifest.json"), encodedManifest); - yield* fs.writeFileString(path.join(pluginDir, "server.js"), input.entrySource ?? pluginEntrySource()); + yield* fs.writeFileString( + path.join(pluginDir, "server.js"), + input.entrySource ?? pluginEntrySource(), + ); yield* store.updatePlugin(input.pluginId, () => Effect.succeed(entry)); return { pluginDir, entry }; }); @@ -180,7 +183,9 @@ layer("PluginHost", (it) => { `; assert.deepEqual(migrationRows, [{ version: 1 }]); assert.isTrue( - yield* fs.exists(path.join(pluginDataDir(config.pluginsDir, pluginId, path.join), "service-ran")), + yield* fs.exists( + path.join(pluginDataDir(config.pluginsDir, pluginId, path.join), "service-ran"), + ), ); let lockfile = yield* store.readLockfile; diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index a772303c817..8bf538dd4f4 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -33,11 +33,8 @@ import * as ServerConfig from "../config.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; import { PluginMigrator } from "./PluginMigrator.ts"; import { PluginModuleLoader } from "./PluginModuleLoader.ts"; -import { - pluginDataDir, - pluginManifestPath, - pluginVersionDir, -} from "./PluginPaths.ts"; +import { makePluginLogger } from "./PluginLogger.ts"; +import { pluginDataDir, pluginManifestPath, pluginVersionDir } from "./PluginPaths.ts"; import { PluginRuntimeRegistry } from "./PluginRuntimeRegistry.ts"; const APP_VERSION = packageJson.version; @@ -121,13 +118,6 @@ function validateRegistration( return Effect.void; } -const makeLogger = (pluginId: PluginId): PluginLogger => ({ - debug: (message, attributes) => Effect.logDebug(message, { ...attributes, pluginId }), - info: (message, attributes) => Effect.logInfo(message, { ...attributes, pluginId }), - warn: (message, attributes) => Effect.logWarning(message, { ...attributes, pluginId }), - error: (message, attributes) => Effect.logError(message, { ...attributes, pluginId }), -}); - const unavailable = (capability: string) => Effect.die(new PluginCapabilityUnavailable({ capability })); @@ -198,22 +188,19 @@ const startService = (input: { readonly logger: PluginLogger; readonly service: PluginServiceDescriptor; }) => - input - .service - .run({ pluginId: input.pluginId, logger: input.logger }) - .pipe( - Effect.catchCause((cause) => - input.logger.error("plugin service failed; restarting", { - service: input.service.name, - cause: Cause.pretty(cause), - }), - ), - // Exponential backoff capped at 30s so a flapping service keeps - // retrying at a bounded cadence instead of backing off forever. - Effect.repeat( - Schedule.either(Schedule.exponential("250 millis"), Schedule.spaced("30 seconds")), - ), - ); + input.service.run({ pluginId: input.pluginId, logger: input.logger }).pipe( + Effect.catchCause((cause) => + input.logger.error("plugin service failed; restarting", { + service: input.service.name, + cause: Cause.pretty(cause), + }), + ), + // Exponential backoff capped at 30s so a flapping service keeps + // retrying at a bounded cadence instead of backing off forever. + Effect.repeat( + Schedule.either(Schedule.exponential("250 millis"), Schedule.spaced("30 seconds")), + ), + ); export const make = Effect.fn("PluginHost.make")(function* () { const config = yield* ServerConfig.ServerConfig; @@ -226,9 +213,9 @@ export const make = Effect.fn("PluginHost.make")(function* () { const clock = yield* Clock.Clock; const readManifest = (pluginDir: string) => - fs.readFileString(pluginManifestPath(pluginDir, path.join)).pipe( - Effect.flatMap(decodeManifest), - ); + fs + .readFileString(pluginManifestPath(pluginDir, path.join)) + .pipe(Effect.flatMap(decodeManifest)); const loadPlugin = (pluginId: PluginId, entry: PluginLockfilePlugin) => Effect.gen(function* () { @@ -280,7 +267,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { const scope = yield* Scope.make("sequential"); const readiness = yield* Deferred.make(); - const logger = makeLogger(pluginId); + const logger = makePluginLogger(pluginId); const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); const hostApi = makeHostApi({ pluginId, dataDir, logger }); @@ -343,7 +330,11 @@ export const make = Effect.fn("PluginHost.make")(function* () { } if (entry.state === "pending-upgrade") { if (!entry.staged) { - yield* updateFailure(store, pluginId, "pending upgrade is missing staged plugin metadata"); + yield* updateFailure( + store, + pluginId, + "pending upgrade is missing staged plugin metadata", + ); return false; } const staged = entry.staged; @@ -412,9 +403,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { ), ); if (!shouldContinue || !entry.enabled) continue; - const currentLockfile = yield* store.readLockfile.pipe( - Effect.orElseSucceed(() => lockfile), - ); + const currentLockfile = yield* store.readLockfile.pipe(Effect.orElseSucceed(() => lockfile)); const currentEntry = getLockfilePlugin(currentLockfile, pluginId); if (!currentEntry?.enabled || currentEntry.state !== "active") continue; yield* loadPlugin(pluginId, currentEntry).pipe( diff --git a/apps/server/src/plugins/PluginLockfileStore.test.ts b/apps/server/src/plugins/PluginLockfileStore.test.ts index 1fee7b4ed16..8650835776d 100644 --- a/apps/server/src/plugins/PluginLockfileStore.test.ts +++ b/apps/server/src/plugins/PluginLockfileStore.test.ts @@ -11,7 +11,10 @@ import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; import * as PluginLockfileStoreModule from "./PluginLockfileStore.ts"; -import { PluginLockfileCorruptError, PluginLockfileTransitionError } from "./PluginLockfileStore.ts"; +import { + PluginLockfileCorruptError, + PluginLockfileTransitionError, +} from "./PluginLockfileStore.ts"; const layer = it.layer( PluginLockfileStoreModule.layer.pipe( diff --git a/apps/server/src/plugins/PluginLockfileStore.ts b/apps/server/src/plugins/PluginLockfileStore.ts index 16686b51c9e..4649599aa30 100644 --- a/apps/server/src/plugins/PluginLockfileStore.ts +++ b/apps/server/src/plugins/PluginLockfileStore.ts @@ -101,7 +101,9 @@ export class PluginLockfileStore extends Context.Service< context: PluginLockfileMutationContext, ) => Effect.Effect, ) => Effect.Effect; - readonly removePlugin: (id: PluginId) => Effect.Effect; + readonly removePlugin: ( + id: PluginId, + ) => Effect.Effect; readonly transition: ( id: PluginId, from: ReadonlyArray, @@ -116,13 +118,15 @@ const isNotFound = (cause: { readonly reason?: { readonly _tag?: string } }) => const readLockfileFromPath = (lockfilePath: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const raw = yield* fs.readFileString(lockfilePath).pipe( - Effect.catch((cause) => - isNotFound(cause) - ? Effect.succeed(null) - : Effect.fail(new PluginLockfileReadError({ path: lockfilePath, cause })), - ), - ); + const raw = yield* fs + .readFileString(lockfilePath) + .pipe( + Effect.catch((cause) => + isNotFound(cause) + ? Effect.succeed(null) + : Effect.fail(new PluginLockfileReadError({ path: lockfilePath, cause })), + ), + ); if (raw === null) return EMPTY_PLUGIN_LOCKFILE; return yield* decodePluginLockfileJson(raw).pipe( Effect.mapError( @@ -158,14 +162,13 @@ const writeLockfileToPath = (input: { yield* file.writeAll(bytes); yield* file.sync; yield* fs.rename(tempPath, input.lockfilePath); - yield* fs - .open(input.pluginsDir, { flag: "r" }) - .pipe(Effect.flatMap((directory) => directory.sync), Effect.ignore); + yield* fs.open(input.pluginsDir, { flag: "r" }).pipe( + Effect.flatMap((directory) => directory.sync), + Effect.ignore, + ); }), ).pipe( - Effect.mapError( - (cause) => new PluginLockfileWriteError({ path: input.lockfilePath, cause }), - ), + Effect.mapError((cause) => new PluginLockfileWriteError({ path: input.lockfilePath, cause })), ); const acquireAdvisoryLock = (input: { @@ -175,11 +178,13 @@ const acquireAdvisoryLock = (input: { Effect.acquireRelease( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(input.pluginsDir, { recursive: true }).pipe( - Effect.mapError( - (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), - ), - ); + yield* fs + .makeDirectory(input.pluginsDir, { recursive: true }) + .pipe( + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); const openLock = Effect.scoped( Effect.gen(function* () { @@ -194,11 +199,13 @@ const acquireAdvisoryLock = (input: { const opened = yield* openLock.pipe(Effect.result); if (Result.isSuccess(opened)) return input.advisoryLockPath; - const stat = yield* fs.stat(input.advisoryLockPath).pipe( - Effect.mapError( - (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), - ), - ); + const stat = yield* fs + .stat(input.advisoryLockPath) + .pipe( + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); const mtime = Option.getOrUndefined(stat.mtime); const ageMs = mtime ? (yield* Clock.currentTimeMillis) - mtime.getTime() : 0; if (ageMs <= STALE_LOCK_MS) { @@ -212,11 +219,13 @@ const acquireAdvisoryLock = (input: { path: input.advisoryLockPath, ageMs, }); - yield* fs.remove(input.advisoryLockPath, { force: true }).pipe( - Effect.mapError( - (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), - ), - ); + yield* fs + .remove(input.advisoryLockPath, { force: true }) + .pipe( + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); yield* openLock.pipe( Effect.mapError( (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), @@ -251,24 +260,26 @@ export const make = Effect.fn("PluginLockfileStore.make")(function* () { const mutate = ( update: (lockfile: PluginLockfile) => Effect.Effect, ) => - provideLocalServices(semaphore.withPermits(1)( - Effect.scoped( - acquireAdvisoryLock({ pluginsDir: config.pluginsDir, advisoryLockPath }).pipe( - Effect.flatMap(() => - Effect.gen(function* () { - const current = yield* readLockfile; - const next = yield* update(current); - yield* writeLockfileToPath({ - pluginsDir: config.pluginsDir, - lockfilePath, - lockfile: next, - }); - return next; - }), + provideLocalServices( + semaphore.withPermits(1)( + Effect.scoped( + acquireAdvisoryLock({ pluginsDir: config.pluginsDir, advisoryLockPath }).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const current = yield* readLockfile; + const next = yield* update(current); + yield* writeLockfileToPath({ + pluginsDir: config.pluginsDir, + lockfilePath, + lockfile: next, + }); + return next; + }), + ), ), ), ), - )); + ); const updatePlugin: PluginLockfileStore["Service"]["updatePlugin"] = (id, fn) => mutate((lockfile) => diff --git a/apps/server/src/plugins/PluginLogger.ts b/apps/server/src/plugins/PluginLogger.ts new file mode 100644 index 00000000000..c8b867f3f6a --- /dev/null +++ b/apps/server/src/plugins/PluginLogger.ts @@ -0,0 +1,10 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { PluginLogger } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; + +export const makePluginLogger = (pluginId: PluginId): PluginLogger => ({ + debug: (message, attributes) => Effect.logDebug(message, { ...attributes, pluginId }), + info: (message, attributes) => Effect.logInfo(message, { ...attributes, pluginId }), + warn: (message, attributes) => Effect.logWarning(message, { ...attributes, pluginId }), + error: (message, attributes) => Effect.logError(message, { ...attributes, pluginId }), +}); diff --git a/apps/server/src/plugins/PluginMigrator.test.ts b/apps/server/src/plugins/PluginMigrator.test.ts index 92a6699450b..3caaf501fdf 100644 --- a/apps/server/src/plugins/PluginMigrator.test.ts +++ b/apps/server/src/plugins/PluginMigrator.test.ts @@ -116,20 +116,16 @@ layer("PluginMigrator", (it) => { const result = yield* Effect.result( migrator.run(pluginId, [ - migration( - 1, - "Trigger", - [ - `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`, - ` + migration(1, "Trigger", [ + `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`, + ` CREATE TRIGGER ${prefix}items_ai AFTER INSERT ON ${prefix}items BEGIN INSERT INTO core_items (id) VALUES (NEW.id); END `, - ], - ), + ]), ]), ); @@ -219,9 +215,7 @@ layer("PluginMigrator", (it) => { const pluginId = PluginId.make("temp-plugin"); const result = yield* Effect.result( - migrator.run(pluginId, [ - migration(1, "Temp", "CREATE TEMP TABLE sneaky (id TEXT)"), - ]), + migrator.run(pluginId, [migration(1, "Temp", "CREATE TEMP TABLE sneaky (id TEXT)")]), ); assert.isTrue(Result.isFailure(result)); @@ -251,23 +245,15 @@ layer("PluginMigrator", (it) => { const prefix = pluginPrefix(pluginId); yield* migrator.run(pluginId, [ - migration( - 1, - "Index", - [ - `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`, - `CREATE INDEX ${prefix}items_id_idx ON ${prefix}items (id)`, - ], - ), + migration(1, "Index", [ + `CREATE TABLE ${prefix}items (id TEXT PRIMARY KEY)`, + `CREATE INDEX ${prefix}items_id_idx ON ${prefix}items (id)`, + ]), ]); const result = yield* Effect.result( migrator.run(pluginId, [ - migration( - 2, - "BadView", - `CREATE VIEW bad_items_view AS SELECT id FROM ${prefix}items`, - ), + migration(2, "BadView", `CREATE VIEW bad_items_view AS SELECT id FROM ${prefix}items`), ]), ); assert.isTrue(Result.isFailure(result)); diff --git a/apps/server/src/plugins/PluginMigrator.ts b/apps/server/src/plugins/PluginMigrator.ts index dcbe7ed02d0..5ef03ccbe5e 100644 --- a/apps/server/src/plugins/PluginMigrator.ts +++ b/apps/server/src/plugins/PluginMigrator.ts @@ -147,7 +147,9 @@ const validateMigrationObjects = (input: { if (entry.type !== "trigger" && entry.type !== "view") continue; const body = entry.sql ?? ""; for (const tableName of preMigrationCoreTables) { - if (new RegExp(`\\b${tableName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(body)) { + if ( + new RegExp(`\\b${tableName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(body) + ) { return yield* new PluginMigrationViolation({ pluginId: input.pluginId, version: input.version, @@ -237,7 +239,8 @@ export const make = Effect.fn("PluginMigrator.make")(function* () { if (attached) { // Best-effort DETACH so a rogue attach cannot persist on the // shared connection past this violation. - yield* sql.unsafe(`DETACH DATABASE "${attached.name.replaceAll('"', '""')}"`) + yield* sql + .unsafe(`DETACH DATABASE "${attached.name.replaceAll('"', '""')}"`) .unprepared.pipe(Effect.ignore); return yield* new PluginMigrationViolation({ pluginId, diff --git a/apps/server/src/plugins/PluginModuleLoader.ts b/apps/server/src/plugins/PluginModuleLoader.ts index 407707c28c4..924f18a5f25 100644 --- a/apps/server/src/plugins/PluginModuleLoader.ts +++ b/apps/server/src/plugins/PluginModuleLoader.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { pathToFileURL } from "node:url"; +import * as NodeURL from "node:url"; import * as ServerConfig from "../config.ts"; @@ -64,7 +64,10 @@ function isPluginDefinition(value: unknown): value is PluginDefinition { } function isInside(parent: string, child: string, separator: string): boolean { - return child === parent || child.startsWith(parent.endsWith(separator) ? parent : `${parent}${separator}`); + return ( + child === parent || + child.startsWith(parent.endsWith(separator) ? parent : `${parent}${separator}`) + ); } export const make = Effect.fn("PluginModuleLoader.make")(function* () { @@ -86,7 +89,7 @@ export const make = Effect.fn("PluginModuleLoader.make")(function* () { nodeModule.register(new URL("./pluginResolveHooks.ts", import.meta.url), { parentURL: import.meta.url, data: { - pluginsRootUrl: pathToFileURL(config.pluginsDir).href, + pluginsRootUrl: NodeURL.pathToFileURL(config.pluginsDir).href, }, }), ).pipe( @@ -103,13 +106,21 @@ export const make = Effect.fn("PluginModuleLoader.make")(function* () { entryRelPath, ) => Effect.gen(function* () { - const realPluginDir = yield* fs.realPath(pluginDir).pipe( - Effect.mapError((cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause })), - ); + const realPluginDir = yield* fs + .realPath(pluginDir) + .pipe( + Effect.mapError( + (cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause }), + ), + ); const resolvedEntry = path.resolve(realPluginDir, entryRelPath); - const realEntry = yield* fs.realPath(resolvedEntry).pipe( - Effect.mapError((cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause })), - ); + const realEntry = yield* fs + .realPath(resolvedEntry) + .pipe( + Effect.mapError( + (cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause }), + ), + ); if (!isInside(realPluginDir, realEntry, path.sep)) { return yield* new PluginModulePathError({ pluginDir, @@ -118,7 +129,7 @@ export const make = Effect.fn("PluginModuleLoader.make")(function* () { }); } const imported = yield* Effect.tryPromise({ - try: () => import(pathToFileURL(realEntry).href), + try: () => import(NodeURL.pathToFileURL(realEntry).href), catch: (cause) => new PluginModuleLoadError({ pluginDir, entry: entryRelPath, cause }), }); if (!isPluginDefinition(imported.default)) { diff --git a/apps/server/src/plugins/PluginPaths.ts b/apps/server/src/plugins/PluginPaths.ts index 5db2886d6f8..7f8c29b7ae3 100644 --- a/apps/server/src/plugins/PluginPaths.ts +++ b/apps/server/src/plugins/PluginPaths.ts @@ -1,7 +1,9 @@ import type { PluginId } from "@t3tools/contracts/plugin"; -export const pluginsRoot = (stateDir: string, join: (...segments: ReadonlyArray) => string) => - join(stateDir, "plugins"); +export const pluginsRoot = ( + stateDir: string, + join: (...segments: ReadonlyArray) => string, +) => join(stateDir, "plugins"); export const pluginVersionDir = ( root: string, diff --git a/apps/server/src/plugins/PluginRpcDispatcher.test.ts b/apps/server/src/plugins/PluginRpcDispatcher.test.ts new file mode 100644 index 00000000000..ad5486236a7 --- /dev/null +++ b/apps/server/src/plugins/PluginRpcDispatcher.test.ts @@ -0,0 +1,314 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + AuthOrchestrationReadScope, + AuthStandardClientScopes, + pluginReadScope, + type AuthScope, +} from "@t3tools/contracts"; +import { PluginId, PluginManifest, type PluginLockfilePlugin } from "@t3tools/contracts/plugin"; +import type { PluginRegistration } from "@t3tools/plugin-sdk"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import { pluginManifestPath, pluginVersionDir } from "./PluginPaths.ts"; +import * as PluginCatalogModule from "./PluginCatalog.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import * as PluginLockfileStoreModule from "./PluginLockfileStore.ts"; +import { PluginRpcDispatcher } from "./PluginRpcDispatcher.ts"; +import * as PluginRpcDispatcherModule from "./PluginRpcDispatcher.ts"; +import { PluginRuntimeRegistry } from "./PluginRuntimeRegistry.ts"; +import * as PluginRuntimeRegistryModule from "./PluginRuntimeRegistry.ts"; + +const pluginId = PluginId.make("test-plugin"); +const failedPluginId = PluginId.make("failed-plugin"); +const encodeManifestJson = Schema.encodeSync(Schema.fromJsonString(PluginManifest)); + +const manifest = (id = pluginId): PluginManifest => ({ + id, + name: id === pluginId ? "Test Plugin" : "Failed Plugin", + version: "1.0.0", + hostApi: "^1.0.0", + capabilities: ["agents"], + entries: { server: "server.js", web: "web.js" }, +}); + +const makeLockfilePlugin = ( + overrides: Partial = {}, +): PluginLockfilePlugin => ({ + version: "1.0.0", + sha256: "sha", + sourceId: "local", + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + ...overrides, +}); + +const session = (scopes: ReadonlyArray) => ({ scopes }); + +const registration: PluginRegistration = { + rpc: [ + { + method: "echo", + scope: "read", + handler: (payload, ctx) => Effect.succeed({ pluginId: ctx.pluginId, payload }), + }, + { + method: "operate", + scope: "operate", + handler: () => Effect.succeed("operated"), + }, + { + method: "pre-ready", + scope: "read", + readiness: "always", + handler: () => Effect.succeed("pre-ready"), + }, + { + method: "defect", + scope: "read", + readiness: "always", + handler: () => Effect.die(new Error("boom")), + }, + ], + streams: [ + { + method: "events", + scope: "read", + handler: (payload) => Stream.make(payload, "done"), + }, + ], +}; + +const dispatcherLayer = PluginRpcDispatcherModule.layer.pipe( + Layer.provideMerge(PluginRuntimeRegistryModule.layer), +); + +const dispatcherTest = it.layer(dispatcherLayer); + +const putRuntime = Effect.fn("PluginRpcDispatcherTest.putRuntime")(function* (input: { + readonly ready: boolean; + readonly registration?: PluginRegistration; + readonly runtimeManifest?: PluginManifest; +}) { + const registry = yield* PluginRuntimeRegistry; + const readiness = yield* Deferred.make(); + if (input.ready) { + yield* Deferred.succeed(readiness, undefined).pipe(Effect.orDie); + } + const scope = yield* Scope.make(); + yield* registry.put(pluginId, { + manifest: input.runtimeManifest ?? manifest(), + registration: input.registration ?? registration, + readiness, + scope, + }); +}); + +dispatcherTest("PluginRpcDispatcher", (it) => { + it.effect("round-trips unary calls and streams", () => + Effect.gen(function* () { + yield* putRuntime({ ready: true }); + const dispatcher = yield* PluginRpcDispatcher; + + const call = yield* dispatcher.call( + pluginId, + "echo", + { value: 1 }, + session([pluginReadScope(pluginId)]), + ); + const events = yield* dispatcher + .subscribe(pluginId, "events", "first", session([pluginReadScope(pluginId)])) + .pipe(Stream.runCollect); + + assert.deepEqual(call, { pluginId, payload: { value: 1 } }); + assert.deepEqual(events, ["first", "done"]); + }), + ); + + it.effect("authorizes explicit plugin read grants and full standard clients", () => + Effect.gen(function* () { + yield* putRuntime({ ready: true }); + const dispatcher = yield* PluginRpcDispatcher; + + const explicit = yield* dispatcher.call( + pluginId, + "echo", + "explicit", + session([pluginReadScope(pluginId)]), + ); + const implicit = yield* dispatcher.call( + pluginId, + "echo", + "implicit", + session(AuthStandardClientScopes), + ); + + assert.deepEqual(explicit, { pluginId, payload: "explicit" }); + assert.deepEqual(implicit, { pluginId, payload: "implicit" }); + }), + ); + + it.effect("rejects restricted sessions and read-only grants for operate methods", () => + Effect.gen(function* () { + yield* putRuntime({ ready: true }); + const dispatcher = yield* PluginRpcDispatcher; + + const restricted = yield* Effect.result( + dispatcher.call(pluginId, "echo", null, session([AuthOrchestrationReadScope])), + ); + const readOnlyOperate = yield* Effect.result( + dispatcher.call(pluginId, "operate", null, session([pluginReadScope(pluginId)])), + ); + + assert.isTrue(Result.isFailure(restricted)); + assert.isTrue(Result.isFailure(readOnlyOperate)); + if (Result.isFailure(restricted)) { + assert.equal(restricted.failure.code, "unauthorized"); + } + if (Result.isFailure(readOnlyOperate)) { + assert.equal(readOnlyOperate.failure.code, "unauthorized"); + } + }), + ); + + it.effect("maps unknown plugin, unknown method, and unresolved readiness to typed errors", () => + Effect.gen(function* () { + yield* putRuntime({ ready: false }); + const dispatcher = yield* PluginRpcDispatcher; + + const unknownPlugin = yield* Effect.result( + dispatcher.call(failedPluginId, "echo", null, session(AuthStandardClientScopes)), + ); + const unknownMethod = yield* Effect.result( + dispatcher.call(pluginId, "missing", null, session(AuthStandardClientScopes)), + ); + const notReady = yield* Effect.result( + dispatcher.call(pluginId, "echo", null, session(AuthStandardClientScopes)), + ); + const preReady = yield* dispatcher.call( + pluginId, + "pre-ready", + null, + session(AuthStandardClientScopes), + ); + + assert.isTrue(Result.isFailure(unknownPlugin)); + assert.isTrue(Result.isFailure(unknownMethod)); + assert.isTrue(Result.isFailure(notReady)); + if (Result.isFailure(unknownPlugin)) assert.equal(unknownPlugin.failure.code, "not-found"); + if (Result.isFailure(unknownMethod)) + assert.equal(unknownMethod.failure.code, "invalid-method"); + if (Result.isFailure(notReady)) assert.equal(notReady.failure.code, "not-ready"); + assert.equal(preReady, "pre-ready"); + }), + ); + + it.effect("maps handler defects to internal errors and continues serving calls", () => + Effect.gen(function* () { + yield* putRuntime({ ready: true }); + const dispatcher = yield* PluginRpcDispatcher; + + const defect = yield* Effect.result( + dispatcher.call(pluginId, "defect", null, session(AuthStandardClientScopes)), + ); + const subsequent = yield* dispatcher.call( + pluginId, + "echo", + "after", + session(AuthStandardClientScopes), + ); + + assert.isTrue(Result.isFailure(defect)); + if (Result.isFailure(defect)) { + assert.equal(defect.failure.code, "internal"); + } + assert.deepEqual(subsequent, { pluginId, payload: "after" }); + }), + ); +}); + +const catalogLayer = PluginCatalogModule.layer.pipe( + Layer.provideMerge(PluginRuntimeRegistryModule.layer), + Layer.provideMerge(PluginLockfileStoreModule.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-catalog-" })), + Layer.provideMerge(NodeServices.layer), +); + +const catalogTest = it.layer(catalogLayer); + +catalogTest("PluginCatalog", (it) => { + it.effect("lists active and failed installed plugins with state and web metadata", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const store = yield* PluginLockfileStore; + const catalog = yield* PluginCatalogModule.PluginCatalog; + + for (const pluginManifest of [manifest(pluginId), manifest(failedPluginId)]) { + const pluginDir = pluginVersionDir( + config.pluginsDir, + pluginManifest.id, + pluginManifest.version, + path.join, + ); + yield* fs.makeDirectory(pluginDir, { recursive: true }); + yield* fs.writeFileString( + pluginManifestPath(pluginDir, path.join), + encodeManifestJson(pluginManifest), + ); + } + + yield* store.updatePlugin(pluginId, () => Effect.succeed(makeLockfilePlugin())); + yield* store.updatePlugin(failedPluginId, () => + Effect.succeed( + makeLockfilePlugin({ + state: "failed", + lastError: "activation failed", + }), + ), + ); + yield* putRuntime({ + ready: true, + runtimeManifest: manifest(pluginId), + }); + + const plugins = yield* catalog.list; + + assert.deepEqual( + plugins.map((plugin) => ({ + id: plugin.id, + state: plugin.state, + hasWeb: plugin.hasWeb, + lastError: plugin.lastError, + })), + [ + { + id: failedPluginId, + state: "failed", + hasWeb: true, + lastError: "activation failed", + }, + { + id: pluginId, + state: "active", + hasWeb: true, + lastError: null, + }, + ], + ); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginRpcDispatcher.ts b/apps/server/src/plugins/PluginRpcDispatcher.ts new file mode 100644 index 00000000000..6dfed20c99a --- /dev/null +++ b/apps/server/src/plugins/PluginRpcDispatcher.ts @@ -0,0 +1,187 @@ +import { + PluginRpcError, + pluginOperateScope, + pluginReadScope, + satisfiesScope, + type AuthScope, +} from "@t3tools/contracts"; +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { PluginRpcDescriptor, PluginStreamDescriptor } from "@t3tools/plugin-sdk"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { makePluginLogger } from "./PluginLogger.ts"; +import { PluginRuntimeRegistry, type ActivePluginRuntime } from "./PluginRuntimeRegistry.ts"; + +export interface PluginRpcSession { + readonly scopes: ReadonlyArray; +} + +export class PluginRpcDispatcher extends Context.Service< + PluginRpcDispatcher, + { + readonly call: ( + pluginId: PluginId, + method: string, + payload: unknown, + session: PluginRpcSession, + ) => Effect.Effect; + readonly subscribe: ( + pluginId: PluginId, + method: string, + payload: unknown, + session: PluginRpcSession, + ) => Stream.Stream; + } +>()("t3/plugins/PluginRpcDispatcher") {} + +const pluginRpcError = ( + pluginId: PluginId, + code: PluginRpcError["code"], + message: string, + data?: unknown, +) => + new PluginRpcError({ + pluginId, + code, + message, + ...(data === undefined ? {} : { data }), + }); + +const internalPluginRpcError = (pluginId: PluginId, error: unknown) => + pluginRpcError(pluginId, "internal", error instanceof Error ? error.message : String(error)); + +const pluginDefectError = (pluginId: PluginId) => + pluginRpcError(pluginId, "internal", "Plugin method failed."); + +const lookupRuntime = Effect.fn("PluginRpcDispatcher.lookupRuntime")(function* ( + registry: PluginRuntimeRegistry["Service"], + pluginId: PluginId, +) { + const runtime = yield* registry.get(pluginId); + if (Option.isNone(runtime)) { + return yield* pluginRpcError(pluginId, "not-found", "Plugin was not found."); + } + return runtime.value; +}); + +const ensureDescriptorReady = Effect.fn("PluginRpcDispatcher.ensureDescriptorReady")(function* ( + runtime: ActivePluginRuntime, + descriptor: PluginRpcDescriptor | PluginStreamDescriptor, +) { + if (descriptor.readiness === "always") { + return; + } + const readiness = yield* Deferred.poll(runtime.readiness); + if (Option.isNone(readiness)) { + return yield* pluginRpcError( + runtime.manifest.id, + "not-ready", + "Plugin is not ready to handle this method.", + ); + } +}); + +const authorizeDescriptor = ( + runtime: ActivePluginRuntime, + descriptor: PluginRpcDescriptor | PluginStreamDescriptor, + session: PluginRpcSession, +) => { + const requiredScope = + descriptor.scope === "operate" + ? pluginOperateScope(runtime.manifest.id) + : pluginReadScope(runtime.manifest.id); + return satisfiesScope(requiredScope, session.scopes) + ? Effect.void + : Effect.fail( + pluginRpcError( + runtime.manifest.id, + "unauthorized", + `The authenticated token is missing required scope: ${requiredScope}.`, + ), + ); +}; + +const mapPluginHandlerCause = (pluginId: PluginId, cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause as Cause.Cause); + } + if (Cause.hasDies(cause)) { + return Effect.logError("Plugin RPC handler defect", { + pluginId, + cause: Cause.pretty(cause), + }).pipe(Effect.andThen(Effect.fail(pluginDefectError(pluginId)))); + } + return Effect.fail(internalPluginRpcError(pluginId, Cause.squash(cause))); +}; + +const mapPluginHandlerStreamCause = (pluginId: PluginId, cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Stream.failCause(cause as Cause.Cause); + } + if (Cause.hasDies(cause)) { + return Stream.fromEffect( + Effect.logError("Plugin RPC stream handler defect", { + pluginId, + cause: Cause.pretty(cause), + }), + ).pipe(Stream.drain, Stream.concat(Stream.fail(pluginDefectError(pluginId)))); + } + return Stream.fail(internalPluginRpcError(pluginId, Cause.squash(cause))); +}; + +export const make = Effect.fn("PluginRpcDispatcher.make")(function* () { + const registry = yield* PluginRuntimeRegistry; + + const call: PluginRpcDispatcher["Service"]["call"] = (pluginId, method, payload, session) => + Effect.gen(function* () { + const runtime = yield* lookupRuntime(registry, pluginId); + const descriptor = (runtime.registration.rpc ?? []).find((rpc) => rpc.method === method); + if (descriptor === undefined) { + return yield* pluginRpcError(pluginId, "invalid-method", "Plugin RPC method is invalid."); + } + yield* authorizeDescriptor(runtime, descriptor, session); + yield* ensureDescriptorReady(runtime, descriptor); + const logger = makePluginLogger(pluginId); + return yield* Effect.suspend(() => descriptor.handler(payload, { pluginId, logger })).pipe( + Effect.catchCause((cause) => mapPluginHandlerCause(pluginId, cause)), + ); + }); + + const subscribe: PluginRpcDispatcher["Service"]["subscribe"] = ( + pluginId, + method, + payload, + session, + ) => + Stream.unwrap( + Effect.gen(function* () { + const runtime = yield* lookupRuntime(registry, pluginId); + const descriptor = (runtime.registration.streams ?? []).find( + (stream) => stream.method === method, + ); + if (descriptor === undefined) { + return yield* pluginRpcError( + pluginId, + "invalid-method", + "Plugin stream method is invalid.", + ); + } + yield* authorizeDescriptor(runtime, descriptor, session); + yield* ensureDescriptorReady(runtime, descriptor); + const logger = makePluginLogger(pluginId); + return Stream.suspend(() => descriptor.handler(payload, { pluginId, logger })).pipe( + Stream.catchCause((cause) => mapPluginHandlerStreamCause(pluginId, cause)), + ); + }), + ); + + return PluginRpcDispatcher.of({ call, subscribe }); +}); + +export const layer = Layer.effect(PluginRpcDispatcher, make()); diff --git a/apps/server/src/plugins/PluginRuntimeRegistry.ts b/apps/server/src/plugins/PluginRuntimeRegistry.ts index 73fe01aab60..8abcccbd340 100644 --- a/apps/server/src/plugins/PluginRuntimeRegistry.ts +++ b/apps/server/src/plugins/PluginRuntimeRegistry.ts @@ -18,10 +18,7 @@ export interface ActivePluginRuntime { export class PluginRuntimeRegistry extends Context.Service< PluginRuntimeRegistry, { - readonly put: ( - pluginId: PluginId, - runtime: ActivePluginRuntime, - ) => Effect.Effect; + readonly put: (pluginId: PluginId, runtime: ActivePluginRuntime) => Effect.Effect; readonly remove: (pluginId: PluginId) => Effect.Effect; readonly list: Effect.Effect>; readonly get: (pluginId: PluginId) => Effect.Effect>; @@ -43,7 +40,7 @@ export const make = Effect.fn("PluginRuntimeRegistry.make")(function* () { const next = new Map(current); next.delete(pluginId); return next; - }), + }), list: Ref.get(runtimes).pipe(Effect.map((current) => Array.from(current.values()))), get: (pluginId) => Ref.get(runtimes).pipe( diff --git a/apps/server/src/plugins/pluginResolveHooks.ts b/apps/server/src/plugins/pluginResolveHooks.ts index b3a1f9de368..1bada6a1094 100644 --- a/apps/server/src/plugins/pluginResolveHooks.ts +++ b/apps/server/src/plugins/pluginResolveHooks.ts @@ -12,9 +12,7 @@ export function initialize(data: unknown) { function shouldResolveFromHost(specifier: string, parentURL: string | undefined): boolean { if (!parentURL || !pluginsRootUrl || !parentURL.startsWith(pluginsRootUrl)) return false; return ( - specifier === "effect" || - specifier.startsWith("effect/") || - specifier === "@t3tools/plugin-sdk" + specifier === "effect" || specifier.startsWith("effect/") || specifier === "@t3tools/plugin-sdk" ); } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 466e443afd7..c02649d7b2e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6,6 +6,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { AuthAccessTokenType, + AuthAdministrativeScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, CommandId, @@ -69,6 +70,7 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const ADMIN_SCOPE_STRING = AuthAdministrativeScopes.join(" "); import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; @@ -112,6 +114,8 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as PluginCatalog from "./plugins/PluginCatalog.ts"; +import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); @@ -732,6 +736,23 @@ const buildAppUnderTest = (options?: { ); const appLayer = servedRoutesLayer.pipe( + Layer.provide( + Layer.succeed( + PluginCatalog.PluginCatalog, + PluginCatalog.PluginCatalog.of({ + list: Effect.succeed([]), + }), + ), + ), + Layer.provide( + Layer.succeed( + PluginRpcDispatcher.PluginRpcDispatcher, + PluginRpcDispatcher.PluginRpcDispatcher.of({ + call: () => Effect.die("PluginRpcDispatcher not stubbed in this test"), + subscribe: () => Stream.die("PluginRpcDispatcher not stubbed in this test"), + }), + ), + ), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -920,9 +941,7 @@ const exchangeAccessToken = ( subject_token: credential, subject_token_type: AuthEnvironmentBootstrapTokenType, requested_token_type: AuthAccessTokenType, - scope: - options?.scope ?? - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", + scope: options?.scope ?? ADMIN_SCOPE_STRING, ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), ...(options?.clientMetadata?.deviceType ? { client_device_type: options.clientMetadata.deviceType } @@ -1364,10 +1383,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(tokenResponse.status, 200); assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); assert.equal(tokenBody.token_type, "Bearer"); - assert.equal( - tokenBody.scope, - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", - ); + assert.equal(tokenBody.scope, ADMIN_SCOPE_STRING); assert.equal(typeof tokenBody.access_token, "string"); const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); @@ -1385,16 +1401,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(sessionResponse.status, 200); assert.equal(sessionBody.authenticated, true); assert.equal(sessionBody.sessionMethod, "bearer-access-token"); - assert.deepEqual(sessionBody.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(sessionBody.scopes, AuthAdministrativeScopes); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a20d859c09a..0111b8b7c61 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,9 +33,11 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginCatalog from "./plugins/PluginCatalog.ts"; import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; import * as PluginMigrator from "./plugins/PluginMigrator.ts"; import * as PluginModuleLoader from "./plugins/PluginModuleLoader.ts"; +import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as PluginRuntimeRegistry from "./plugins/PluginRuntimeRegistry.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -188,11 +190,25 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const PluginLayerLive = PluginHost.layer.pipe( - Layer.provideMerge(PluginLockfileStore.layer), +const PluginRuntimeRegistryLayerLive = PluginRuntimeRegistry.layer; +const PluginLockfileStoreLayerLive = PluginLockfileStore.layer; +const PluginHostLayerLive = PluginHost.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), Layer.provideMerge(PluginModuleLoader.layer), Layer.provideMerge(PluginMigrator.layer), - Layer.provideMerge(PluginRuntimeRegistry.layer), + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginRpcDispatcherLayerLive = PluginRpcDispatcher.layer.pipe( + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginCatalogLayerLive = PluginCatalog.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginLayerLive = Layer.mergeAll( + PluginHostLayerLive, + PluginRpcDispatcherLayerLive, + PluginCatalogLayerLive, ); const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9020e99f670..352d8eb6bf9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -44,6 +44,7 @@ import { RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, + PLUGINS_WS_METHODS, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -56,6 +57,7 @@ import { type TerminalMetadataStreamEvent, WS_METHODS, WsRpcGroup, + satisfiesScope, } from "@t3tools/contracts"; import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; @@ -111,6 +113,8 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { PluginCatalog } from "./plugins/PluginCatalog.ts"; +import { PluginRpcDispatcher } from "./plugins/PluginRpcDispatcher.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -343,6 +347,11 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], + [PLUGINS_WS_METHODS.list, AuthOrchestrationReadScope], + // Plugin method RPCs have this static environment-read baseline; the dispatcher + // performs the real per-plugin plugin::read|operate authorization. + [PLUGINS_WS_METHODS.call, AuthOrchestrationReadScope], + [PLUGINS_WS_METHODS.subscribe, AuthOrchestrationReadScope], ]); function toAuthAccessStreamEvent( @@ -434,6 +443,8 @@ const makeWsRpcLayer = ( const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; + const pluginCatalog = yield* PluginCatalog; + const pluginRpcDispatcher = yield* PluginRpcDispatcher; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -443,14 +454,14 @@ const makeWsRpcLayer = ( requiredScope: AuthEnvironmentScope, effect: Effect.Effect, ): Effect.Effect => - currentSession.scopes.includes(requiredScope) + satisfiesScope(requiredScope, currentSession.scopes) ? effect : Effect.fail(authorizationError(requiredScope)); const authorizeStream = ( requiredScope: AuthEnvironmentScope, stream: Stream.Stream, ): Stream.Stream => - currentSession.scopes.includes(requiredScope) + satisfiesScope(requiredScope, currentSession.scopes) ? stream : Stream.fail(authorizationError(requiredScope)); const requiredScopeForMethod = (method: string): AuthEnvironmentScope => { @@ -1173,6 +1184,37 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), + [PLUGINS_WS_METHODS.list]: (_input) => + observeRpcEffect( + PLUGINS_WS_METHODS.list, + pluginCatalog.list.pipe(Effect.map((plugins) => ({ plugins }))), + { "rpc.aggregate": "plugins" }, + ), + [PLUGINS_WS_METHODS.call]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.call, + pluginRpcDispatcher.call(input.pluginId, input.method, input.payload, currentSession), + { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + "plugin.method": input.method, + }, + ), + [PLUGINS_WS_METHODS.subscribe]: (input) => + observeRpcStream( + PLUGINS_WS_METHODS.subscribe, + pluginRpcDispatcher.subscribe( + input.pluginId, + input.method, + input.payload, + currentSession, + ), + { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + "plugin.method": input.method, + }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 69c157d0e50..8bf241d22b5 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -3,7 +3,7 @@ import { type AuthClientPresentationMetadata, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, - type AuthEnvironmentScope, + type AuthScope, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Effect from "effect/Effect"; @@ -37,7 +37,7 @@ export const exchangeRemoteDpopAccessToken = Effect.fn( )(function* (input: { readonly httpBaseUrl: string; readonly credential: string; - readonly scopes?: ReadonlyArray; + readonly scopes?: ReadonlyArray; readonly clientMetadata?: AuthClientPresentationMetadata; readonly dpopProof: string; readonly timeoutMs?: number; @@ -66,7 +66,7 @@ export const bootstrapRemoteBearerSession = Effect.fn( )(function* (input: { readonly httpBaseUrl: string; readonly credential: string; - readonly scopes?: ReadonlyArray; + readonly scopes?: ReadonlyArray; readonly clientMetadata?: AuthClientPresentationMetadata; readonly timeoutMs?: number; }) { diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index a20b7d404b2..19b0a4b1f09 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -1,6 +1,6 @@ import { type AuthClientPresentationMetadata, - type AuthEnvironmentScope, + type AuthScope, type DesktopSshEnvironmentBootstrap, type DesktopSshEnvironmentTarget, EnvironmentId, @@ -39,7 +39,7 @@ export class ClientPresentation extends Context.Service< ClientPresentation, { readonly metadata: AuthClientPresentationMetadata; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; } >()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 507d137cacc..75131ee8dd3 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,5 +1,7 @@ import { EnvironmentId, + PLUGINS_WS_METHODS, + PluginId, type RelayClientInstallProgressEvent, WS_METHODS, } from "@t3tools/contracts"; @@ -25,7 +27,15 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; +import { + EnvironmentRpcRequestObserver, + callPlugin, + listPlugins, + request, + runStream, + subscribe, + subscribePlugin, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -111,6 +121,68 @@ describe("environment RPC", () => { }), ); + it.effect("lists plugins through the active session RPC client", () => + Effect.gen(function* () { + const client = { + [PLUGINS_WS_METHODS.list]: () => Effect.succeed({ plugins: [] }), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + const result = yield* listPlugins().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(result).toEqual({ plugins: [] }); + }), + ); + + it.effect("calls plugin methods with optional payloads", () => + Effect.gen(function* () { + const pluginId = PluginId.make("test-plugin"); + const observedInputs: Array = []; + const client = { + [PLUGINS_WS_METHODS.call]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ ok: true }); + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + const result = yield* callPlugin(pluginId, "echo", { value: 1 }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(result).toEqual({ ok: true }); + expect(observedInputs).toEqual([{ pluginId, method: "echo", payload: { value: 1 } }]); + }), + ); + + it.effect("subscribes to plugin streams through durable subscription handling", () => + Effect.gen(function* () { + const pluginId = PluginId.make("test-plugin"); + const observedInputs: Array = []; + const client = { + [PLUGINS_WS_METHODS.subscribe]: (input: unknown) => { + observedInputs.push(input); + return Stream.make("first", "second"); + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + const result = yield* subscribePlugin(pluginId, "events").pipe( + Stream.take(2), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(result).toEqual(["first", "second"]); + expect(observedInputs).toEqual([{ pluginId, method: "events" }]); + }), + ); + it.effect("binds finite streaming commands to one active session", () => Effect.gen(function* () { const firstEvents = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 92892431e45..c676ddeab7d 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,4 +1,9 @@ -import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; +import { + ORCHESTRATION_WS_METHODS, + PLUGINS_WS_METHODS, + WS_METHODS, + type PluginId, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import type * as Duration from "effect/Duration"; @@ -50,6 +55,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus + | typeof PLUGINS_WS_METHODS.subscribe | typeof WS_METHODS.terminalAttach; export type EnvironmentStreamCommandRpcTag = @@ -240,3 +246,35 @@ export const config = Effect.gen(function* () { const session = yield* currentSession(); return yield* session.initialConfig; }).pipe(Effect.withSpan("EnvironmentRpc.config")); + +export const listPlugins = Effect.fn("EnvironmentRpc.listPlugins")(function* () { + return yield* request(PLUGINS_WS_METHODS.list, {}); +}); + +export const callPlugin = Effect.fn("EnvironmentRpc.callPlugin")(function* ( + pluginId: PluginId, + method: string, + payload?: unknown, +) { + return yield* request(PLUGINS_WS_METHODS.call, { + pluginId, + method, + ...(payload === undefined ? {} : { payload }), + }); +}); + +export function subscribePlugin( + pluginId: PluginId, + method: string, + payload?: unknown, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribe(PLUGINS_WS_METHODS.subscribe, { + pluginId, + method, + ...(payload === undefined ? {} : { payload }), + }); +} diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts new file mode 100644 index 00000000000..f3c72d9e900 --- /dev/null +++ b/packages/contracts/src/auth.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + AuthOrchestrationReadScope, + AuthPluginsManageScope, + AuthStandardClientScopes, + PluginScope, + pluginOperateScope, + pluginReadScope, + satisfiesScope, +} from "./auth.ts"; + +const decodePluginScope = Schema.decodeUnknownSync(PluginScope); + +describe("PluginScope", () => { + it.each(["plugin:test-plugin:read", "plugin:test-plugin:operate"])( + "accepts valid plugin scope %s", + (scope) => { + expect(decodePluginScope(scope)).toBe(scope); + }, + ); + + it.each([ + "plugin:x:read", + "plugin:1test-plugin:read", + "plugin:test_plugin:read", + "plugin:Test-Plugin:read", + "plugin:test-plugin:write", + "plugin:test-plugin", + "test-plugin:read", + `plugin:${"a".repeat(42)}:read`, + ])("rejects invalid plugin scope %s", (scope) => { + expect(() => decodePluginScope(scope)).toThrow(); + }); + + it("builds validated read and operate scope strings", () => { + expect(pluginReadScope("test-plugin")).toBe("plugin:test-plugin:read"); + expect(pluginOperateScope("test-plugin")).toBe("plugin:test-plugin:operate"); + }); +}); + +describe("satisfiesScope", () => { + it("requires exact membership for core scopes", () => { + expect(satisfiesScope(AuthOrchestrationReadScope, [AuthOrchestrationReadScope])).toBe(true); + expect(satisfiesScope(AuthPluginsManageScope, [AuthPluginsManageScope])).toBe(true); + expect(satisfiesScope(AuthPluginsManageScope, [AuthOrchestrationReadScope])).toBe(false); + }); + + it("accepts exact plugin-scope membership", () => { + const required = pluginReadScope("test-plugin"); + + expect(satisfiesScope(required, [required])).toBe(true); + expect(satisfiesScope(required, [pluginOperateScope("test-plugin")])).toBe(false); + }); + + it("allows a full standard client scope bundle to satisfy plugin scopes", () => { + expect(satisfiesScope(pluginReadScope("test-plugin"), AuthStandardClientScopes)).toBe(true); + expect(satisfiesScope(pluginOperateScope("test-plugin"), AuthStandardClientScopes)).toBe(true); + }); + + it("does not treat a partial standard client scope set as implicit plugin access", () => { + const partialStandard = AuthStandardClientScopes.filter( + (scope) => scope !== AuthPluginsManageScope, + ); + + expect(satisfiesScope(pluginReadScope("test-plugin"), partialStandard)).toBe(false); + }); +}); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 70b2899757d..3be9d013e57 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PLUGIN_ID_PATTERN_SOURCE } from "./plugin.ts"; /** * Declares the server's overall authentication posture. @@ -81,6 +82,7 @@ export const AuthAccessReadScope = "access:read" as const; export const AuthAccessWriteScope = "access:write" as const; export const AuthRelayReadScope = "relay:read" as const; export const AuthRelayWriteScope = "relay:write" as const; +export const AuthPluginsManageScope = "plugins:manage" as const; export const AuthEnvironmentScope = Schema.Literals([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, @@ -90,10 +92,29 @@ export const AuthEnvironmentScope = Schema.Literals([ AuthAccessWriteScope, AuthRelayReadScope, AuthRelayWriteScope, + AuthPluginsManageScope, ]); export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type; export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope); export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; +export const isAuthEnvironmentScope = Schema.is(AuthEnvironmentScope); + +const PLUGIN_SCOPE_PATTERN = new RegExp(`^plugin:${PLUGIN_ID_PATTERN_SOURCE}:(read|operate)$`); +export const PluginScope = TrimmedNonEmptyString.check(Schema.isPattern(PLUGIN_SCOPE_PATTERN)).pipe( + Schema.brand("PluginScope"), +); +export type PluginScope = typeof PluginScope.Type; +export const isPluginScope = Schema.is(PluginScope); +const decodePluginScope = Schema.decodeUnknownSync(PluginScope); + +export const pluginReadScope = (id: string): PluginScope => decodePluginScope(`plugin:${id}:read`); +export const pluginOperateScope = (id: string): PluginScope => + decodePluginScope(`plugin:${id}:operate`); + +export const AuthScope = Schema.Union([AuthEnvironmentScope, PluginScope]); +export type AuthScope = typeof AuthScope.Type; +export const AuthScopes = Schema.Array(AuthScope); +export type AuthScopes = typeof AuthScopes.Type; export const AuthStandardClientScopes = [ AuthOrchestrationReadScope, @@ -101,6 +122,7 @@ export const AuthStandardClientScopes = [ AuthTerminalOperateScope, AuthReviewWriteScope, AuthRelayReadScope, + AuthPluginsManageScope, ] as const; export const AuthAdministrativeScopes = [ ...AuthStandardClientScopes, @@ -109,6 +131,20 @@ export const AuthAdministrativeScopes = [ AuthRelayWriteScope, ] as const; +export function satisfiesScope(required: AuthScope, granted: ReadonlyArray): boolean { + if (!isPluginScope(required)) { + return granted.includes(required); + } + return ( + granted.includes(required) || + AuthStandardClientScopes.every((standardScope) => granted.includes(standardScope)) + ); +} + +export const authEnvironmentScopes = ( + scopes: ReadonlyArray, +): ReadonlyArray => scopes.filter(isAuthEnvironmentScope); + export const AuthTokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" as const; export const AuthAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" as const; @@ -150,7 +186,7 @@ export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type; export const AuthBrowserSessionResult = Schema.Struct({ authenticated: Schema.Literal(true), - scopes: AuthEnvironmentScopes, + scopes: AuthScopes, sessionMethod: ServerAuthSessionMethod, expiresAt: Schema.DateTimeUtc, }); @@ -330,14 +366,14 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T export const AuthCreatePairingCredentialInput = Schema.Struct({ label: Schema.optionalKey(TrimmedNonEmptyString), - scopes: Schema.optionalKey(AuthEnvironmentScopes), + scopes: Schema.optionalKey(AuthScopes), }); export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; export const AuthSessionState = Schema.Struct({ authenticated: Schema.Boolean, auth: ServerAuthDescriptor, - scopes: Schema.optionalKey(AuthEnvironmentScopes), + scopes: Schema.optionalKey(AuthScopes), sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), expiresAt: Schema.optionalKey(Schema.DateTimeUtc), }); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index adc5f149cba..e61f15699f1 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -18,7 +18,7 @@ import { AuthPairingLink, AuthRevokeClientSessionInput, AuthRevokePairingLinkInput, - AuthEnvironmentScope, + AuthScope, AuthTokenExchangeRequest, AuthSessionState, AuthWebSocketTicketResult, @@ -117,7 +117,7 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass; + readonly scopes: ReadonlySet; readonly proofKeyThumbprint?: string; readonly expiresAt?: DateTime.DateTime; } diff --git a/packages/contracts/src/plugin.test.ts b/packages/contracts/src/plugin.test.ts index d41ae6cc1f2..889c5cbf226 100644 --- a/packages/contracts/src/plugin.test.ts +++ b/packages/contracts/src/plugin.test.ts @@ -1,12 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { - HOST_API_VERSION, - PluginLockfile, - PluginManifest, - hostApiSatisfies, -} from "./plugin.ts"; +import { HOST_API_VERSION, PluginLockfile, PluginManifest, hostApiSatisfies } from "./plugin.ts"; const decodeManifest = Schema.decodeUnknownSync(PluginManifest); const decodeLockfile = Schema.decodeUnknownSync(PluginLockfile); @@ -51,9 +46,7 @@ describe("PluginManifest", () => { ); it("rejects unknown capabilities", () => { - expect(() => - decodeManifest({ ...minimalManifest, capabilities: ["not-real"] }), - ).toThrow(); + expect(() => decodeManifest({ ...minimalManifest, capabilities: ["not-real"] })).toThrow(); }); it("rejects duplicate capabilities", () => { @@ -81,8 +74,12 @@ describe("PluginManifest", () => { }); it("rejects unsafe entry paths", () => { - expect(() => decodeManifest({ ...minimalManifest, entries: { server: "../server.js" } })).toThrow(); - expect(() => decodeManifest({ ...minimalManifest, entries: { server: "/server.js" } })).toThrow(); + expect(() => + decodeManifest({ ...minimalManifest, entries: { server: "../server.js" } }), + ).toThrow(); + expect(() => + decodeManifest({ ...minimalManifest, entries: { server: "/server.js" } }), + ).toThrow(); }); it("rejects bad hostApi ranges", () => { diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts index 8111288528a..8f531894341 100644 --- a/packages/contracts/src/plugin.ts +++ b/packages/contracts/src/plugin.ts @@ -5,7 +5,8 @@ import { IsoDateTime, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const HOST_API_RANGE_PATTERN = /^[~^]?\d+\.\d+\.\d+$/; -const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]{1,40}$/; +export const PLUGIN_ID_PATTERN_SOURCE = "[a-z][a-z0-9-]{1,40}"; +const PLUGIN_ID_PATTERN = new RegExp(`^${PLUGIN_ID_PATTERN_SOURCE}$`); export const HOST_API_VERSION = "1.0.0"; @@ -119,6 +120,42 @@ export const PluginState = Schema.Literals([ ]); export type PluginState = typeof PluginState.Type; +export class PluginRpcError extends Schema.TaggedErrorClass()("PluginRpcError", { + pluginId: PluginId, + code: Schema.Literals(["not-found", "not-ready", "unauthorized", "invalid-method", "internal"]), + message: Schema.String, + data: Schema.optional(Schema.Unknown), +}) {} + +export const PluginInfo = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString, + version: SemverString, + state: PluginState, + capabilities: Schema.Array(PluginCapability), + hasWeb: Schema.Boolean, + lastError: Schema.NullOr(Schema.String), +}); +export type PluginInfo = typeof PluginInfo.Type; + +export const PluginListResult = Schema.Struct({ + plugins: Schema.Array(PluginInfo), +}); +export type PluginListResult = typeof PluginListResult.Type; + +export const PluginMethodInput = Schema.Struct({ + pluginId: PluginId, + method: TrimmedNonEmptyString, + payload: Schema.optionalKey(Schema.Unknown), +}); +export type PluginMethodInput = typeof PluginMethodInput.Type; + +export const PLUGINS_WS_METHODS = { + list: "plugins.list", + call: "plugins.call", + subscribe: "plugins.subscribe", +} as const; + const LockfileSource = Schema.Struct({ id: TrimmedNonEmptyString, url: TrimmedNonEmptyString, @@ -183,9 +220,8 @@ function compareSemver(left: ParsedSemver, right: ParsedSemver): number { export function hostApiSatisfies(range: string, version: string): boolean { const trimmedRange = range.trim(); - const operator = trimmedRange.startsWith("^") || trimmedRange.startsWith("~") - ? trimmedRange[0] - : ""; + const operator = + trimmedRange.startsWith("^") || trimmedRange.startsWith("~") ? trimmedRange[0] : ""; const target = parseStrictSemver(operator ? trimmedRange.slice(1) : trimmedRange); const actual = parseStrictSemver(version); if (!target || !actual) return false; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..03d22aaff9b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -143,6 +143,12 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + PLUGINS_WS_METHODS, + PluginListResult, + PluginMethodInput, + PluginRpcError, +} from "./plugin.ts"; export const WS_METHODS = { // Project registry methods @@ -218,6 +224,11 @@ export const WS_METHODS = { cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", + // Plugin methods + pluginsList: PLUGINS_WS_METHODS.list, + pluginsCall: PLUGINS_WS_METHODS.call, + pluginsSubscribe: PLUGINS_WS_METHODS.subscribe, + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -681,6 +692,25 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, stream: true, }); +export const WsPluginsListRpc = Rpc.make(PLUGINS_WS_METHODS.list, { + payload: Schema.Struct({}), + success: PluginListResult, + error: EnvironmentAuthorizationError, +}); + +export const WsPluginsCallRpc = Rpc.make(PLUGINS_WS_METHODS.call, { + payload: PluginMethodInput, + success: Schema.Unknown, + error: Schema.Union([PluginRpcError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsSubscribeRpc = Rpc.make(PLUGINS_WS_METHODS.subscribe, { + payload: PluginMethodInput, + success: Schema.Unknown, + error: Schema.Union([PluginRpcError, EnvironmentAuthorizationError]), + stream: true, +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, @@ -743,6 +773,9 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, + WsPluginsListRpc, + WsPluginsCallRpc, + WsPluginsSubscribeRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 55adc2bb1d9..134d1007a51 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1,5 +1,6 @@ import type * as Effect from "effect/Effect"; import type * as SqlClient from "effect/unstable/sql/SqlClient"; +import type * as Stream from "effect/Stream"; export type { PluginCapability, @@ -85,10 +86,7 @@ export interface PluginHostApi { readonly terminals: Effect.Effect; readonly database: Effect.Effect; readonly projectionsRead: Effect.Effect; - readonly environmentsRead: Effect.Effect< - EnvironmentsReadCapability, - PluginCapabilityUnavailable - >; + readonly environmentsRead: Effect.Effect; readonly secrets: Effect.Effect; readonly http: Effect.Effect; readonly sourceControl: Effect.Effect; @@ -111,7 +109,7 @@ export interface PluginStreamDescriptor { readonly method: string; readonly scope: PluginRpcScope; readonly readiness?: PluginReadiness | undefined; - readonly handler: (payload: unknown, ctx: PluginRpcContext) => Effect.Effect; + readonly handler: (payload: unknown, ctx: PluginRpcContext) => Stream.Stream; } export interface PluginHttpDescriptor { From 71a7da6ac9d15019e6cd0d4315a3485ff22ada0f Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 04:53:00 -0400 Subject: [PATCH 06/75] Address slice-2a-2 code review findings - Legacy-session marker rule (Claude review MUST): sessions persisted before plugins:manage joined AuthStandardClientScopes hold only the original five scopes; a frozen AuthStandardClientMarkerScopes bundle now drives the implicit rules so pre-upgrade standard clients keep plugin access and plugins:manage without re-pairing or migration. Tests cover legacy-bundle satisfaction, partial-marker rejection, and that the marker implies no unrelated core scopes. - Document the deliberate invalid-method/unauthorized error-order disclosure in PluginRpcDispatcher (Grok review SHOULD). Rejected review findings (evidence in debate dir): scope-filter sites are display-DTO-only (grant consumption reads persisted scopes unfiltered); the token-exchange allow-list is a strict superset of the old parser's (upstream never had workflow scopes). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .../server/src/plugins/PluginRpcDispatcher.ts | 4 +++ packages/contracts/src/auth.test.ts | 27 ++++++++++++-- packages/contracts/src/auth.ts | 36 +++++++++++++++---- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/apps/server/src/plugins/PluginRpcDispatcher.ts b/apps/server/src/plugins/PluginRpcDispatcher.ts index 6dfed20c99a..52da0a18e46 100644 --- a/apps/server/src/plugins/PluginRpcDispatcher.ts +++ b/apps/server/src/plugins/PluginRpcDispatcher.ts @@ -138,6 +138,10 @@ const mapPluginHandlerStreamCause = (pluginId: PluginId, cause: Cause.Cause Effect.gen(function* () { const runtime = yield* lookupRuntime(registry, pluginId); diff --git a/packages/contracts/src/auth.test.ts b/packages/contracts/src/auth.test.ts index f3c72d9e900..e605e3459e7 100644 --- a/packages/contracts/src/auth.test.ts +++ b/packages/contracts/src/auth.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { + AuthAccessWriteScope, AuthOrchestrationReadScope, AuthPluginsManageScope, + AuthRelayReadScope, + AuthStandardClientMarkerScopes, AuthStandardClientScopes, PluginScope, pluginOperateScope, @@ -59,11 +62,29 @@ describe("satisfiesScope", () => { expect(satisfiesScope(pluginOperateScope("test-plugin"), AuthStandardClientScopes)).toBe(true); }); - it("does not treat a partial standard client scope set as implicit plugin access", () => { - const partialStandard = AuthStandardClientScopes.filter( + it("treats the legacy five-scope standard bundle as a full standard client", () => { + // Sessions persisted before plugins:manage joined AuthStandardClientScopes + // hold exactly the marker scopes — they keep implicit plugin access AND + // plugins:manage after an upgrade, without re-pairing. + const legacyStandard = AuthStandardClientScopes.filter( (scope) => scope !== AuthPluginsManageScope, ); - expect(satisfiesScope(pluginReadScope("test-plugin"), partialStandard)).toBe(false); + expect(satisfiesScope(pluginReadScope("test-plugin"), legacyStandard)).toBe(true); + expect(satisfiesScope(pluginOperateScope("test-plugin"), legacyStandard)).toBe(true); + expect(satisfiesScope(AuthPluginsManageScope, legacyStandard)).toBe(true); + }); + + it("does not treat a partial marker scope set as implicit plugin access", () => { + const partialMarker = AuthStandardClientMarkerScopes.filter( + (scope) => scope !== AuthRelayReadScope, + ); + + expect(satisfiesScope(pluginReadScope("test-plugin"), partialMarker)).toBe(false); + expect(satisfiesScope(AuthPluginsManageScope, partialMarker)).toBe(false); + }); + + it("does not let the marker imply unrelated core scopes", () => { + expect(satisfiesScope(AuthAccessWriteScope, [...AuthStandardClientMarkerScopes])).toBe(false); }); }); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 3be9d013e57..8e29f2342d5 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -131,14 +131,38 @@ export const AuthAdministrativeScopes = [ AuthRelayWriteScope, ] as const; +/** + * The original standard-client scope bundle, used as the durable MARKER for + * the implicit grant rules below. Sessions persisted before newer scopes + * (e.g. `plugins:manage`) joined `AuthStandardClientScopes` still hold + * exactly these five — they must keep behaving as full standard clients + * after an upgrade, without re-pairing or a data migration. + * + * Do NOT add new scopes here: this list is frozen by definition. + */ +export const AuthStandardClientMarkerScopes = [ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthTerminalOperateScope, + AuthReviewWriteScope, + AuthRelayReadScope, +] as const; + +const holdsStandardClientMarker = (granted: ReadonlyArray): boolean => + AuthStandardClientMarkerScopes.every((markerScope) => granted.includes(markerScope)); + export function satisfiesScope(required: AuthScope, granted: ReadonlyArray): boolean { - if (!isPluginScope(required)) { - return granted.includes(required); + if (isPluginScope(required)) { + // A full standard (local, full-trust) client implicitly satisfies every + // plugin scope; restricted/managed tokens must carry them explicitly. + return granted.includes(required) || holdsStandardClientMarker(granted); + } + if (required === AuthPluginsManageScope) { + // plugins:manage joined the standard bundle after sessions began being + // persisted; the marker keeps pre-upgrade standard sessions whole. + return granted.includes(required) || holdsStandardClientMarker(granted); } - return ( - granted.includes(required) || - AuthStandardClientScopes.every((standardScope) => granted.includes(standardScope)) - ); + return granted.includes(required); } export const authEnvironmentScopes = ( From ed58230ddf001d01f6adcb51dde85e4c60a9c85a Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 05:21:16 -0400 Subject: [PATCH 07/75] Add mechanical plugin capability facades Eight of the ten capability facades, each a curated wrapper over an existing core service, handed to plugins only when declared in the manifest (undeclared accessors keep the typed defect; agents/vcs stubs remain for the next slice). - database: execute/withTransaction (namespace by convention, documented) - secrets: get/set/delete/list under an enforced plugin:: prefix - environments.read: environment id/descriptor + project reads - projections.read: thread/turn/message/activity reads, capped - textGeneration: one-shot generation - sourceControl: provider detection + the GitHub CLI operations that exist upstream (checks/reviews/merge omitted - no backing) - terminals: PTY spawn/observe/sendInput/kill - http: PluginHttpRegistry + /hooks/plugins/:pluginId/* route layer (:param matcher, public/token auth via satisfiesScope, body caps, attributed 500s, no-oracle 404s), routes registered at activation and removed on plugin scope close Implemented by GPT-5.5 via codex exec (assembly-line slice 2a-3a). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/plugins/PluginHost.test.ts | 207 +++++++++- apps/server/src/plugins/PluginHost.ts | 156 ++++++- apps/server/src/plugins/PluginHttpRegistry.ts | 100 +++++ .../src/plugins/PluginHttpRoutes.test.ts | 268 ++++++++++++ apps/server/src/plugins/PluginHttpRoutes.ts | 184 +++++++++ .../capabilities/DatabaseCapability.ts | 10 + .../EnvironmentsReadCapability.ts | 37 ++ .../plugins/capabilities/HttpCapability.ts | 8 + .../capabilities/PluginCapabilities.test.ts | 378 +++++++++++++++++ .../capabilities/ProjectionsReadCapability.ts | 86 ++++ .../plugins/capabilities/SecretsCapability.ts | 49 +++ .../capabilities/SourceControlCapability.ts | 27 ++ .../capabilities/TerminalsCapability.ts | 59 +++ .../capabilities/TextGenerationCapability.ts | 14 + apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 69 +++- packages/plugin-sdk/src/index.ts | 391 +++++++++++++++++- 17 files changed, 1993 insertions(+), 52 deletions(-) create mode 100644 apps/server/src/plugins/PluginHttpRegistry.ts create mode 100644 apps/server/src/plugins/PluginHttpRoutes.test.ts create mode 100644 apps/server/src/plugins/PluginHttpRoutes.ts create mode 100644 apps/server/src/plugins/capabilities/DatabaseCapability.ts create mode 100644 apps/server/src/plugins/capabilities/EnvironmentsReadCapability.ts create mode 100644 apps/server/src/plugins/capabilities/HttpCapability.ts create mode 100644 apps/server/src/plugins/capabilities/PluginCapabilities.test.ts create mode 100644 apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts create mode 100644 apps/server/src/plugins/capabilities/SecretsCapability.ts create mode 100644 apps/server/src/plugins/capabilities/SourceControlCapability.ts create mode 100644 apps/server/src/plugins/capabilities/TerminalsCapability.ts create mode 100644 apps/server/src/plugins/capabilities/TextGenerationCapability.ts diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index d969841ed93..463e8c61f84 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -1,9 +1,15 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { PluginId, PluginManifest, type PluginLockfilePlugin } from "@t3tools/contracts/plugin"; +import { + PluginId, + PluginManifest, + type PluginCapability, + type PluginLockfilePlugin, +} from "@t3tools/contracts/plugin"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -11,9 +17,20 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as NodeURL from "node:url"; import * as ServerConfig from "../config.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { runMigrations } from "../persistence/Migrations.ts"; import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; +import * as ProjectionThreadMessages from "../persistence/Services/ProjectionThreadMessages.ts"; +import * as ProjectionTurns from "../persistence/Services/ProjectionTurns.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as PluginHostModule from "./PluginHost.ts"; +import * as PluginHttpRegistry from "./PluginHttpRegistry.ts"; import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; import * as PluginMigrator from "./PluginMigrator.ts"; import * as PluginModuleLoaderLayer from "./PluginModuleLoader.ts"; @@ -21,12 +38,115 @@ import { pluginDataDir, pluginVersionDir } from "./PluginPaths.ts"; import * as PluginRuntimeRegistryLayer from "./PluginRuntimeRegistry.ts"; const encodeManifestJson = Schema.encodeEffect(Schema.fromJsonString(PluginManifest)); +const unexpectedCapabilityUse = () => Effect.die(new Error("unexpected capability use in host test")); const testLayer = PluginHostModule.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayer.layer), Layer.provideMerge(PluginModuleLoaderLayer.layer), Layer.provideMerge(PluginMigrator.layer), Layer.provideMerge(PluginRuntimeRegistryLayer.layer), + Layer.provideMerge(PluginHttpRegistry.layer), + Layer.provideMerge( + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: unexpectedCapabilityUse, + set: unexpectedCapabilityUse, + create: unexpectedCapabilityUse, + getOrCreateRandom: unexpectedCapabilityUse, + remove: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: unexpectedCapabilityUse(), + getDescriptor: unexpectedCapabilityUse(), + }), + ), + Layer.provideMerge( + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getCommandReadModel: unexpectedCapabilityUse, + getSnapshot: unexpectedCapabilityUse, + getShellSnapshot: unexpectedCapabilityUse, + getArchivedShellSnapshot: unexpectedCapabilityUse, + getSnapshotSequence: unexpectedCapabilityUse, + getCounts: unexpectedCapabilityUse, + getActiveProjectByWorkspaceRoot: unexpectedCapabilityUse, + getProjectShellById: unexpectedCapabilityUse, + getFirstActiveThreadIdByProjectId: unexpectedCapabilityUse, + getThreadOwnerById: unexpectedCapabilityUse, + getThreadCheckpointContext: unexpectedCapabilityUse, + getFullThreadDiffContext: unexpectedCapabilityUse, + getThreadShellById: unexpectedCapabilityUse, + getThreadDetailById: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + Layer.mock(ProjectionTurns.ProjectionTurnRepository)({ + upsertByTurnId: unexpectedCapabilityUse, + replacePendingTurnStart: unexpectedCapabilityUse, + getPendingTurnStartByThreadId: unexpectedCapabilityUse, + deletePendingTurnStartByThreadId: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + getByTurnId: unexpectedCapabilityUse, + clearCheckpointTurnConflict: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + Layer.mock(ProjectionThreadMessages.ProjectionThreadMessageRepository)({ + upsert: unexpectedCapabilityUse, + getByMessageId: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + Layer.mock(ProjectionThreadActivities.ProjectionThreadActivityRepository)({ + upsert: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + Layer.mock(TextGeneration.TextGeneration)({ + generateCommitMessage: unexpectedCapabilityUse, + generatePrContent: unexpectedCapabilityUse, + generateBranchName: unexpectedCapabilityUse, + generateThreadTitle: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + get: unexpectedCapabilityUse, + resolveHandle: unexpectedCapabilityUse, + resolve: unexpectedCapabilityUse, + discover: unexpectedCapabilityUse(), + }), + ), + Layer.provideMerge( + Layer.mock(GitHubCli.GitHubCli)({ + execute: unexpectedCapabilityUse, + listOpenPullRequests: unexpectedCapabilityUse, + getPullRequest: unexpectedCapabilityUse, + getRepositoryCloneUrls: unexpectedCapabilityUse, + createRepository: unexpectedCapabilityUse, + createPullRequest: unexpectedCapabilityUse, + getDefaultBranch: unexpectedCapabilityUse, + checkoutPullRequest: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + Layer.mock(TerminalManager.TerminalManager)({ + open: unexpectedCapabilityUse, + attachStream: unexpectedCapabilityUse, + write: unexpectedCapabilityUse, + resize: unexpectedCapabilityUse, + clear: unexpectedCapabilityUse, + restart: unexpectedCapabilityUse, + close: unexpectedCapabilityUse, + subscribe: unexpectedCapabilityUse, + subscribeMetadata: unexpectedCapabilityUse, + }), + ), Layer.provideMerge(NodeSqliteClient.layerMemory()), Layer.provideMerge( Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-host-" })), @@ -37,6 +157,14 @@ const testLayer = PluginHostModule.layer.pipe( const layer = it.layer(testLayer); const now = "2026-07-03T00:00:00.000Z"; +const decodeCapabilityMarker = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + httpBasePath: Schema.String, + terminalsUnavailable: Schema.Boolean, + }), + ), +); const makeLockEntry = (overrides: Partial = {}): PluginLockfilePlugin => ({ version: "1.0.0", @@ -87,6 +215,7 @@ export default { const installPlugin = (input: { readonly pluginId: PluginId; readonly manifestHostApi?: string; + readonly capabilities?: ReadonlyArray; readonly entrySource?: string; readonly lockEntry?: Partial; }) => @@ -104,7 +233,7 @@ const installPlugin = (input: { name: "Test Plugin", version: entry.version, hostApi: input.manifestHostApi ?? "^1.0.0", - capabilities: [], + capabilities: input.capabilities ?? [], entries: { server: "server.js" }, }); yield* fs.writeFileString(path.join(pluginDir, "manifest.json"), encodedManifest); @@ -116,6 +245,43 @@ const installPlugin = (input: { return { pluginDir, entry }; }); +const capabilityGateEntrySource = () => ` +import { createRequire } from "node:module"; +const require = createRequire(${JSON.stringify(NodeURL.pathToFileURL(import.meta.url).href)}); +const Effect = require("effect/Effect"); +const NodeFs = require("node:fs"); + +export default { + register(hostApi) { + return Effect.gen(function* () { + const http = yield* hostApi.http; + let terminalsUnavailable = false; + const terminalsExit = yield* Effect.exit(hostApi.terminals); + terminalsUnavailable = terminalsExit._tag === "Failure"; + NodeFs.mkdirSync(hostApi.config.dataDir, { recursive: true }); + NodeFs.writeFileSync( + hostApi.config.dataDir + "/capabilities.json", + JSON.stringify({ httpBasePath: http.basePath, terminalsUnavailable }), + ); + return { + http: [ + { + method: "POST", + path: "/ping/:name", + auth: "public", + handler: (request) => + Effect.succeed({ + status: 200, + body: { name: request.params.name }, + }), + }, + ], + }; + }); + }, +}; +`; + layer("PluginModuleLoader", (it) => { it.effect("loads a definePlugin-shaped default export from inside the plugin dir", () => Effect.gen(function* () { @@ -239,6 +405,43 @@ layer("PluginHost", (it) => { }), ); + it.effect("passes only declared capabilities and registers http routes on activation", () => + Effect.gen(function* () { + const pluginId = PluginId.make("capability-plugin"); + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* PluginHostModule.PluginHost; + const httpRegistry = yield* PluginHttpRegistry.PluginHttpRegistry; + + yield* installPlugin({ + pluginId, + capabilities: ["http"], + entrySource: capabilityGateEntrySource(), + }); + + yield* host.start; + yield* Effect.yieldNow; + + const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const capabilityFile = yield* fs.readFileString(path.join(dataDir, "capabilities.json")); + assert.deepEqual(yield* decodeCapabilityMarker(capabilityFile), { + httpBasePath: "/hooks/plugins/capability-plugin", + terminalsUnavailable: true, + }); + + const match = yield* httpRegistry.match({ + pluginId, + method: "POST", + path: "/ping/chris", + }); + assert.isTrue(Option.isSome(match)); + if (Option.isSome(match)) { + assert.deepEqual(match.value.params, { name: "chris" }); + } + }), + ); + it.effect("does not load anything when T3_NO_PLUGINS is set", () => Effect.gen(function* () { const pluginId = PluginId.make("disabled-env"); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 8bf538dd4f4..f63d7616b48 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -27,10 +27,30 @@ import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import packageJson from "../../package.json" with { type: "json" }; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; +import * as ProjectionThreadMessages from "../persistence/Services/ProjectionThreadMessages.ts"; +import * as ProjectionTurns from "../persistence/Services/ProjectionTurns.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import { makeDatabaseCapability } from "./capabilities/DatabaseCapability.ts"; +import { makeEnvironmentsReadCapability } from "./capabilities/EnvironmentsReadCapability.ts"; +import { makeHttpCapability } from "./capabilities/HttpCapability.ts"; +import { makeProjectionsReadCapability } from "./capabilities/ProjectionsReadCapability.ts"; +import { makeSecretsCapability } from "./capabilities/SecretsCapability.ts"; +import { makeSourceControlCapability } from "./capabilities/SourceControlCapability.ts"; +import { makeTerminalsCapability } from "./capabilities/TerminalsCapability.ts"; +import { makeTextGenerationCapability } from "./capabilities/TextGenerationCapability.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { PluginHttpRegistry } from "./PluginHttpRegistry.ts"; import { PluginMigrator } from "./PluginMigrator.ts"; import { PluginModuleLoader } from "./PluginModuleLoader.ts"; import { makePluginLogger } from "./PluginLogger.ts"; @@ -123,27 +143,88 @@ const unavailable = (capability: string) => const makeHostApi = (input: { readonly pluginId: PluginId; + readonly capabilities: ReadonlyArray; readonly dataDir: string; readonly logger: PluginLogger; -}): PluginHostApi => ({ - hostApiVersion: HOST_API_VERSION, - config: { - appVersion: APP_VERSION, + readonly deps: { + readonly sql: SqlClient.SqlClient; + readonly secretStore: ServerSecretStore.ServerSecretStore["Service"]; + readonly config: ServerConfig.ServerConfig["Service"]; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly environment: ServerEnvironment.ServerEnvironment["Service"]; + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly turns: ProjectionTurns.ProjectionTurnRepository["Service"]; + readonly messages: ProjectionThreadMessages.ProjectionThreadMessageRepository["Service"]; + readonly activities: ProjectionThreadActivities.ProjectionThreadActivityRepository["Service"]; + readonly textGeneration: TextGeneration.TextGeneration["Service"]; + readonly sourceControlRegistry: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; + readonly github: GitHubCli.GitHubCli["Service"]; + readonly terminals: TerminalManager.TerminalManager["Service"]; + }; +}): PluginHostApi => { + const capabilities = new Set(input.capabilities); + const available = (capability: PluginManifest["capabilities"][number], value: A) => + capabilities.has(capability) ? Effect.succeed(value) : unavailable(capability); + + return { hostApiVersion: HOST_API_VERSION, - dataDir: input.dataDir, - logger: input.logger, - }, - agents: unavailable("agents"), - vcs: unavailable("vcs"), - terminals: unavailable("terminals"), - database: unavailable("database"), - projectionsRead: unavailable("projections.read"), - environmentsRead: unavailable("environments.read"), - secrets: unavailable("secrets"), - http: unavailable("http"), - sourceControl: unavailable("sourceControl"), - textGeneration: unavailable("textGeneration"), -}); + config: { + appVersion: APP_VERSION, + hostApiVersion: HOST_API_VERSION, + dataDir: input.dataDir, + logger: input.logger, + }, + agents: unavailable("agents"), + vcs: unavailable("vcs"), + terminals: available( + "terminals", + makeTerminalsCapability({ + pluginId: input.pluginId, + manager: input.deps.terminals, + }), + ), + database: available("database", makeDatabaseCapability(input.deps.sql)), + projectionsRead: available( + "projections.read", + makeProjectionsReadCapability({ + snapshots: input.deps.snapshots, + turns: input.deps.turns, + messages: input.deps.messages, + activities: input.deps.activities, + }), + ), + environmentsRead: available( + "environments.read", + makeEnvironmentsReadCapability({ + environment: input.deps.environment, + snapshots: input.deps.snapshots, + }), + ), + secrets: available( + "secrets", + makeSecretsCapability({ + pluginId: input.pluginId, + store: input.deps.secretStore, + config: input.deps.config, + fileSystem: input.deps.fileSystem, + path: input.deps.path, + }), + ), + http: available("http", makeHttpCapability(input.pluginId)), + sourceControl: available( + "sourceControl", + makeSourceControlCapability({ + registry: input.deps.sourceControlRegistry, + github: input.deps.github, + }), + ), + textGeneration: available( + "textGeneration", + makeTextGenerationCapability(input.deps.textGeneration), + ), + }; +}; const upgradeLockfileEntry = ( entry: PluginLockfilePlugin, @@ -210,7 +291,19 @@ export const make = Effect.fn("PluginHost.make")(function* () { const loader = yield* PluginModuleLoader; const migrator = yield* PluginMigrator; const registry = yield* PluginRuntimeRegistry; + const httpRegistry = yield* PluginHttpRegistry; const clock = yield* Clock.Clock; + const sql = yield* SqlClient.SqlClient; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const environment = yield* ServerEnvironment.ServerEnvironment; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const turns = yield* ProjectionTurns.ProjectionTurnRepository; + const messages = yield* ProjectionThreadMessages.ProjectionThreadMessageRepository; + const activities = yield* ProjectionThreadActivities.ProjectionThreadActivityRepository; + const textGeneration = yield* TextGeneration.TextGeneration; + const sourceControlRegistry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const github = yield* GitHubCli.GitHubCli; + const terminals = yield* TerminalManager.TerminalManager; const readManifest = (pluginDir: string) => fs @@ -269,7 +362,28 @@ export const make = Effect.fn("PluginHost.make")(function* () { const readiness = yield* Deferred.make(); const logger = makePluginLogger(pluginId); const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); - const hostApi = makeHostApi({ pluginId, dataDir, logger }); + const hostApi = makeHostApi({ + pluginId, + capabilities: manifest.capabilities, + dataDir, + logger, + deps: { + sql, + secretStore, + config, + fileSystem: fs, + path, + environment, + snapshots, + turns, + messages, + activities, + textGeneration, + sourceControlRegistry, + github, + terminals, + }, + }); const activation = Effect.gen(function* () { yield* fs.makeDirectory(dataDir, { recursive: true }); @@ -280,6 +394,10 @@ export const make = Effect.fn("PluginHost.make")(function* () { if (registration.recover) { yield* registration.recover(); } + if (manifest.capabilities.includes("http") && (registration.http?.length ?? 0) > 0) { + yield* httpRegistry.put(pluginId, registration.http ?? []); + yield* Scope.addFinalizer(scope, httpRegistry.remove(pluginId)); + } yield* registry.put(pluginId, { manifest, registration, readiness, scope }); for (const service of registration.services ?? []) { yield* startService({ pluginId, logger, service }).pipe( diff --git a/apps/server/src/plugins/PluginHttpRegistry.ts b/apps/server/src/plugins/PluginHttpRegistry.ts new file mode 100644 index 00000000000..861c6ed3015 --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRegistry.ts @@ -0,0 +1,100 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { PluginHttpDescriptor } from "@t3tools/plugin-sdk"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +export interface MatchedPluginHttpRoute { + readonly descriptor: PluginHttpDescriptor; + readonly params: Readonly>; +} + +export class PluginHttpRegistry extends Context.Service< + PluginHttpRegistry, + { + readonly put: ( + pluginId: PluginId, + routes: ReadonlyArray, + ) => Effect.Effect; + readonly remove: (pluginId: PluginId) => Effect.Effect; + readonly match: (input: { + readonly pluginId: PluginId; + readonly method: string; + readonly path: string; + }) => Effect.Effect>; + } +>()("t3/plugins/PluginHttpRegistry") {} + +const normalizePath = (path: string) => { + const trimmed = path.trim(); + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withSlash.length > 1 ? withSlash.replace(/\/+$/u, "") : "/"; +}; + +const pathSegments = (path: string) => + normalizePath(path) + .split("/") + .filter((segment) => segment.length > 0); + +const matchPath = ( + pattern: string, + path: string, +): Readonly> | null => { + const patternSegments = pathSegments(pattern); + const requestSegments = pathSegments(path); + if (patternSegments.length !== requestSegments.length) return null; + + const params: Record = {}; + for (let index = 0; index < patternSegments.length; index++) { + const patternSegment = patternSegments[index]; + const requestSegment = requestSegments[index]; + if (patternSegment === undefined || requestSegment === undefined) return null; + if (patternSegment.startsWith(":")) { + const name = patternSegment.slice(1); + if (name.length === 0) return null; + params[name] = decodeURIComponent(requestSegment); + continue; + } + if (patternSegment !== requestSegment) return null; + } + return params; +}; + +export const make = Effect.fn("PluginHttpRegistry.make")(function* () { + const routesRef = yield* Ref.make( + new Map>(), + ); + + return PluginHttpRegistry.of({ + put: (pluginId, routes) => + Ref.update(routesRef, (current) => { + const next = new Map(current); + next.set(pluginId, routes); + return next; + }), + remove: (pluginId) => + Ref.update(routesRef, (current) => { + const next = new Map(current); + next.delete(pluginId); + return next; + }), + match: ({ pluginId, method, path }) => + Ref.get(routesRef).pipe( + Effect.map((routes) => { + const normalizedMethod = method.toUpperCase(); + for (const descriptor of routes.get(pluginId) ?? []) { + if (descriptor.method.toUpperCase() !== normalizedMethod) continue; + const params = matchPath(descriptor.path, path); + if (params) { + return Option.some({ descriptor, params }); + } + } + return Option.none(); + }), + ), + }); +}); + +export const layer = Layer.effect(PluginHttpRegistry, make()); diff --git a/apps/server/src/plugins/PluginHttpRoutes.test.ts b/apps/server/src/plugins/PluginHttpRoutes.test.ts new file mode 100644 index 00000000000..a68ac69b4c3 --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRoutes.test.ts @@ -0,0 +1,268 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { pluginOperateScope } from "@t3tools/contracts"; +import { PluginId } from "@t3tools/contracts/plugin"; +import type { PluginHttpDescriptor } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServer, +} from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { PluginHttpRegistry } from "./PluginHttpRegistry.ts"; +import * as PluginHttpRegistryLayer from "./PluginHttpRegistry.ts"; +import { pluginHttpRouteLayer } from "./PluginHttpRoutes.ts"; + +const pluginId = PluginId.make("http-plugin"); + +const canBindLoopback = async () => { + const NodeNet = await import("node:net"); + return await new Promise((resolve) => { + const server = NodeNet.createServer(); + server.once("error", () => { + resolve(false); + }); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + server.close(() => resolve(true)); + }); + }); +}; + +const loopbackAvailable = await canBindLoopback(); + +const nodeHttpServerLayer = Layer.unwrap( + Effect.promise(() => import("node:http")).pipe( + Effect.map((NodeHttp) => + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + ), +); + +const makeAuthLayer = ( + authenticateHttpRequest: EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"], +) => + Layer.succeed( + EnvironmentAuth.EnvironmentAuth, + EnvironmentAuth.EnvironmentAuth.of({ + authenticateHttpRequest, + } as EnvironmentAuth.EnvironmentAuth["Service"]), + ); + +const authenticatedAuthLayer = makeAuthLayer(() => + Effect.succeed({ + sessionId: "session-1" as any, + subject: "test", + method: "bearer-access-token", + scopes: [pluginOperateScope(pluginId)], + }), +); + +const unauthenticatedAuthLayer = makeAuthLayer(() => + Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError()), +); + +const makeRouteLayer = (authLayer = authenticatedAuthLayer) => + HttpRouter.serve(pluginHttpRouteLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge(PluginHttpRegistryLayer.layer), + Layer.provideMerge(authLayer), + Layer.provideMerge(nodeHttpServerLayer), + Layer.provideMerge(FetchHttpClient.layer), + ); + +const routeUrl = (path: string) => + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + assert.fail(`Expected TCP address, got ${String(address)}`); + } + return `http://127.0.0.1:${address.port}${path}`; + }); + +const postText = (path: string, body: string) => + Effect.gen(function* () { + const url = yield* routeUrl(path); + return yield* HttpClient.execute( + HttpClientRequest.post(url).pipe(HttpClientRequest.bodyText(body, "text/plain")), + ); + }); + +const getPath = (path: string) => + Effect.gen(function* () { + const url = yield* routeUrl(path); + return yield* HttpClient.get(url); + }); + +it.layer(PluginHttpRegistryLayer.layer)("PluginHttpRegistry", (it) => { + it.effect("matches method and path params for registered plugin routes", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "POST", + path: "/incoming/:name", + auth: "public", + handler: () => Effect.succeed({ status: 204 }), + }, + ]); + + const matched = yield* registry.match({ + pluginId, + method: "post", + path: "/incoming/alice", + }); + + assert.isTrue(Option.isSome(matched)); + if (Option.isSome(matched)) { + assert.deepEqual(matched.value.params, { name: "alice" }); + } + }), + ); +}); + +if (loopbackAvailable) { + it.layer(makeRouteLayer())("plugin http route layer", (it) => { + it.effect("round-trips a public route through the router", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "POST", + path: "/echo/:name", + auth: "public", + handler: (request) => + Effect.succeed({ + status: 201, + headers: { "x-plugin-test": "ok" }, + body: { + name: request.params.name, + query: request.query.q, + body: new TextDecoder().decode(request.body), + }, + }), + }, + ]); + + const response = yield* postText("/hooks/plugins/http-plugin/echo/chris?q=1", "hello"); + const body = yield* response.json; + + assert.equal(response.status, 201); + assert.equal(response.headers["x-plugin-test"], "ok"); + assert.deepEqual(body, { name: "chris", query: "1", body: "hello" }); + }), + ); + + it.effect("returns 413 when the request body exceeds the route cap", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "POST", + path: "/limited", + auth: "public", + maxBodyBytes: 4, + handler: () => Effect.succeed({ status: 204 }), + }, + ]); + + const response = yield* postText("/hooks/plugins/http-plugin/limited", "12345"); + + assert.equal(response.status, 413); + }), + ); + + it.effect("returns a generic 404 for unknown plugin routes", () => + Effect.gen(function* () { + const response = yield* getPath("/hooks/plugins/missing-plugin/route"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + + it.effect("returns 500 for handler defects and continues serving later requests", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "POST", + path: "/boom", + auth: "public", + handler: () => Effect.die(new Error("boom")), + }, + { + method: "POST", + path: "/ok", + auth: "public", + handler: () => Effect.succeed({ status: 200, body: "ok" }), + }, + ]); + + const failed = yield* postText("/hooks/plugins/http-plugin/boom", ""); + assert.equal(failed.status, 500); + assert.equal(yield* failed.text, "Internal Server Error"); + + const ok = yield* postText("/hooks/plugins/http-plugin/ok", ""); + assert.equal(ok.status, 200); + assert.equal(yield* ok.text, "ok"); + }), + ); + }); + + it.layer(makeRouteLayer(unauthenticatedAuthLayer))("plugin http token route layer", (it) => { + it.effect("rejects unauthenticated token routes", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "POST", + path: "/token", + auth: "token", + handler: () => Effect.succeed({ status: 200 }), + }, + ]); + + const response = yield* postText("/hooks/plugins/http-plugin/token", ""); + + assert.equal(response.status, 401); + }), + ); + }); + + it.layer(makeRouteLayer())("plugin http authenticated route layer", (it) => { + it.effect("allows token routes when the session has plugin operate scope", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "POST", + path: "/token", + auth: "token", + handler: () => Effect.succeed({ status: 200, body: "authorized" }), + }, + ] satisfies ReadonlyArray); + + const response = yield* postText("/hooks/plugins/http-plugin/token", ""); + + assert.equal(response.status, 200); + assert.equal(yield* response.text, "authorized"); + }), + ); + }); +} else { + describe.skip("plugin http live route layer", () => { + it("skips live router assertions when local TCP bind is unavailable", () => {}); + }); +} diff --git a/apps/server/src/plugins/PluginHttpRoutes.ts b/apps/server/src/plugins/PluginHttpRoutes.ts new file mode 100644 index 00000000000..f278e4b8356 --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRoutes.ts @@ -0,0 +1,184 @@ +import { + pluginOperateScope, + satisfiesScope, +} from "@t3tools/contracts"; +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { PluginHttpResponse } from "@t3tools/plugin-sdk"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { + failEnvironmentAuthInvalid, + failEnvironmentInternal, + failEnvironmentScopeRequired, +} from "../auth/http.ts"; +import { PluginHttpRegistry } from "./PluginHttpRegistry.ts"; +import { makePluginLogger } from "./PluginLogger.ts"; + +const ROUTE_PREFIX = "/hooks/plugins"; +const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; +const MAX_BODY_BYTES = 8 * 1024 * 1024; +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]{1,40}$/u; + +function bodyLimit(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_MAX_BODY_BYTES; + return Math.min(MAX_BODY_BYTES, Math.max(0, Math.floor(value))); +} + +function parsePluginPath(pathname: string): + | { + readonly pluginId: PluginId; + readonly routePath: string; + } + | null { + if (!pathname.startsWith(`${ROUTE_PREFIX}/`)) return null; + const suffix = pathname.slice(`${ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + const rawPluginId = separatorIndex === -1 ? suffix : suffix.slice(0, separatorIndex); + if (!PLUGIN_ID_PATTERN.test(rawPluginId)) return null; + const rest = separatorIndex === -1 ? "" : suffix.slice(separatorIndex + 1); + return { + pluginId: rawPluginId as PluginId, + routePath: rest.length === 0 ? "/" : `/${rest}`, + }; +} + +function requestQuery(url: URL): Readonly>> { + const query: Record> = {}; + for (const [key, value] of url.searchParams.entries()) { + const existing = query[key]; + if (existing === undefined) { + query[key] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + query[key] = [existing, value]; + } + } + return query; +} + +const contentLength = (request: HttpServerRequest.HttpServerRequest): number | null => { + const raw = request.headers["content-length"]; + if (!raw) return null; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +}; + +const authenticatePluginRoute = (pluginId: PluginId) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* serverAuth.authenticateHttpRequest(request).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), + ); + const requiredScope = pluginOperateScope(pluginId); + if (!satisfiesScope(requiredScope, session.scopes)) { + return yield* failEnvironmentScopeRequired(requiredScope); + } + }); + +function toHttpResponse(response: PluginHttpResponse): HttpServerResponse.HttpServerResponse { + const options = { + status: response.status, + ...(response.headers === undefined ? {} : { headers: response.headers }), + }; + const body = response.body; + if (body === undefined || body === null) { + return HttpServerResponse.empty(options); + } + if (body instanceof Uint8Array) { + return HttpServerResponse.uint8Array(body, options); + } + if (typeof body === "string") { + return HttpServerResponse.text(body, options); + } + return HttpServerResponse.jsonUnsafe(body, options); +} + +export const pluginHttpRouteLayer = HttpRouter.add( + "*", + `${ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const parsed = parsePluginPath(url.value.pathname); + if (!parsed) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const registry = yield* PluginHttpRegistry; + const matched = yield* registry.match({ + pluginId: parsed.pluginId, + method: request.method, + path: parsed.routePath, + }); + if (Option.isNone(matched)) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + const { descriptor, params } = matched.value; + if (descriptor.auth === "token") { + yield* authenticatePluginRoute(parsed.pluginId); + } + + const maxBodyBytes = bodyLimit(descriptor.maxBodyBytes); + const declaredLength = contentLength(request); + if (declaredLength !== null && declaredLength > maxBodyBytes) { + return HttpServerResponse.text("Payload Too Large", { status: 413 }); + } + + const body = new Uint8Array(yield* request.arrayBuffer); + if (body.byteLength > maxBodyBytes) { + return HttpServerResponse.text("Payload Too Large", { status: 413 }); + } + + const logger = makePluginLogger(parsed.pluginId); + const exit = yield* descriptor + .handler( + { + method: request.method, + params, + query: requestQuery(url.value), + headers: request.headers, + body, + }, + { pluginId: parsed.pluginId, logger }, + ) + .pipe(Effect.exit); + + if (exit._tag === "Failure") { + yield* logger.error("plugin http handler failed", { + method: request.method, + path: parsed.routePath, + cause: Cause.pretty(exit.cause), + }); + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + } + + return toHttpResponse(exit.value); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + Effect.catchCause((cause) => + Effect.logWarning("plugin http route failed", { cause: Cause.pretty(cause) }).pipe( + Effect.as(HttpServerResponse.text("Internal Server Error", { status: 500 })), + ), + ), + ), +); diff --git a/apps/server/src/plugins/capabilities/DatabaseCapability.ts b/apps/server/src/plugins/capabilities/DatabaseCapability.ts new file mode 100644 index 00000000000..94744860579 --- /dev/null +++ b/apps/server/src/plugins/capabilities/DatabaseCapability.ts @@ -0,0 +1,10 @@ +import type { DatabaseCapability } from "@t3tools/plugin-sdk"; +import type * as SqlClient from "effect/unstable/sql/SqlClient"; + +export function makeDatabaseCapability(sql: SqlClient.SqlClient): DatabaseCapability { + return { + execute: (statement, params = []) => + sql.unsafe>(statement, params).unprepared, + withTransaction: (effect) => sql.withTransaction(effect), + }; +} diff --git a/apps/server/src/plugins/capabilities/EnvironmentsReadCapability.ts b/apps/server/src/plugins/capabilities/EnvironmentsReadCapability.ts new file mode 100644 index 00000000000..9241b6990d4 --- /dev/null +++ b/apps/server/src/plugins/capabilities/EnvironmentsReadCapability.ts @@ -0,0 +1,37 @@ +import type { EnvironmentsReadCapability } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; + +export function makeEnvironmentsReadCapability(input: { + readonly environment: ServerEnvironment.ServerEnvironment["Service"]; + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; +}): EnvironmentsReadCapability { + return { + getEnvironmentId: input.environment.getEnvironmentId, + getDescriptor: input.environment.getDescriptor, + listProjects: input.snapshots + .getShellSnapshot() + .pipe(Effect.map((snapshot) => snapshot.projects)), + getProjectById: (projectId) => + input.snapshots.getProjectShellById(projectId).pipe( + Effect.map( + Option.match({ + onNone: () => null, + onSome: (project) => project, + }), + ), + ), + resolveProjectByWorkspaceRoot: (workspaceRoot) => + input.snapshots.getActiveProjectByWorkspaceRoot(workspaceRoot).pipe( + Effect.map( + Option.match({ + onNone: () => null, + onSome: (project) => project, + }), + ), + ), + }; +} diff --git a/apps/server/src/plugins/capabilities/HttpCapability.ts b/apps/server/src/plugins/capabilities/HttpCapability.ts new file mode 100644 index 00000000000..978d53cb609 --- /dev/null +++ b/apps/server/src/plugins/capabilities/HttpCapability.ts @@ -0,0 +1,8 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { HttpCapability } from "@t3tools/plugin-sdk"; + +export function makeHttpCapability(pluginId: PluginId): HttpCapability { + return { + basePath: `/hooks/plugins/${pluginId}`, + }; +} diff --git a/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts new file mode 100644 index 00000000000..dfad9ebfaf1 --- /dev/null +++ b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts @@ -0,0 +1,378 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts/plugin"; +import type { TerminalAttachStreamEvent, TerminalSessionSnapshot } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; +import { makeDatabaseCapability } from "./DatabaseCapability.ts"; +import { makeEnvironmentsReadCapability } from "./EnvironmentsReadCapability.ts"; +import { makeProjectionsReadCapability } from "./ProjectionsReadCapability.ts"; +import { makeSecretsCapability } from "./SecretsCapability.ts"; +import { makeSourceControlCapability } from "./SourceControlCapability.ts"; +import { makeTerminalsCapability } from "./TerminalsCapability.ts"; +import { makeTextGenerationCapability } from "./TextGenerationCapability.ts"; + +class RollbackTestError extends Data.TaggedError("RollbackTestError") {} + +it.effect("database executes parameterized SQL and rolls back failed transactions", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const database = makeDatabaseCapability(sql); + + yield* database.execute("CREATE TABLE p_test_plugin_items (id TEXT PRIMARY KEY, value TEXT)"); + yield* database.execute("INSERT INTO p_test_plugin_items (id, value) VALUES (?, ?)", [ + "one", + "kept", + ]); + const rows = yield* database.execute( + "SELECT id, value FROM p_test_plugin_items WHERE id = ?", + ["one"], + ); + assert.deepEqual(rows, [{ id: "one", value: "kept" }]); + + yield* database + .withTransaction( + Effect.gen(function* () { + yield* database.execute("INSERT INTO p_test_plugin_items (id, value) VALUES (?, ?)", [ + "two", + "rolled-back", + ]); + return yield* new RollbackTestError(); + }), + ) + .pipe(Effect.flip); + + const afterRollback = yield* database.execute( + "SELECT id FROM p_test_plugin_items WHERE id = ?", + ["two"], + ); + assert.deepEqual(afterRollback, []); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), +); + +it.effect("secrets enforce and strip the plugin key prefix", () => + Effect.gen(function* () { + const pluginId = PluginId.make("secret-plugin"); + const store = yield* ServerSecretStore.ServerSecretStore; + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secrets = makeSecretsCapability({ pluginId, store, config, fileSystem, path }); + + const value = new TextEncoder().encode("secret-value"); + yield* secrets.set("api-key", value); + yield* secrets.set("plugin:other:key", new TextEncoder().encode("scoped")); + + const stored = yield* secrets.get("api-key"); + assert.deepEqual(Array.from(stored ?? []), Array.from(value)); + assert.deepEqual(yield* secrets.list, ["api-key", "plugin:other:key"]); + assert.isTrue(Option.isNone(yield* store.get("plugin:other:key"))); + assert.isTrue(Option.isSome(yield* store.get(`plugin:${pluginId}:plugin:other:key`))); + + yield* secrets.delete("api-key"); + assert.isNull(yield* secrets.get("api-key")); + }).pipe( + Effect.provide( + ServerSecretStore.layer.pipe( + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-secrets-" })), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), +); + +it.effect("environments read delegates to environment and projection snapshots", () => + Effect.gen(function* () { + const projectShell = { id: "project-1", title: "Project" } as any; + const project = { id: "project-1", workspaceRoot: "/repo" } as any; + const capability = makeEnvironmentsReadCapability({ + environment: { + getEnvironmentId: Effect.succeed("env-1" as any), + getDescriptor: Effect.succeed({ environmentId: "env-1", label: "Local" } as any), + }, + snapshots: { + getShellSnapshot: () => Effect.succeed({ projects: [projectShell], threads: [] } as any), + getProjectShellById: () => Effect.succeed(Option.some(projectShell)), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some(project)), + } as any, + }); + + assert.equal(yield* capability.getEnvironmentId, "env-1"); + assert.deepEqual(yield* capability.listProjects, [projectShell]); + assert.deepEqual(yield* capability.getProjectById("project-1" as any), projectShell); + assert.deepEqual(yield* capability.resolveProjectByWorkspaceRoot("/repo"), project); + }), +); + +it.effect("projections read returns contract-shaped thread data with caps", () => + Effect.gen(function* () { + const threadShell = { id: "thread-1", title: "Thread" } as any; + const threadDetail = { id: "thread-1", messages: [], activities: [] } as any; + const capability = makeProjectionsReadCapability({ + snapshots: { + getThreadShellById: () => Effect.succeed(Option.some(threadShell)), + getThreadDetailById: () => Effect.succeed(Option.some(threadDetail)), + } as any, + turns: { + listByThreadId: () => + Effect.succeed([ + { + threadId: "thread-1", + turnId: "turn-1", + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: null, + state: "completed", + requestedAt: "2026-07-03T00:00:00.000Z", + startedAt: null, + completedAt: null, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }, + ] as any), + } as any, + messages: { + listByThreadId: () => + Effect.succeed([ + { + messageId: "message-1", + threadId: "thread-1", + turnId: "turn-1", + role: "assistant", + text: "hello", + isStreaming: false, + createdAt: "2026-07-03T00:00:00.000Z", + updatedAt: "2026-07-03T00:00:01.000Z", + }, + { + messageId: "message-2", + threadId: "thread-1", + turnId: "turn-1", + role: "assistant", + text: "ignored by cap", + isStreaming: false, + createdAt: "2026-07-03T00:00:02.000Z", + updatedAt: "2026-07-03T00:00:03.000Z", + }, + ] as any), + } as any, + activities: { + listByThreadId: () => + Effect.succeed([ + { + activityId: "activity-1", + threadId: "thread-1", + turnId: null, + tone: "info", + kind: "note", + summary: "summary", + payload: { ok: true }, + createdAt: "2026-07-03T00:00:00.000Z", + }, + ] as any), + } as any, + }); + + assert.deepEqual(yield* capability.getThreadShellById("thread-1" as any), threadShell); + assert.deepEqual(yield* capability.getThreadDetailById("thread-1" as any), threadDetail); + assert.equal((yield* capability.listTurnsByThreadId({ threadId: "thread-1" as any })).length, 1); + assert.deepEqual( + yield* capability.listMessagesByThreadId({ threadId: "thread-1" as any, limit: 1 }), + [ + { + id: "message-1" as any, + role: "assistant", + text: "hello", + turnId: "turn-1" as any, + streaming: false, + createdAt: "2026-07-03T00:00:00.000Z", + updatedAt: "2026-07-03T00:00:01.000Z", + }, + ], + ); + assert.deepEqual(yield* capability.listActivitiesByThreadId({ threadId: "thread-1" as any }), [ + { + id: "activity-1" as any, + tone: "info", + kind: "note", + summary: "summary", + payload: { ok: true }, + turnId: null, + createdAt: "2026-07-03T00:00:00.000Z", + }, + ]); + }), +); + +it.effect("text generation delegates the existing one-shot operations", () => + Effect.gen(function* () { + const capability = makeTextGenerationCapability({ + generateCommitMessage: (input) => + Effect.succeed({ subject: `commit:${input.branch}`, body: input.stagedSummary }), + generatePrContent: (input) => Effect.succeed({ title: input.headBranch, body: input.diffSummary }), + generateBranchName: (input) => Effect.succeed({ branch: `feature/${input.message}` }), + generateThreadTitle: (input) => Effect.succeed({ title: input.message.slice(0, 10) }), + }); + const modelSelection = { instanceId: "codex", model: "gpt-test" } as any; + + assert.deepEqual( + yield* capability.generateCommitMessage({ + cwd: "/repo", + branch: "main", + stagedSummary: "summary", + stagedPatch: "patch", + modelSelection, + }), + { subject: "commit:main", body: "summary" }, + ); + assert.deepEqual( + yield* capability.generatePrContent({ + cwd: "/repo", + baseBranch: "main", + headBranch: "feature", + commitSummary: "commits", + diffSummary: "diff", + diffPatch: "patch", + modelSelection, + }), + { title: "feature", body: "diff" }, + ); + assert.deepEqual( + yield* capability.generateBranchName({ cwd: "/repo", message: "work", modelSelection }), + { branch: "feature/work" }, + ); + assert.deepEqual( + yield* capability.generateThreadTitle({ cwd: "/repo", message: "hello world", modelSelection }), + { title: "hello worl" }, + ); + }), +); + +it.effect("source control exposes provider detection and existing GitHub CLI PR operations", () => + Effect.gen(function* () { + const createInputs: unknown[] = []; + const capability = makeSourceControlCapability({ + registry: { + resolveHandle: () => + Effect.succeed({ + provider: {} as any, + context: { + provider: { kind: "github", name: "GitHub", baseUrl: "https://github.com" }, + remoteName: "origin", + remoteUrl: "git@github.com:owner/repo.git", + }, + }), + discover: Effect.succeed([{ kind: "github", status: "available" } as any]), + } as any, + github: { + listOpenPullRequests: () => + Effect.succeed([{ number: 1, title: "PR", url: "https://github.com/o/r/pull/1", baseRefName: "main", headRefName: "feature" }]), + getPullRequest: () => + Effect.succeed({ number: 2, title: "Detail", url: "https://github.com/o/r/pull/2", baseRefName: "main", headRefName: "fix" }), + createPullRequest: (input: any) => + Effect.sync(() => { + createInputs.push(input); + }), + getDefaultBranch: () => Effect.succeed("main"), + checkoutPullRequest: () => Effect.void, + } as any, + }); + + assert.deepEqual(yield* capability.detectProvider({ cwd: "/repo" }), { + provider: { kind: "github", name: "GitHub", baseUrl: "https://github.com" }, + remoteName: "origin", + remoteUrl: "git@github.com:owner/repo.git", + }); + assert.equal((yield* capability.discoverProviders)[0]?.kind, "github"); + assert.equal((yield* capability.listOpenPullRequests({ cwd: "/repo", headSelector: "feature" }))[0]?.number, 1); + assert.equal((yield* capability.getPullRequest({ cwd: "/repo", reference: "2" })).number, 2); + yield* capability.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature", + title: "PR", + bodyFile: "/tmp/body.md", + }); + assert.equal(createInputs.length, 1); + assert.equal(yield* capability.getDefaultBranch({ cwd: "/repo" }), "main"); + yield* capability.checkoutPullRequest({ cwd: "/repo", reference: "2" }); + }), +); + +it.effect("terminals spawn through a plugin-owned shell session and expose observe/input/kill", () => + Effect.gen(function* () { + const writes: string[] = []; + const closes: unknown[] = []; + const snapshot: TerminalSessionSnapshot = { + threadId: "plugin:terminal-plugin:run-1", + terminalId: "run-1", + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "run", + updatedAt: "2026-07-03T00:00:00.000Z", + }; + const capability = makeTerminalsCapability({ + pluginId: PluginId.make("terminal-plugin"), + manager: { + open: () => Effect.succeed(snapshot), + attachStream: (_input: any, listener: (event: TerminalAttachStreamEvent) => Effect.Effect) => + listener({ type: "snapshot", snapshot } satisfies TerminalAttachStreamEvent).pipe( + Effect.as(() => undefined), + ), + write: (input: any) => + Effect.sync(() => { + writes.push(input.data); + }), + close: (input: any) => + Effect.sync(() => { + closes.push(input); + }), + } as any, + }); + + const spawned = yield* capability.spawn({ + terminalId: "run-1", + cwd: "/repo", + command: "echo", + args: ["hello world"], + }); + assert.deepEqual(spawned.handle, { + threadId: "plugin:terminal-plugin:run-1", + terminalId: "run-1", + }); + assert.deepEqual(writes, ["'echo' 'hello world'\n"]); + + const events: TerminalAttachStreamEvent[] = []; + const unsubscribe = yield* capability.observe(spawned.handle, (event) => + Effect.sync(() => { + events.push(event); + }), + ); + unsubscribe(); + assert.equal(events[0]?.type, "snapshot"); + + yield* capability.sendInput({ ...spawned.handle, data: "q" }); + yield* capability.kill({ ...spawned.handle, deleteHistory: true }); + assert.equal(writes.at(-1), "q"); + assert.deepEqual(closes, [{ ...spawned.handle, deleteHistory: true }]); + }), +); diff --git a/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts new file mode 100644 index 00000000000..5dfdd55cfff --- /dev/null +++ b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts @@ -0,0 +1,86 @@ +import type { + OrchestrationMessage, + OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import type { ProjectionsReadCapability } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProjectionTurns from "../../persistence/Services/ProjectionTurns.ts"; +import * as ProjectionThreadMessages from "../../persistence/Services/ProjectionThreadMessages.ts"; +import * as ProjectionThreadActivities from "../../persistence/Services/ProjectionThreadActivities.ts"; + +const DEFAULT_LIMIT = 500; +const MAX_LIMIT = 2_000; + +const boundedLimit = (limit: number | undefined) => + Math.max(0, Math.min(MAX_LIMIT, limit ?? DEFAULT_LIMIT)); + +function toMessage(row: ProjectionThreadMessages.ProjectionThreadMessage): OrchestrationMessage { + return { + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments === undefined ? {} : { attachments: row.attachments }), + turnId: row.turnId, + streaming: row.isStreaming, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toActivity( + row: ProjectionThreadActivities.ProjectionThreadActivity, +): OrchestrationThreadActivity { + return { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + ...(row.sequence === undefined ? {} : { sequence: row.sequence }), + createdAt: row.createdAt, + }; +} + +export function makeProjectionsReadCapability(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly turns: ProjectionTurns.ProjectionTurnRepository["Service"]; + readonly messages: ProjectionThreadMessages.ProjectionThreadMessageRepository["Service"]; + readonly activities: ProjectionThreadActivities.ProjectionThreadActivityRepository["Service"]; +}): ProjectionsReadCapability { + return { + getThreadShellById: (threadId) => + input.snapshots.getThreadShellById(threadId).pipe( + Effect.map( + Option.match({ + onNone: () => null, + onSome: (thread) => thread, + }), + ), + ), + getThreadDetailById: (threadId) => + input.snapshots.getThreadDetailById(threadId).pipe( + Effect.map( + Option.match({ + onNone: () => null, + onSome: (thread) => thread, + }), + ), + ), + listTurnsByThreadId: ({ threadId, limit }) => + input.turns + .listByThreadId({ threadId }) + .pipe(Effect.map((rows) => rows.slice(0, boundedLimit(limit)))), + listMessagesByThreadId: ({ threadId, limit }) => + input.messages + .listByThreadId({ threadId }) + .pipe(Effect.map((rows) => rows.slice(0, boundedLimit(limit)).map(toMessage))), + listActivitiesByThreadId: ({ threadId, limit }) => + input.activities + .listByThreadId({ threadId }) + .pipe(Effect.map((rows) => rows.slice(0, boundedLimit(limit)).map(toActivity))), + }; +} diff --git a/apps/server/src/plugins/capabilities/SecretsCapability.ts b/apps/server/src/plugins/capabilities/SecretsCapability.ts new file mode 100644 index 00000000000..2fb6d496166 --- /dev/null +++ b/apps/server/src/plugins/capabilities/SecretsCapability.ts @@ -0,0 +1,49 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { SecretsCapability } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; + +import * as ServerConfig from "../../config.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; + +const keyPrefix = (pluginId: PluginId) => `plugin:${pluginId}:`; + +export function makeSecretsCapability(input: { + readonly pluginId: PluginId; + readonly store: ServerSecretStore.ServerSecretStore["Service"]; + readonly config: ServerConfig.ServerConfig["Service"]; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; +}): SecretsCapability { + const prefix = keyPrefix(input.pluginId); + const scoped = (name: string) => `${prefix}${name}`; + + return { + get: (name) => + input.store.get(scoped(name)).pipe( + Effect.map( + Option.match({ + onNone: () => null, + onSome: (value) => value, + }), + ), + ), + set: (name, value) => input.store.set(scoped(name), value), + delete: (name) => input.store.remove(scoped(name)), + list: input.fileSystem.readDirectory(input.config.secretsDir).pipe( + Effect.map((entries) => + entries + .filter((entry) => entry.endsWith(".bin")) + .map((entry) => entry.slice(0, -".bin".length)) + .filter((name) => name.startsWith(prefix)) + .map((name) => name.slice(prefix.length)) + .sort(), + ), + Effect.catch((cause) => + cause.reason._tag === "NotFound" ? Effect.succeed([]) : Effect.fail(cause), + ), + ), + }; +} diff --git a/apps/server/src/plugins/capabilities/SourceControlCapability.ts b/apps/server/src/plugins/capabilities/SourceControlCapability.ts new file mode 100644 index 00000000000..5303c1998c9 --- /dev/null +++ b/apps/server/src/plugins/capabilities/SourceControlCapability.ts @@ -0,0 +1,27 @@ +import type { SourceControlCapability } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; + +import * as GitHubCli from "../../sourceControl/GitHubCli.ts"; +import * as SourceControlProviderRegistry from "../../sourceControl/SourceControlProviderRegistry.ts"; + +export function makeSourceControlCapability(input: { + readonly registry: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; + readonly github: GitHubCli.GitHubCli["Service"]; +}): SourceControlCapability { + return { + detectProvider: ({ cwd }) => + input.registry.resolveHandle({ cwd }).pipe( + Effect.map((handle) => ({ + provider: handle.context?.provider ?? null, + remoteName: handle.context?.remoteName ?? null, + remoteUrl: handle.context?.remoteUrl ?? null, + })), + ), + discoverProviders: input.registry.discover, + listOpenPullRequests: (request) => input.github.listOpenPullRequests(request), + getPullRequest: (request) => input.github.getPullRequest(request), + createPullRequest: (request) => input.github.createPullRequest(request), + getDefaultBranch: (request) => input.github.getDefaultBranch(request), + checkoutPullRequest: (request) => input.github.checkoutPullRequest(request), + }; +} diff --git a/apps/server/src/plugins/capabilities/TerminalsCapability.ts b/apps/server/src/plugins/capabilities/TerminalsCapability.ts new file mode 100644 index 00000000000..0e8b4c5ab2e --- /dev/null +++ b/apps/server/src/plugins/capabilities/TerminalsCapability.ts @@ -0,0 +1,59 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { TerminalSessionHandle, TerminalsCapability } from "@t3tools/plugin-sdk"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Random from "effect/Random"; + +import * as TerminalManager from "../../terminal/Manager.ts"; + +const quoteShellArg = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +const commandLine = (command: string, args: ReadonlyArray | undefined) => + [command, ...(args ?? [])].map(quoteShellArg).join(" "); + +const defaultHandle = (pluginId: PluginId, terminalId: string): TerminalSessionHandle => ({ + threadId: `plugin:${pluginId}:${terminalId}`, + terminalId, +}); + +export function makeTerminalsCapability(input: { + readonly pluginId: PluginId; + readonly manager: TerminalManager.TerminalManager["Service"]; +}): TerminalsCapability { + return { + spawn: (request) => + Effect.gen(function* () { + const terminalId = + request.terminalId ?? + `run-${yield* Clock.currentTimeMillis}-${(yield* Random.nextInt).toString(36)}`; + const handle = defaultHandle(input.pluginId, terminalId); + const snapshot = yield* input.manager.open({ + ...handle, + cwd: request.cwd, + ...(request.env === undefined ? {} : { env: request.env }), + cols: request.cols ?? 120, + rows: request.rows ?? 30, + }); + yield* input.manager.write({ + ...handle, + data: `${commandLine(request.command, request.args)}\n`, + }); + return { handle, snapshot }; + }), + observe: (handle, listener) => + input.manager.attachStream( + { + ...handle, + restartIfNotRunning: false, + }, + listener, + ), + sendInput: (request) => input.manager.write(request), + kill: (request) => + input.manager.close({ + threadId: request.threadId, + terminalId: request.terminalId, + ...(request.deleteHistory === undefined ? {} : { deleteHistory: request.deleteHistory }), + }), + }; +} diff --git a/apps/server/src/plugins/capabilities/TextGenerationCapability.ts b/apps/server/src/plugins/capabilities/TextGenerationCapability.ts new file mode 100644 index 00000000000..4411ceb60e5 --- /dev/null +++ b/apps/server/src/plugins/capabilities/TextGenerationCapability.ts @@ -0,0 +1,14 @@ +import type { TextGenerationCapability } from "@t3tools/plugin-sdk"; + +import * as TextGeneration from "../../textGeneration/TextGeneration.ts"; + +export function makeTextGenerationCapability( + textGeneration: TextGeneration.TextGeneration["Service"], +): TextGenerationCapability { + return { + generateCommitMessage: (input) => textGeneration.generateCommitMessage(input), + generatePrContent: (input) => textGeneration.generatePrContent(input), + generateBranchName: (input) => textGeneration.generateBranchName(input), + generateThreadTitle: (input) => textGeneration.generateThreadTitle(input), + }; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c02649d7b2e..fcf419cb91e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -115,6 +115,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; +import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as Data from "effect/Data"; @@ -753,6 +754,7 @@ const buildAppUnderTest = (options?: { }), ), ), + Layer.provideMerge(PluginHttpRegistry.layer), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0111b8b7c61..e6daa678538 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,8 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; +import { pluginHttpRouteLayer } from "./plugins/PluginHttpRoutes.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; import * as PluginMigrator from "./plugins/PluginMigrator.ts"; @@ -51,6 +53,7 @@ import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery.ts"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; @@ -90,6 +93,9 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { ProjectionThreadActivityRepositoryLive } from "./persistence/Layers/ProjectionThreadActivities.ts"; +import { ProjectionThreadMessageRepositoryLive } from "./persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "./persistence/Layers/ProjectionTurns.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -190,27 +196,6 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const PluginRuntimeRegistryLayerLive = PluginRuntimeRegistry.layer; -const PluginLockfileStoreLayerLive = PluginLockfileStore.layer; -const PluginHostLayerLive = PluginHost.layer.pipe( - Layer.provideMerge(PluginLockfileStoreLayerLive), - Layer.provideMerge(PluginModuleLoader.layer), - Layer.provideMerge(PluginMigrator.layer), - Layer.provideMerge(PluginRuntimeRegistryLayerLive), -); -const PluginRpcDispatcherLayerLive = PluginRpcDispatcher.layer.pipe( - Layer.provideMerge(PluginRuntimeRegistryLayerLive), -); -const PluginCatalogLayerLive = PluginCatalog.layer.pipe( - Layer.provideMerge(PluginLockfileStoreLayerLive), - Layer.provideMerge(PluginRuntimeRegistryLayerLive), -); -const PluginLayerLive = Layer.mergeAll( - PluginHostLayerLive, - PluginRpcDispatcherLayerLive, - PluginCatalogLayerLive, -); - const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -265,6 +250,13 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); +const PluginProjectionReadLayerLive = Layer.mergeAll( + OrchestrationProjectionSnapshotQueryLive, + ProjectionTurnRepositoryLive, + ProjectionThreadMessageRepositoryLive, + ProjectionThreadActivityRepositoryLive, +).pipe(Layer.provide(RepositoryIdentityResolver.layer)); + const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); const TerminalLayerLive = TerminalManager.layer.pipe( @@ -312,6 +304,40 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); +const PluginRuntimeRegistryLayerLive = PluginRuntimeRegistry.layer; +const PluginHttpRegistryLayerLive = PluginHttpRegistry.layer; +const PluginLockfileStoreLayerLive = PluginLockfileStore.layer; +const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( + PluginProjectionReadLayerLive, + SourceControlProviderRegistryLayerLive, + GitHubCli.layer, + TextGeneration.layer, + TerminalLayerLive, + ServerSecretStore.layer, + ServerEnvironment.layer, +); +const PluginHostLayerLive = PluginHost.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginModuleLoader.layer), + Layer.provideMerge(PluginMigrator.layer), + Layer.provideMerge(PluginRuntimeRegistryLayerLive), + Layer.provideMerge(PluginHttpRegistryLayerLive), + Layer.provideMerge(PluginHostCapabilityDepsLayerLive), +); +const PluginRpcDispatcherLayerLive = PluginRpcDispatcher.layer.pipe( + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginCatalogLayerLive = PluginCatalog.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginLayerLive = Layer.mergeAll( + PluginHostLayerLive, + PluginRpcDispatcherLayerLive, + PluginCatalogLayerLive, + PluginHttpRegistryLayerLive, +); + const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), @@ -386,6 +412,7 @@ export const makeRoutesLayer = Layer.mergeAll( ), otlpTracesProxyRouteLayer, assetRouteLayer, + pluginHttpRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 134d1007a51..ed5b13b18b7 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1,3 +1,26 @@ +import type { + ChangeRequestState, + ChatAttachment, + EnvironmentId, + ExecutionEnvironmentDescriptor, + MessageId, + ModelSelection, + OrchestrationCheckpointFile, + OrchestrationCheckpointStatus, + OrchestrationMessage, + OrchestrationProject, + OrchestrationProjectShell, + OrchestrationThread, + OrchestrationThreadActivity, + OrchestrationThreadShell, + ProjectId, + SourceControlProviderDiscoveryItem, + SourceControlProviderInfo, + TerminalAttachStreamEvent, + TerminalSessionSnapshot, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as SqlClient from "effect/unstable/sql/SqlClient"; import type * as Stream from "effect/Stream"; @@ -46,36 +69,358 @@ export interface VcsCapability { } export interface TerminalsCapability { - readonly open: (input: unknown) => Effect.Effect; + /** + * Open a plugin-owned shell terminal and write the requested command line. + * + * The server terminal manager exposes PTY shell sessions, not raw process + * handles, so command execution is shell-backed. `env` is passed to the shell + * session and `args` are shell-quoted before the first write. + */ + readonly spawn: (input: TerminalSpawnInput) => Effect.Effect; + + /** + * Attach to a plugin terminal and receive its initial snapshot plus live + * output/lifecycle events. The returned function unsubscribes the listener. + */ + readonly observe: ( + input: TerminalSessionHandle, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, Error>; + + /** + * Write raw input to a running plugin terminal session. + */ + readonly sendInput: ( + input: TerminalSessionHandle & { readonly data: string }, + ) => Effect.Effect; + + /** + * Close a plugin terminal session. This maps to the server terminal close + * operation and does not expose UI resize/clear metadata controls. + */ + readonly kill: ( + input: TerminalSessionHandle & { readonly deleteHistory?: boolean }, + ) => Effect.Effect; } export interface DatabaseCapability { - readonly sql: SqlClient.SqlClient; + /** + * Execute trusted plugin SQL and return decoded row objects. + * + * Plugin tables are namespaced by convention as `p__*`. Runtime + * queries are not policed: plugins run with full SQL trust. The migration + * gate is the only enforcement point for database namespace rules. + */ + readonly execute: ( + sql: string, + params?: ReadonlyArray, + ) => Effect.Effect>, Error>; + + /** + * Run an Effect inside the shared SQL client's transaction boundary. + */ + readonly withTransaction: ( + effect: Effect.Effect, + ) => Effect.Effect; } export interface ProjectionsReadCapability { - readonly getSnapshot: (input: unknown) => Effect.Effect; + /** + * Read a single active thread shell by id. The lookup is intentionally + * id-keyed and not owner-filtered. + */ + readonly getThreadShellById: (threadId: ThreadId) => Effect.Effect; + + /** + * Read a single active thread detail snapshot by id. The lookup is + * intentionally id-keyed and not owner-filtered. + */ + readonly getThreadDetailById: (threadId: ThreadId) => Effect.Effect; + + /** + * List projected turn rows for a thread, including pending placeholders. + */ + readonly listTurnsByThreadId: (input: { + readonly threadId: ThreadId; + readonly limit?: number; + }) => Effect.Effect, Error>; + + /** + * List projected thread messages in creation order with a bounded result cap. + */ + readonly listMessagesByThreadId: (input: { + readonly threadId: ThreadId; + readonly limit?: number; + }) => Effect.Effect, Error>; + + /** + * List projected thread activities in runtime sequence order with a bounded + * result cap. + */ + readonly listActivitiesByThreadId: (input: { + readonly threadId: ThreadId; + readonly limit?: number; + }) => Effect.Effect, Error>; } export interface EnvironmentsReadCapability { - readonly list: Effect.Effect>; + /** + * Read the stable server environment id. + */ + readonly getEnvironmentId: Effect.Effect; + + /** + * Read the current execution environment descriptor. + */ + readonly getDescriptor: Effect.Effect; + + /** + * List active project shells from the orchestration projection. + */ + readonly listProjects: Effect.Effect, Error>; + + /** + * Read a single active project shell by id. + */ + readonly getProjectById: ( + projectId: ProjectId, + ) => Effect.Effect; + + /** + * Resolve an active project by exact workspace root. + */ + readonly resolveProjectByWorkspaceRoot: ( + workspaceRoot: string, + ) => Effect.Effect; } export interface SecretsCapability { - readonly get: (name: string) => Effect.Effect; - readonly set: (name: string, value: Uint8Array) => Effect.Effect; + /** + * Read a plugin-scoped secret. The host prepends `plugin::` and strips it + * from returned names, so plugins cannot address keys outside their prefix. + */ + readonly get: (name: string) => Effect.Effect; + + /** + * Set a plugin-scoped secret under the enforced `plugin::` key prefix. + */ + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + + /** + * Delete a plugin-scoped secret. Missing keys are treated as already deleted. + */ + readonly delete: (name: string) => Effect.Effect; + + /** + * List plugin-scoped secret names with the enforced prefix stripped. + */ + readonly list: Effect.Effect, Error>; } export interface HttpCapability { - readonly baseUrl: string | null; + /** + * Base path for this plugin's registered HTTP hooks. + * + * Routes are mounted under `/hooks/plugins//...` and are only + * registered when the plugin declares the `http` capability. + */ + readonly basePath: string; } export interface SourceControlCapability { - readonly listPullRequests: (input: unknown) => Effect.Effect>; + /** + * Detect the source-control provider context for a repository root. + */ + readonly detectProvider: ( + input: { readonly cwd: string }, + ) => Effect.Effect; + + /** + * List configured source-control providers and auth availability. + */ + readonly discoverProviders: Effect.Effect, Error>; + + /** + * List open GitHub pull requests for a head selector. This exposes the + * existing GitHub CLI primitive; checks, reviews, and merge are not available + * in the backing service and are intentionally omitted. + */ + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, Error>; + + /** + * Read GitHub pull request details by number, URL, or branch reference. + */ + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + /** + * Create a GitHub pull request using a body file already present on disk. + */ + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + /** + * Read the default branch reported by the GitHub CLI for the current repo. + */ + readonly getDefaultBranch: (input: { readonly cwd: string }) => Effect.Effect; + + /** + * Check out a GitHub pull request by number, URL, or branch reference. + */ + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; } export interface TextGenerationCapability { - readonly generateText: (input: unknown) => Effect.Effect; + /** + * Generate a commit message from staged change context. + */ + readonly generateCommitMessage: ( + input: CommitMessageGenerationInput, + ) => Effect.Effect; + + /** + * Generate pull request title/body content from branch and diff context. + */ + readonly generatePrContent: ( + input: PrContentGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise branch name from a user message and optional + * attachments. + */ + readonly generateBranchName: ( + input: BranchNameGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise thread title from a user's first message. + */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; +} + +export interface TerminalSessionHandle { + readonly threadId: string; + readonly terminalId: string; +} + +export interface TerminalSpawnInput { + readonly cwd: string; + readonly command: string; + readonly args?: ReadonlyArray | undefined; + readonly env?: Record | undefined; + readonly terminalId?: string | undefined; + readonly cols?: number | undefined; + readonly rows?: number | undefined; +} + +export interface TerminalSpawnResult { + readonly handle: TerminalSessionHandle; + readonly snapshot: TerminalSessionSnapshot; +} + +export interface ProjectionTurnRecord { + readonly threadId: ThreadId; + readonly turnId: TurnId | null; + readonly pendingMessageId: MessageId | null; + readonly sourceProposedPlanThreadId: ThreadId | null; + readonly sourceProposedPlanId: string | null; + readonly assistantMessageId: MessageId | null; + readonly state: "pending" | "running" | "interrupted" | "completed" | "error"; + readonly requestedAt: string; + readonly startedAt: string | null; + readonly completedAt: string | null; + readonly checkpointTurnCount: number | null; + readonly checkpointRef: string | null; + readonly checkpointStatus: OrchestrationCheckpointStatus | null; + readonly checkpointFiles: ReadonlyArray; +} + +export interface SourceControlProviderDetectionResult { + readonly provider: SourceControlProviderInfo | null; + readonly remoteName: string | null; + readonly remoteUrl: string | null; +} + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: ChangeRequestState | undefined; + readonly isCrossRepository?: boolean | undefined; + readonly headRepositoryNameWithOwner?: string | null | undefined; + readonly headRepositoryOwnerLogin?: string | null | undefined; +} + +export interface CommitMessageGenerationInput { + readonly cwd: string; + readonly branch: string | null; + readonly stagedSummary: string; + readonly stagedPatch: string; + readonly includeBranch?: boolean; + readonly modelSelection: ModelSelection; +} + +export interface CommitMessageGenerationResult { + readonly subject: string; + readonly body: string; + readonly branch?: string | undefined; +} + +export interface PrContentGenerationInput { + readonly cwd: string; + readonly baseBranch: string; + readonly headBranch: string; + readonly commitSummary: string; + readonly diffSummary: string; + readonly diffPatch: string; + readonly modelSelection: ModelSelection; +} + +export interface PrContentGenerationResult { + readonly title: string; + readonly body: string; +} + +export interface BranchNameGenerationInput { + readonly cwd: string; + readonly message: string; + readonly attachments?: ReadonlyArray | undefined; + readonly modelSelection: ModelSelection; +} + +export interface BranchNameGenerationResult { + readonly branch: string; +} + +export interface ThreadTitleGenerationInput { + readonly cwd: string; + readonly message: string; + readonly attachments?: ReadonlyArray | undefined; + readonly modelSelection: ModelSelection; +} + +export interface ThreadTitleGenerationResult { + readonly title: string; } export interface PluginHostApi { @@ -113,10 +458,36 @@ export interface PluginStreamDescriptor { } export interface PluginHttpDescriptor { + /** HTTP method to match, for example `GET` or `POST`. */ readonly method: string; + /** Plugin-local route path, with `:param` segments supported. */ readonly path: string; + /** Public routes skip auth; token routes require `plugin::operate`. */ readonly auth: "public" | "token"; - readonly handler: (request: unknown, ctx: PluginRpcContext) => Effect.Effect; + /** + * Maximum request body size in bytes. Defaults to 1 MiB and is capped by the + * host at 8 MiB. + */ + readonly maxBodyBytes?: number | undefined; + /** Handle a matched HTTP request and return a serializable response. */ + readonly handler: ( + request: PluginHttpRequest, + ctx: PluginRpcContext, + ) => Effect.Effect; +} + +export interface PluginHttpRequest { + readonly method: string; + readonly params: Readonly>; + readonly query: Readonly>>; + readonly headers: Readonly>; + readonly body: Uint8Array; +} + +export interface PluginHttpResponse { + readonly status: number; + readonly headers?: Readonly> | undefined; + readonly body?: string | Uint8Array | ReadonlyArray | Readonly> | null; } export interface PluginServiceContext { From b50ca8c0785761c0eeb6788c39bec6d8a8cc8d24 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 05:32:17 -0400 Subject: [PATCH 08/75] Address slice-2a-3a code review findings - Secret names must match a safe path-segment grammar (Claude review): the backing store maps keys to file paths, so separators/colons/ traversal in a name are now rejected before they reach it - Plugin HTTP body read is incremental with a hard cap (Claude review): replaces buffer-then-check, bounding memory on public webhook routes; oversize -> 413, malformed -> 400 - Terminals capability tracks live sessions and closes them on plugin scope teardown (Grok review): a leaked PTY/process can no longer outlive its plugin on any exit path; finalizer registered before plugin code runs - Malformed percent-escapes in a route path degrade to no-match/404 instead of a caught defect -> 500 on a public route (Grok review) - Tests: invalid secret-name rejection, terminal leak-on-shutdown, decode-throw no-match, route removal on scope close Documented (not changed) for v1: matcher registration-order precedence, post-fetch projection caps, no HEAD/OPTIONS synthesis. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/plugins/PluginHost.ts | 31 +++++++---- apps/server/src/plugins/PluginHttpRegistry.ts | 8 ++- .../src/plugins/PluginHttpRoutes.test.ts | 47 +++++++++++++++++ apps/server/src/plugins/PluginHttpRoutes.ts | 52 +++++++++++++++++-- .../capabilities/PluginCapabilities.test.ts | 36 +++++++++++-- .../plugins/capabilities/SecretsCapability.ts | 27 ++++++++-- .../capabilities/TerminalsCapability.ts | 44 +++++++++++++--- 7 files changed, 215 insertions(+), 30 deletions(-) diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index f63d7616b48..af3a1c5299e 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -162,12 +162,21 @@ const makeHostApi = (input: { readonly github: GitHubCli.GitHubCli["Service"]; readonly terminals: TerminalManager.TerminalManager["Service"]; }; -}): PluginHostApi => { +}): { readonly api: PluginHostApi; readonly teardown: ReadonlyArray> } => { const capabilities = new Set(input.capabilities); const available = (capability: PluginManifest["capabilities"][number], value: A) => capabilities.has(capability) ? Effect.succeed(value) : unavailable(capability); - return { + const terminalsBundle = makeTerminalsCapability({ + pluginId: input.pluginId, + manager: input.deps.terminals, + }); + const teardown: Array> = []; + if (capabilities.has("terminals")) { + teardown.push(terminalsBundle.shutdown); + } + + const api: PluginHostApi = { hostApiVersion: HOST_API_VERSION, config: { appVersion: APP_VERSION, @@ -177,13 +186,7 @@ const makeHostApi = (input: { }, agents: unavailable("agents"), vcs: unavailable("vcs"), - terminals: available( - "terminals", - makeTerminalsCapability({ - pluginId: input.pluginId, - manager: input.deps.terminals, - }), - ), + terminals: available("terminals", terminalsBundle.capability), database: available("database", makeDatabaseCapability(input.deps.sql)), projectionsRead: available( "projections.read", @@ -224,6 +227,8 @@ const makeHostApi = (input: { makeTextGenerationCapability(input.deps.textGeneration), ), }; + + return { api, teardown }; }; const upgradeLockfileEntry = ( @@ -362,7 +367,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { const readiness = yield* Deferred.make(); const logger = makePluginLogger(pluginId); const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); - const hostApi = makeHostApi({ + const { api: hostApi, teardown: hostApiTeardown } = makeHostApi({ pluginId, capabilities: manifest.capabilities, dataDir, @@ -386,6 +391,12 @@ export const make = Effect.fn("PluginHost.make")(function* () { }); const activation = Effect.gen(function* () { + // Register capability teardowns (e.g. killing leaked terminals) on the + // plugin scope before running any plugin code, so cleanup fires on + // EVERY exit path — activation failure, stop, disable, crash. + for (const teardown of hostApiTeardown) { + yield* Scope.addFinalizer(scope, teardown); + } yield* fs.makeDirectory(dataDir, { recursive: true }); const definition = yield* loader.loadServerEntry(pluginDir, serverEntry); const registration = yield* resolveRegistration(pluginId, definition, hostApi); diff --git a/apps/server/src/plugins/PluginHttpRegistry.ts b/apps/server/src/plugins/PluginHttpRegistry.ts index 861c6ed3015..7bd6cf452ba 100644 --- a/apps/server/src/plugins/PluginHttpRegistry.ts +++ b/apps/server/src/plugins/PluginHttpRegistry.ts @@ -54,7 +54,13 @@ const matchPath = ( if (patternSegment.startsWith(":")) { const name = patternSegment.slice(1); if (name.length === 0) return null; - params[name] = decodeURIComponent(requestSegment); + // Malformed percent-escapes must not become route defects (public, + // unauthenticated surface): an undecodable segment simply doesn't match. + try { + params[name] = decodeURIComponent(requestSegment); + } catch { + return null; + } continue; } if (patternSegment !== requestSegment) return null; diff --git a/apps/server/src/plugins/PluginHttpRoutes.test.ts b/apps/server/src/plugins/PluginHttpRoutes.test.ts index a68ac69b4c3..27b4a10a347 100644 --- a/apps/server/src/plugins/PluginHttpRoutes.test.ts +++ b/apps/server/src/plugins/PluginHttpRoutes.test.ts @@ -130,6 +130,53 @@ it.layer(PluginHttpRegistryLayer.layer)("PluginHttpRegistry", (it) => { } }), ); + + it.effect("does not match (rather than throwing) on a malformed percent-escape", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "GET", + path: "/item/:id", + auth: "public", + handler: () => Effect.succeed({ status: 204 }), + }, + ]); + + // A bare "%" is an invalid escape; decodeURIComponent throws on it. + // The matcher must degrade to no-match, so the route layer 404s rather + // than turning a public request into a 500 defect. + const matched = yield* registry.match({ + pluginId, + method: "get", + path: "/item/%E0%A4%A", + }); + + assert.isTrue(Option.isNone(matched)); + }), + ); + + it.effect("removes a plugin's routes so a closed-scope plugin stops matching", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "GET", + path: "/ping", + auth: "public", + handler: () => Effect.succeed({ status: 204 }), + }, + ]); + assert.isTrue( + Option.isSome(yield* registry.match({ pluginId, method: "get", path: "/ping" })), + ); + + yield* registry.remove(pluginId); + assert.isTrue( + Option.isNone(yield* registry.match({ pluginId, method: "get", path: "/ping" })), + ); + }), + ); }); if (loopbackAvailable) { diff --git a/apps/server/src/plugins/PluginHttpRoutes.ts b/apps/server/src/plugins/PluginHttpRoutes.ts index f278e4b8356..fb89605b647 100644 --- a/apps/server/src/plugins/PluginHttpRoutes.ts +++ b/apps/server/src/plugins/PluginHttpRoutes.ts @@ -7,6 +7,8 @@ import type { PluginHttpResponse } from "@t3tools/plugin-sdk"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -68,6 +70,38 @@ const contentLength = (request: HttpServerRequest.HttpServerRequest): number | n return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; }; +class PluginHttpBodyTooLarge extends Schema.TaggedErrorClass()( + "PluginHttpBodyTooLarge", + { limit: Schema.Number }, +) {} + +// Read the body INCREMENTALLY with a hard cap: the content-length precheck +// is advisory (headers can lie, chunked bodies have none) — this is what +// actually bounds memory on public webhook routes. +const readBodyCapped = (request: HttpServerRequest.HttpServerRequest, maxBodyBytes: number) => + request.stream.pipe( + Stream.runFoldEffect( + () => ({ chunks: [] as Array, total: 0 }), + (acc, chunk: Uint8Array) => { + const total = acc.total + chunk.byteLength; + if (total > maxBodyBytes) { + return Effect.fail(new PluginHttpBodyTooLarge({ limit: maxBodyBytes })); + } + acc.chunks.push(chunk); + return Effect.succeed({ chunks: acc.chunks, total }); + }, + ), + Effect.map(({ chunks, total }) => { + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; + }), + ); + const authenticatePluginRoute = (pluginId: PluginId) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; @@ -140,10 +174,22 @@ export const pluginHttpRouteLayer = HttpRouter.add( return HttpServerResponse.text("Payload Too Large", { status: 413 }); } - const body = new Uint8Array(yield* request.arrayBuffer); - if (body.byteLength > maxBodyBytes) { - return HttpServerResponse.text("Payload Too Large", { status: 413 }); + const bodyOutcome = yield* readBodyCapped(request, maxBodyBytes).pipe( + Effect.map((body) => ({ kind: "ok" as const, body })), + Effect.catch((error) => + Effect.succeed({ + kind: "rejected" as const, + response: + (error as { readonly _tag?: string })._tag === "PluginHttpBodyTooLarge" + ? HttpServerResponse.text("Payload Too Large", { status: 413 }) + : HttpServerResponse.text("Bad Request", { status: 400 }), + }), + ), + ); + if (bodyOutcome.kind === "rejected") { + return bodyOutcome.response; } + const body = bodyOutcome.body; const logger = makePluginLogger(parsed.pluginId); const exit = yield* descriptor diff --git a/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts index dfad9ebfaf1..f11621e668f 100644 --- a/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts +++ b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; @@ -70,13 +71,21 @@ it.effect("secrets enforce and strip the plugin key prefix", () => const value = new TextEncoder().encode("secret-value"); yield* secrets.set("api-key", value); - yield* secrets.set("plugin:other:key", new TextEncoder().encode("scoped")); const stored = yield* secrets.get("api-key"); assert.deepEqual(Array.from(stored ?? []), Array.from(value)); - assert.deepEqual(yield* secrets.list, ["api-key", "plugin:other:key"]); - assert.isTrue(Option.isNone(yield* store.get("plugin:other:key"))); - assert.isTrue(Option.isSome(yield* store.get(`plugin:${pluginId}:plugin:other:key`))); + assert.deepEqual(yield* secrets.list, ["api-key"]); + assert.isTrue(Option.isNone(yield* store.get("api-key"))); + assert.isTrue(Option.isSome(yield* store.get(`plugin:${pluginId}:api-key`))); + + // Names outside the safe grammar are rejected: the backing store maps + // keys to file paths, so separators/colons/traversal must never reach it. + for (const invalidName of ["plugin:other:key", "../escape", "a/b", "a\\b", ".hidden", ""]) { + const rejected = yield* Effect.result( + secrets.set(invalidName, new TextEncoder().encode("nope")), + ); + assert.isTrue(Result.isFailure(rejected), `expected rejection for ${invalidName}`); + } yield* secrets.delete("api-key"); assert.isNull(yield* secrets.get("api-key")); @@ -330,7 +339,7 @@ it.effect("terminals spawn through a plugin-owned shell session and expose obser label: "run", updatedAt: "2026-07-03T00:00:00.000Z", }; - const capability = makeTerminalsCapability({ + const { capability, shutdown } = makeTerminalsCapability({ pluginId: PluginId.make("terminal-plugin"), manager: { open: () => Effect.succeed(snapshot), @@ -374,5 +383,22 @@ it.effect("terminals spawn through a plugin-owned shell session and expose obser yield* capability.kill({ ...spawned.handle, deleteHistory: true }); assert.equal(writes.at(-1), "q"); assert.deepEqual(closes, [{ ...spawned.handle, deleteHistory: true }]); + + // A killed terminal is no longer tracked, so shutdown closes nothing. + yield* shutdown; + assert.equal(closes.length, 1); + + // A terminal left open IS closed by shutdown (the scope-close leak guard). + const leaked = yield* capability.spawn({ + terminalId: "run-2", + cwd: "/repo", + command: "sleep", + args: ["100"], + }); + yield* shutdown; + assert.deepEqual(closes.at(-1), { + threadId: leaked.handle.threadId, + terminalId: leaked.handle.terminalId, + }); }), ); diff --git a/apps/server/src/plugins/capabilities/SecretsCapability.ts b/apps/server/src/plugins/capabilities/SecretsCapability.ts index 2fb6d496166..d466e55ea7e 100644 --- a/apps/server/src/plugins/capabilities/SecretsCapability.ts +++ b/apps/server/src/plugins/capabilities/SecretsCapability.ts @@ -2,6 +2,7 @@ import type { PluginId } from "@t3tools/contracts/plugin"; import type { SecretsCapability } from "@t3tools/plugin-sdk"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import type * as FileSystem from "effect/FileSystem"; import type * as Path from "effect/Path"; @@ -10,6 +11,20 @@ import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; const keyPrefix = (pluginId: PluginId) => `plugin:${pluginId}:`; +// The backing store maps keys to file paths verbatim, so secret names must +// be a safe path segment: no separators, no dots-only tricks, no colons +// (colons delimit the plugin prefix and break list() parsing). +const SECRET_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export class PluginSecretNameError extends Schema.TaggedErrorClass()( + "PluginSecretNameError", + { name: Schema.String }, +) { + override get message(): string { + return `Invalid secret name ${JSON.stringify(this.name)}: names must match ${SECRET_NAME_PATTERN.source}.`; + } +} + export function makeSecretsCapability(input: { readonly pluginId: PluginId; readonly store: ServerSecretStore.ServerSecretStore["Service"]; @@ -18,11 +33,15 @@ export function makeSecretsCapability(input: { readonly path: Path.Path; }): SecretsCapability { const prefix = keyPrefix(input.pluginId); - const scoped = (name: string) => `${prefix}${name}`; + const scoped = (name: string): Effect.Effect => + SECRET_NAME_PATTERN.test(name) + ? Effect.succeed(`${prefix}${name}`) + : Effect.fail(new PluginSecretNameError({ name })); return { get: (name) => - input.store.get(scoped(name)).pipe( + scoped(name).pipe( + Effect.flatMap((key) => input.store.get(key)), Effect.map( Option.match({ onNone: () => null, @@ -30,8 +49,8 @@ export function makeSecretsCapability(input: { }), ), ), - set: (name, value) => input.store.set(scoped(name), value), - delete: (name) => input.store.remove(scoped(name)), + set: (name, value) => scoped(name).pipe(Effect.flatMap((key) => input.store.set(key, value))), + delete: (name) => scoped(name).pipe(Effect.flatMap((key) => input.store.remove(key))), list: input.fileSystem.readDirectory(input.config.secretsDir).pipe( Effect.map((entries) => entries diff --git a/apps/server/src/plugins/capabilities/TerminalsCapability.ts b/apps/server/src/plugins/capabilities/TerminalsCapability.ts index 0e8b4c5ab2e..8a7770c79e9 100644 --- a/apps/server/src/plugins/capabilities/TerminalsCapability.ts +++ b/apps/server/src/plugins/capabilities/TerminalsCapability.ts @@ -16,11 +16,30 @@ const defaultHandle = (pluginId: PluginId, terminalId: string): TerminalSessionH terminalId, }); +export interface TerminalsCapabilityBundle { + readonly capability: TerminalsCapability; + /** Closes every terminal the plugin still holds open; run on plugin scope close. */ + readonly shutdown: Effect.Effect; +} + export function makeTerminalsCapability(input: { readonly pluginId: PluginId; readonly manager: TerminalManager.TerminalManager["Service"]; -}): TerminalsCapability { - return { +}): TerminalsCapabilityBundle { + // Track live terminals so a plugin that forgets to kill one, throws after + // spawn, or has its scope aborted cannot leak the underlying PTY/process. + const live = new Map(); + + const closeHandle = (handle: TerminalSessionHandle, deleteHistory?: boolean) => + input.manager + .close({ + threadId: handle.threadId, + terminalId: handle.terminalId, + ...(deleteHistory === undefined ? {} : { deleteHistory }), + }) + .pipe(Effect.ensuring(Effect.sync(() => live.delete(handle.terminalId)))); + + const capability: TerminalsCapability = { spawn: (request) => Effect.gen(function* () { const terminalId = @@ -34,6 +53,7 @@ export function makeTerminalsCapability(input: { cols: request.cols ?? 120, rows: request.rows ?? 30, }); + live.set(terminalId, handle); yield* input.manager.write({ ...handle, data: `${commandLine(request.command, request.args)}\n`, @@ -50,10 +70,20 @@ export function makeTerminalsCapability(input: { ), sendInput: (request) => input.manager.write(request), kill: (request) => - input.manager.close({ - threadId: request.threadId, - terminalId: request.terminalId, - ...(request.deleteHistory === undefined ? {} : { deleteHistory: request.deleteHistory }), - }), + closeHandle( + { threadId: request.threadId, terminalId: request.terminalId }, + request.deleteHistory, + ), }; + + // Suspend so the live set is read at teardown time, not at construction. + const shutdown = Effect.suspend(() => + Effect.forEach( + Array.from(live.values()), + (handle) => closeHandle(handle).pipe(Effect.ignore), + { discard: true }, + ), + ); + + return { capability, shutdown }; } From 35ae33dd5e6602b66ccc3c6aa50f6461f8cdadbb Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 06:09:27 -0400 Subject: [PATCH 09/75] Add agents and vcs plugin capability facades Completes the plugin capability surface. Both handed to plugins only when declared in the manifest. agents: built entirely on the orchestration command plane (OrchestrationEngineService.dispatch + streamDomainEvents + projection reads), never ProviderService, so plugin-driven turns are indistinguishable from core turns and inherit dedup, decider invariants, session resume, and approval routing. Security core: threads are created with owner "plugin:" INJECTED by the host (never from plugin input), and every thread-scoped operation verifies ownership before it dispatches or reads. listInstances, createThread, startTurn (with owner-injected bootstrap create + best-effort rollback if turn-start fails), observeThread, awaitTurn (session-local turn handle, documented), listPendingRequests, respond/interrupt/stop/delete. vcs: Git + checkpoint facade over GitVcsDriver/CheckpointStore. Absolute-path validated; worktree create/remove/list; branch/commit; merge with conflicts returned as a value; working-tree and ref diffs; push; checkpoint create/has/restore/delete (the store has no list). Reviewed by Claude + Grok (SHIP); applied: alias-map pruning on terminal resolution, bootstrap-create rollback, and the session-local turn-handle docs. Implemented by GPT-5.5 via codex exec (assembly-line slice 2a-3b). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/plugins/PluginHost.test.ts | 71 ++- apps/server/src/plugins/PluginHost.ts | 38 +- apps/server/src/plugins/PluginHttpRegistry.ts | 9 +- apps/server/src/plugins/PluginHttpRoutes.ts | 22 +- .../capabilities/AgentsCapability.test.ts | 477 ++++++++++++++++ .../plugins/capabilities/AgentsCapability.ts | 510 ++++++++++++++++++ .../capabilities/PluginCapabilities.test.ts | 198 ++++--- .../capabilities/ProjectionsReadCapability.ts | 5 +- .../capabilities/TerminalsCapability.ts | 8 +- .../capabilities/VcsCapability.test.ts | 224 ++++++++ .../src/plugins/capabilities/VcsCapability.ts | 329 +++++++++++ apps/server/src/server.ts | 3 + packages/plugin-sdk/src/index.ts | 413 +++++++++++++- 13 files changed, 2182 insertions(+), 125 deletions(-) create mode 100644 apps/server/src/plugins/capabilities/AgentsCapability.test.ts create mode 100644 apps/server/src/plugins/capabilities/AgentsCapability.ts create mode 100644 apps/server/src/plugins/capabilities/VcsCapability.test.ts create mode 100644 apps/server/src/plugins/capabilities/VcsCapability.ts diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index 463e8c61f84..7186c391995 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -13,22 +13,27 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as NodeURL from "node:url"; +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { runMigrations } from "../persistence/Migrations.ts"; import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; import * as ProjectionThreadMessages from "../persistence/Services/ProjectionThreadMessages.ts"; import * as ProjectionTurns from "../persistence/Services/ProjectionTurns.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as PluginHostModule from "./PluginHost.ts"; import * as PluginHttpRegistry from "./PluginHttpRegistry.ts"; import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; @@ -38,9 +43,10 @@ import { pluginDataDir, pluginVersionDir } from "./PluginPaths.ts"; import * as PluginRuntimeRegistryLayer from "./PluginRuntimeRegistry.ts"; const encodeManifestJson = Schema.encodeEffect(Schema.fromJsonString(PluginManifest)); -const unexpectedCapabilityUse = () => Effect.die(new Error("unexpected capability use in host test")); +const unexpectedCapabilityUse = () => + Effect.die(new Error("unexpected capability use in host test")); -const testLayer = PluginHostModule.layer.pipe( +const testLayerBase = PluginHostModule.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayer.layer), Layer.provideMerge(PluginModuleLoaderLayer.layer), Layer.provideMerge(PluginMigrator.layer), @@ -61,6 +67,13 @@ const testLayer = PluginHostModule.layer.pipe( getDescriptor: unexpectedCapabilityUse(), }), ), + Layer.provideMerge( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: unexpectedCapabilityUse, + streamDomainEvents: Stream.empty, + }), + ), Layer.provideMerge( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getCommandReadModel: unexpectedCapabilityUse, @@ -106,6 +119,57 @@ const testLayer = PluginHostModule.layer.pipe( deleteByThreadId: unexpectedCapabilityUse, }), ), + Layer.provideMerge( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: unexpectedCapabilityUse, + listInstances: unexpectedCapabilityUse(), + listUnavailable: unexpectedCapabilityUse(), + streamChanges: Stream.empty, + subscribeChanges: unexpectedCapabilityUse(), + }), + ), + Layer.provideMerge( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: unexpectedCapabilityUse, + status: unexpectedCapabilityUse, + statusDetails: unexpectedCapabilityUse, + statusDetailsLocal: unexpectedCapabilityUse, + statusDetailsRemote: unexpectedCapabilityUse, + prepareCommitContext: unexpectedCapabilityUse, + commit: unexpectedCapabilityUse, + pushCurrentBranch: unexpectedCapabilityUse, + readRangeContext: unexpectedCapabilityUse, + getReviewDiffPreview: unexpectedCapabilityUse, + readConfigValue: unexpectedCapabilityUse, + listRefs: unexpectedCapabilityUse, + pullCurrentBranch: unexpectedCapabilityUse, + createWorktree: unexpectedCapabilityUse, + fetchPullRequestBranch: unexpectedCapabilityUse, + ensureRemote: unexpectedCapabilityUse, + resolvePrimaryRemoteName: unexpectedCapabilityUse, + fetchRemote: unexpectedCapabilityUse, + resolveRemoteTrackingCommit: unexpectedCapabilityUse, + fetchRemoteBranch: unexpectedCapabilityUse, + fetchRemoteTrackingBranch: unexpectedCapabilityUse, + setBranchUpstream: unexpectedCapabilityUse, + removeWorktree: unexpectedCapabilityUse, + renameBranch: unexpectedCapabilityUse, + createRef: unexpectedCapabilityUse, + switchRef: unexpectedCapabilityUse, + initRepo: unexpectedCapabilityUse, + listLocalBranchNames: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: unexpectedCapabilityUse, + captureCheckpoint: unexpectedCapabilityUse, + hasCheckpointRef: unexpectedCapabilityUse, + restoreCheckpoint: unexpectedCapabilityUse, + diffCheckpoints: unexpectedCapabilityUse, + deleteCheckpointRefs: unexpectedCapabilityUse, + }), + ), Layer.provideMerge( Layer.mock(TextGeneration.TextGeneration)({ generateCommitMessage: unexpectedCapabilityUse, @@ -147,6 +211,9 @@ const testLayer = PluginHostModule.layer.pipe( subscribeMetadata: unexpectedCapabilityUse, }), ), +); + +const testLayer = testLayerBase.pipe( Layer.provideMerge(NodeSqliteClient.layerMemory()), Layer.provideMerge( Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-host-" })), diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index af3a1c5299e..81c6090dd4b 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -31,16 +31,21 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; import * as ProjectionThreadMessages from "../persistence/Services/ProjectionThreadMessages.ts"; import * as ProjectionTurns from "../persistence/Services/ProjectionTurns.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { makeAgentsCapability } from "./capabilities/AgentsCapability.ts"; import { makeDatabaseCapability } from "./capabilities/DatabaseCapability.ts"; import { makeEnvironmentsReadCapability } from "./capabilities/EnvironmentsReadCapability.ts"; import { makeHttpCapability } from "./capabilities/HttpCapability.ts"; @@ -49,6 +54,7 @@ import { makeSecretsCapability } from "./capabilities/SecretsCapability.ts"; import { makeSourceControlCapability } from "./capabilities/SourceControlCapability.ts"; import { makeTerminalsCapability } from "./capabilities/TerminalsCapability.ts"; import { makeTextGenerationCapability } from "./capabilities/TextGenerationCapability.ts"; +import { makeVcsCapability } from "./capabilities/VcsCapability.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; import { PluginHttpRegistry } from "./PluginHttpRegistry.ts"; import { PluginMigrator } from "./PluginMigrator.ts"; @@ -153,10 +159,14 @@ const makeHostApi = (input: { readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; readonly environment: ServerEnvironment.ServerEnvironment["Service"]; + readonly orchestrationEngine: OrchestrationEngine.OrchestrationEngineService["Service"]; readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; readonly turns: ProjectionTurns.ProjectionTurnRepository["Service"]; readonly messages: ProjectionThreadMessages.ProjectionThreadMessageRepository["Service"]; readonly activities: ProjectionThreadActivities.ProjectionThreadActivityRepository["Service"]; + readonly providerInstances: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"]; + readonly git: GitVcsDriver.GitVcsDriver["Service"]; + readonly checkpointStore: CheckpointStore.CheckpointStore["Service"]; readonly textGeneration: TextGeneration.TextGeneration["Service"]; readonly sourceControlRegistry: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; readonly github: GitHubCli.GitHubCli["Service"]; @@ -184,8 +194,24 @@ const makeHostApi = (input: { dataDir: input.dataDir, logger: input.logger, }, - agents: unavailable("agents"), - vcs: unavailable("vcs"), + agents: available( + "agents", + makeAgentsCapability({ + pluginId: input.pluginId, + engine: input.deps.orchestrationEngine, + snapshots: input.deps.snapshots, + turns: input.deps.turns, + messages: input.deps.messages, + providerInstances: input.deps.providerInstances, + }), + ), + vcs: available( + "vcs", + makeVcsCapability({ + git: input.deps.git, + checkpoints: input.deps.checkpointStore, + }), + ), terminals: available("terminals", terminalsBundle.capability), database: available("database", makeDatabaseCapability(input.deps.sql)), projectionsRead: available( @@ -301,10 +327,14 @@ export const make = Effect.fn("PluginHost.make")(function* () { const sql = yield* SqlClient.SqlClient; const secretStore = yield* ServerSecretStore.ServerSecretStore; const environment = yield* ServerEnvironment.ServerEnvironment; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const turns = yield* ProjectionTurns.ProjectionTurnRepository; const messages = yield* ProjectionThreadMessages.ProjectionThreadMessageRepository; const activities = yield* ProjectionThreadActivities.ProjectionThreadActivityRepository; + const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const git = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; const textGeneration = yield* TextGeneration.TextGeneration; const sourceControlRegistry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const github = yield* GitHubCli.GitHubCli; @@ -379,10 +409,14 @@ export const make = Effect.fn("PluginHost.make")(function* () { fileSystem: fs, path, environment, + orchestrationEngine, snapshots, turns, messages, activities, + providerInstances, + git, + checkpointStore, textGeneration, sourceControlRegistry, github, diff --git a/apps/server/src/plugins/PluginHttpRegistry.ts b/apps/server/src/plugins/PluginHttpRegistry.ts index 7bd6cf452ba..fa40c642027 100644 --- a/apps/server/src/plugins/PluginHttpRegistry.ts +++ b/apps/server/src/plugins/PluginHttpRegistry.ts @@ -38,10 +38,7 @@ const pathSegments = (path: string) => .split("/") .filter((segment) => segment.length > 0); -const matchPath = ( - pattern: string, - path: string, -): Readonly> | null => { +const matchPath = (pattern: string, path: string): Readonly> | null => { const patternSegments = pathSegments(pattern); const requestSegments = pathSegments(path); if (patternSegments.length !== requestSegments.length) return null; @@ -69,9 +66,7 @@ const matchPath = ( }; export const make = Effect.fn("PluginHttpRegistry.make")(function* () { - const routesRef = yield* Ref.make( - new Map>(), - ); + const routesRef = yield* Ref.make(new Map>()); return PluginHttpRegistry.of({ put: (pluginId, routes) => diff --git a/apps/server/src/plugins/PluginHttpRoutes.ts b/apps/server/src/plugins/PluginHttpRoutes.ts index fb89605b647..1a9a8f7b450 100644 --- a/apps/server/src/plugins/PluginHttpRoutes.ts +++ b/apps/server/src/plugins/PluginHttpRoutes.ts @@ -1,7 +1,4 @@ -import { - pluginOperateScope, - satisfiesScope, -} from "@t3tools/contracts"; +import { pluginOperateScope, satisfiesScope } from "@t3tools/contracts"; import type { PluginId } from "@t3tools/contracts/plugin"; import type { PluginHttpResponse } from "@t3tools/plugin-sdk"; import * as Cause from "effect/Cause"; @@ -9,7 +6,12 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"; +import { + HttpRouter, + HttpServerRequest, + HttpServerRespondable, + HttpServerResponse, +} from "effect/unstable/http"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import { @@ -30,12 +32,10 @@ function bodyLimit(value: number | undefined): number { return Math.min(MAX_BODY_BYTES, Math.max(0, Math.floor(value))); } -function parsePluginPath(pathname: string): - | { - readonly pluginId: PluginId; - readonly routePath: string; - } - | null { +function parsePluginPath(pathname: string): { + readonly pluginId: PluginId; + readonly routePath: string; +} | null { if (!pathname.startsWith(`${ROUTE_PREFIX}/`)) return null; const suffix = pathname.slice(`${ROUTE_PREFIX}/`.length); const separatorIndex = suffix.indexOf("/"); diff --git a/apps/server/src/plugins/capabilities/AgentsCapability.test.ts b/apps/server/src/plugins/capabilities/AgentsCapability.test.ts new file mode 100644 index 00000000000..8fa27aa125a --- /dev/null +++ b/apps/server/src/plugins/capabilities/AgentsCapability.test.ts @@ -0,0 +1,477 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationCommand, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { PluginId } from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; +import { expect } from "vite-plus/test"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationEngineLive } from "../../orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import { + AgentsThreadOwnershipError, + AgentsTurnAwaitTimeoutError, + makeAgentsCapability, +} from "./AgentsCapability.ts"; + +const pluginId = PluginId.make("agent-plugin"); +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +}; +const createdAt = "2026-01-01T00:00:00.000Z"; + +const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-plugin-agents-test-", +}); +const orchestrationLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, + ProjectionTurnRepositoryLive, + ProjectionThreadMessageRepositoryLive, +).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(NodeServices.layer), +); +const agentsIt = it.layer(orchestrationLayer); + +function makeProviderRegistry() { + const available = { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "not-required" }, + checkedAt: createdAt, + models: [{ slug: "gpt-5-codex", name: "GPT-5 Codex", isCustom: false }], + slashCommands: [], + skills: [], + } as const; + const unavailable = { + instanceId: ProviderInstanceId.make("missing"), + driver: "missing", + displayName: "Missing", + enabled: true, + installed: false, + version: null, + status: "disabled", + auth: { status: "not-required" }, + checkedAt: createdAt, + availability: "unavailable", + models: [], + slashCommands: [], + skills: [], + } as const; + return { + listInstances: Effect.succeed([ + { + snapshot: { + getSnapshot: Effect.succeed(available), + refresh: Effect.succeed(available), + streamChanges: Stream.empty, + maintenanceCapabilities: {} as any, + }, + }, + ] as any), + listUnavailable: Effect.succeed([unavailable] as any), + getInstance: () => Effect.sync(() => undefined), + streamChanges: Stream.empty, + subscribeChanges: Effect.die("not used"), + } satisfies ProviderInstanceRegistry["Service"]; +} + +const makeCapability = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const turns = yield* ProjectionTurnRepository; + const messages = yield* ProjectionThreadMessageRepository; + const agents = makeAgentsCapability({ + pluginId, + engine, + snapshots, + turns, + messages, + providerInstances: makeProviderRegistry(), + }); + return { agents, engine, snapshots, turns, messages }; +}); + +const createProject = (engine: OrchestrationEngineService["Service"], id = "project-agents") => + engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`cmd-${id}-create`), + projectId: ProjectId.make(id), + title: "Project", + workspaceRoot: `/tmp/${id}`, + defaultModelSelection: modelSelection, + createdAt, + }); + +const dispatchThreadCreate = ( + engine: OrchestrationEngineService["Service"], + input: { + readonly threadId: ThreadId; + readonly owner?: "user" | `plugin:${string}`; + readonly projectId?: ProjectId; + readonly commandId?: string; + }, +) => + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(input.commandId ?? `cmd-${input.threadId}-create`), + threadId: input.threadId, + projectId: input.projectId ?? ProjectId.make("project-agents"), + title: "Thread", + ...(input.owner === undefined ? {} : { owner: input.owner }), + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + +const dispatchAssistantCompletion = ( + engine: OrchestrationEngineService["Service"], + input: { + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly messageId: MessageId; + readonly text: string; + }, +) => + Effect.gen(function* () { + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make(`cmd-${input.messageId}-delta`), + threadId: input.threadId, + messageId: input.messageId, + turnId: input.turnId, + delta: input.text, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make(`cmd-${input.messageId}-complete`), + threadId: input.threadId, + messageId: input.messageId, + turnId: input.turnId, + createdAt, + }); + }); + +agentsIt("AgentsCapability", (it) => { + it.effect("createThread dispatches through orchestration and stamps plugin ownership", () => + Effect.gen(function* () { + const { agents, engine, snapshots } = yield* makeCapability; + yield* createProject(engine); + + const { threadId } = yield* agents.createThread({ + projectId: ProjectId.make("project-agents"), + title: "Owned", + modelSelection, + }); + + const owner = yield* snapshots.getThreadOwnerById(threadId); + expect(Option.getOrUndefined(owner)).toBe("plugin:agent-plugin"); + }), + ); + + it.effect( + "rejects startTurn, respond, interrupt, stop, delete, observe, and await for non-owned threads", + () => + Effect.gen(function* () { + const { agents, engine } = yield* makeCapability; + const userThreadId = ThreadId.make("thread-user-owned"); + const otherThreadId = ThreadId.make("thread-other-plugin"); + yield* createProject(engine); + yield* dispatchThreadCreate(engine, { threadId: userThreadId, owner: "user" }); + yield* dispatchThreadCreate(engine, { + threadId: otherThreadId, + owner: "plugin:other-plugin", + commandId: "cmd-other-plugin-thread", + }); + + const checks = [ + agents.startTurn({ threadId: userThreadId, text: "hello" }), + agents.respondToApproval({ + threadId: userThreadId, + requestId: "request-1" as any, + decision: "accept", + }), + agents.respondToUserInput({ + threadId: userThreadId, + requestId: "request-1" as any, + answers: {}, + }), + agents.interruptTurn({ threadId: userThreadId }), + agents.stopSession({ threadId: userThreadId }), + agents.deleteThread({ threadId: userThreadId }), + agents.observeThread(userThreadId).pipe(Stream.runCollect), + agents.awaitTurn({ + threadId: otherThreadId, + turnId: TurnId.make("turn-other"), + timeout: "10 millis", + }), + ]; + + for (const check of checks) { + const exit = yield* Effect.exit(check); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(String(exit.cause)).toContain(AgentsThreadOwnershipError.name); + } + } + }), + ); + + it.effect("startTurn injects ownership into bootstrap thread creation", () => + Effect.gen(function* () { + const { agents, engine, snapshots } = yield* makeCapability; + yield* createProject(engine); + const threadId = ThreadId.make("thread-bootstrap-owned"); + + yield* agents.startTurn({ + threadId, + text: "hello", + bootstrap: { + createThread: { + projectId: ProjectId.make("project-agents"), + title: "Bootstrap", + modelSelection, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + }, + }, + }); + + const owner = yield* snapshots.getThreadOwnerById(threadId); + expect(Option.getOrUndefined(owner)).toBe("plugin:agent-plugin"); + }), + ); + + it.effect("observeThread emits the owned snapshot followed by thread-detail events", () => + Effect.gen(function* () { + const { agents, engine } = yield* makeCapability; + yield* createProject(engine); + const { threadId } = yield* agents.createThread({ + projectId: ProjectId.make("project-agents"), + title: "Observed", + modelSelection, + }); + + const collected = yield* Effect.scoped( + Effect.gen(function* () { + const fiber = yield* agents + .observeThread(threadId) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped); + yield* Effect.yieldNow; + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-plugin-observe-activity"), + threadId, + activity: { + id: "event-plugin-observe-activity" as any, + tone: "info", + kind: "note", + summary: "Observed", + payload: {}, + turnId: null, + createdAt, + }, + createdAt, + }); + return yield* Fiber.join(fiber); + }), + ); + + expect(collected[0]?.kind).toBe("snapshot"); + expect(collected[1]?.kind).toBe("event"); + expect(collected[1]?.kind === "event" ? collected[1].event.type : null).toBe( + "thread.activity-appended", + ); + }), + ); + + it.effect( + "awaitTurn returns already-terminal and streamed terminal turns with assistant text", + () => + Effect.gen(function* () { + const { agents, engine } = yield* makeCapability; + yield* createProject(engine); + const { threadId } = yield* agents.createThread({ + projectId: ProjectId.make("project-agents"), + title: "Awaited", + modelSelection, + }); + const fastTurnId = TurnId.make("turn-fast"); + yield* dispatchAssistantCompletion(engine, { + threadId, + turnId: fastTurnId, + messageId: MessageId.make("message-fast"), + text: "already done", + }); + + const fastResult = yield* agents.awaitTurn({ + threadId, + turnId: fastTurnId, + timeout: "1 second", + }); + expect(fastResult).toEqual({ + state: "completed", + assistantText: "already done", + }); + + const streamedTurnId = TurnId.make("turn-streamed"); + const streamedResult = yield* Effect.scoped( + Effect.gen(function* () { + const fiber = yield* agents + .awaitTurn({ threadId, turnId: streamedTurnId, timeout: "1 second" }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* dispatchAssistantCompletion(engine, { + threadId, + turnId: streamedTurnId, + messageId: MessageId.make("message-streamed"), + text: "stream completed", + }); + return yield* Fiber.join(fiber); + }), + ); + expect(streamedResult).toEqual({ + state: "completed", + assistantText: "stream completed", + }); + }), + ); + + it.effect("awaitTurn times out without interrupting the turn", () => + Effect.gen(function* () { + const { agents, engine } = yield* makeCapability; + yield* createProject(engine); + const { threadId } = yield* agents.createThread({ + projectId: ProjectId.make("project-agents"), + title: "Timeout", + modelSelection, + }); + + const timeoutFiber = yield* agents + .awaitTurn({ + threadId, + turnId: TurnId.make("turn-never"), + timeout: "1 millis", + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("1 millis"); + + const error = yield* Fiber.join(timeoutFiber); + expect(error).toBeInstanceOf(AgentsTurnAwaitTimeoutError); + }), + ); + + it.effect("respond, interrupt, stop, and delete dispatch owned thread commands", () => + Effect.gen(function* () { + const dispatched: OrchestrationCommand[] = []; + const events = yield* Queue.unbounded(); + const agents = makeAgentsCapability({ + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.fromQueue(events), + }, + snapshots: { + getThreadOwnerById: () => Effect.succeed(Option.some("plugin:agent-plugin" as any)), + getThreadDetailById: () => Effect.succeed(Option.none()), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + } as any, + turns: {} as any, + messages: {} as any, + providerInstances: makeProviderRegistry(), + }); + const threadId = ThreadId.make("thread-owned"); + + yield* Effect.all([ + agents.respondToApproval({ threadId, requestId: "approval-1" as any, decision: "accept" }), + agents.respondToUserInput({ threadId, requestId: "input-1" as any, answers: { ok: true } }), + agents.interruptTurn({ threadId }), + agents.stopSession({ threadId }), + agents.deleteThread({ threadId }), + ]); + + expect(dispatched.map((command) => command.type)).toEqual([ + "thread.approval.respond", + "thread.user-input.respond", + "thread.turn.interrupt", + "thread.session.stop", + "thread.delete", + ]); + }), + ); + + it.effect("listInstances reads available and unavailable registry entries", () => + Effect.gen(function* () { + const agents = makeAgentsCapability({ + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.empty, + }, + snapshots: {} as any, + turns: {} as any, + messages: {} as any, + providerInstances: makeProviderRegistry(), + }); + + const instances = yield* agents.listInstances(); + expect(instances.available[0]?.instanceId).toBe("codex"); + expect(instances.unavailable[0]?.instanceId).toBe("missing"); + }), + ); +}); diff --git a/apps/server/src/plugins/capabilities/AgentsCapability.ts b/apps/server/src/plugins/capabilities/AgentsCapability.ts new file mode 100644 index 00000000000..7b2b07dcf70 --- /dev/null +++ b/apps/server/src/plugins/capabilities/AgentsCapability.ts @@ -0,0 +1,510 @@ +import * as NodeCrypto from "node:crypto"; + +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + MessageId, + ThreadId, + TurnId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { + AgentsAwaitTurnResult, + AgentsCapability, + AgentsCreateThreadInput, + AgentsPendingRequest, + AgentsStartTurnBootstrapInput, +} from "@t3tools/plugin-sdk"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import type { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type * as ProjectionThreadMessages from "../../persistence/Services/ProjectionThreadMessages.ts"; +import type * as ProjectionTurns from "../../persistence/Services/ProjectionTurns.ts"; +import type { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; + +const DEFAULT_AWAIT_TURN_TIMEOUT = Duration.minutes(30); + +export class AgentsThreadOwnershipError extends Schema.TaggedErrorClass()( + "AgentsThreadOwnershipError", + { + pluginId: Schema.String, + threadId: Schema.String, + expectedOwner: Schema.String, + actualOwner: Schema.NullOr(Schema.String), + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} cannot access thread ${this.threadId}; expected owner ${this.expectedOwner}, got ${this.actualOwner ?? "none"}.`; + } +} + +export class AgentsThreadNotFoundError extends Schema.TaggedErrorClass()( + "AgentsThreadNotFoundError", + { + threadId: Schema.String, + }, +) { + override get message(): string { + return `Thread ${this.threadId} was not found.`; + } +} + +export class AgentsTurnAwaitTimeoutError extends Schema.TaggedErrorClass()( + "AgentsTurnAwaitTimeoutError", + { + threadId: Schema.String, + turnId: Schema.String, + }, +) { + override get message(): string { + return `Timed out waiting for turn ${this.turnId} on thread ${this.threadId}.`; + } +} + +const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); +const nextCommandId = (tag: string) => CommandId.make(`plugin:${tag}:${NodeCrypto.randomUUID()}`); +const nextThreadId = () => ThreadId.make(NodeCrypto.randomUUID()); +const nextMessageId = () => MessageId.make(`plugin-message:${NodeCrypto.randomUUID()}`); +const nextTurnId = () => TurnId.make(`plugin-turn:${NodeCrypto.randomUUID()}`); + +function isThreadDetailEvent(event: OrchestrationEvent): boolean { + return ( + event.type === "thread.message-sent" || + event.type === "thread.proposed-plan-upserted" || + event.type === "thread.activity-appended" || + event.type === "thread.turn-diff-completed" || + event.type === "thread.reverted" || + event.type === "thread.session-set" + ); +} + +function toTimeoutDuration(input: string | number | undefined): Duration.Duration { + if (input === undefined) return DEFAULT_AWAIT_TURN_TIMEOUT; + if (typeof input === "number") return Duration.millis(input); + return Duration.fromInputUnsafe(input as Duration.Input); +} + +type TerminalProjectionTurn = ProjectionTurns.ProjectionTurnById & { + readonly state: AgentsAwaitTurnResult["state"]; +}; + +function terminalState( + state: ProjectionTurns.ProjectionTurnById["state"], +): state is AgentsAwaitTurnResult["state"] { + return state === "completed" || state === "error" || state === "interrupted"; +} + +function isTerminalTurn( + row: ProjectionTurns.ProjectionTurnById | null, +): row is TerminalProjectionTurn { + return row !== null && terminalState(row.state); +} + +function pendingRequestFromActivity(activity: { + readonly kind: string; + readonly payload: unknown; +}): AgentsPendingRequest | null { + if (activity.kind !== "approval.requested" && activity.kind !== "user-input.requested") { + return null; + } + if ( + typeof activity.payload !== "object" || + activity.payload === null || + !("requestId" in activity.payload) || + typeof (activity.payload as { requestId?: unknown }).requestId !== "string" + ) { + return null; + } + return { + kind: activity.kind, + requestId: (activity.payload as { requestId: string }).requestId, + activity: activity as AgentsPendingRequest["activity"], + }; +} + +function normalizeBootstrapForTurnStart( + bootstrap: AgentsStartTurnBootstrapInput | undefined, +): AgentsStartTurnBootstrapInput | undefined { + if (!bootstrap?.createThread) return bootstrap; + return { + ...bootstrap, + createThread: { + ...bootstrap.createThread, + createdAt: bootstrap.createThread.createdAt ?? nowIso(), + runtimeMode: bootstrap.createThread.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: bootstrap.createThread.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + branch: bootstrap.createThread.branch ?? null, + worktreePath: bootstrap.createThread.worktreePath ?? null, + } as AgentsStartTurnBootstrapInput["createThread"], + }; +} + +export function makeAgentsCapability(input: { + readonly pluginId: PluginId; + readonly engine: OrchestrationEngineService["Service"]; + readonly snapshots: ProjectionSnapshotQuery["Service"]; + readonly turns: ProjectionTurns.ProjectionTurnRepository["Service"]; + readonly messages: ProjectionThreadMessages.ProjectionThreadMessageRepository["Service"]; + readonly providerInstances: ProviderInstanceRegistry["Service"]; +}): AgentsCapability { + const owner = `plugin:${input.pluginId}` as `plugin:${string}`; + const turnAliases = new Map< + string, + { readonly threadId: ThreadId; readonly messageId: MessageId } + >(); + + const requireOwnedThread = (threadId: ThreadId) => + input.snapshots.getThreadOwnerById(threadId).pipe( + Effect.flatMap((actualOwner) => { + if (Option.isSome(actualOwner) && actualOwner.value === owner) { + return Effect.void; + } + return Effect.fail( + new AgentsThreadOwnershipError({ + pluginId: input.pluginId, + threadId, + expectedOwner: owner, + actualOwner: Option.getOrNull(actualOwner), + }), + ); + }), + ); + + const readTerminalTurn = (threadId: ThreadId, turnId: TurnId) => + Effect.gen(function* () { + const direct = yield* input.turns.getByTurnId({ threadId, turnId }); + if (Option.isSome(direct)) { + return direct.value; + } + const alias = turnAliases.get(String(turnId)); + if (!alias || alias.threadId !== threadId) { + return null; + } + const rows = yield* input.turns.listByThreadId({ threadId }); + return ( + rows.find( + (row): row is ProjectionTurns.ProjectionTurnById => + row.turnId !== null && row.pendingMessageId === alias.messageId, + ) ?? null + ); + }).pipe( + Effect.flatMap((row) => { + if (!isTerminalTurn(row)) return Effect.succeed(null); + // Prune the alias once the turn is terminal so the in-memory map does + // not grow unbounded over a long-lived plugin. + turnAliases.delete(String(turnId)); + return Effect.succeed(row); + }), + ); + + const readAwaitResult = (row: TerminalProjectionTurn) => + Effect.gen(function* () { + const assistantMessage = + row.assistantMessageId === null + ? Option.none() + : yield* input.messages.getByMessageId({ messageId: row.assistantMessageId }); + return { + state: row.state, + assistantText: + Option.isSome(assistantMessage) && !assistantMessage.value.isStreaming + ? assistantMessage.value.text + : null, + } satisfies AgentsAwaitTurnResult; + }); + + const awaitTerminalTurn = (threadId: ThreadId, turnId: TurnId) => + Effect.gen(function* () { + const first = yield* readTerminalTurn(threadId, turnId); + if (first) return first; + + return yield* Effect.scoped( + Effect.gen(function* () { + const terminalDeferred = yield* Deferred.make(); + const waitForEvent = input.engine.streamDomainEvents.pipe( + Stream.filter( + (event) => event.aggregateKind === "thread" && event.aggregateId === threadId, + ), + Stream.mapEffect(() => + readTerminalTurn(threadId, turnId).pipe( + Effect.flatMap((row) => + row ? Deferred.succeed(terminalDeferred, row).pipe(Effect.ignore) : Effect.void, + ), + ), + ), + Stream.runDrain, + ); + yield* waitForEvent.pipe(Effect.forkScoped); + const afterSubscribe = yield* readTerminalTurn(threadId, turnId); + if (afterSubscribe) return afterSubscribe; + return yield* Deferred.await(terminalDeferred); + }), + ); + }); + + return { + listInstances: () => + Effect.gen(function* () { + const [instances, unavailable] = yield* Effect.all([ + input.providerInstances.listInstances, + input.providerInstances.listUnavailable, + ]); + const available = yield* Effect.forEach( + instances, + (instance) => instance.snapshot.getSnapshot, + ); + return { available, unavailable }; + }), + + createThread: (request: AgentsCreateThreadInput) => + Effect.gen(function* () { + const threadId = nextThreadId(); + const createdAt = nowIso(); + yield* input.engine.dispatch({ + type: "thread.create", + commandId: nextCommandId("thread-create"), + threadId, + projectId: request.projectId, + title: request.title, + owner, + modelSelection: request.modelSelection, + runtimeMode: request.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: request.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + branch: request.branch ?? null, + worktreePath: request.worktreePath ?? null, + createdAt, + }); + return { threadId }; + }), + + startTurn: (request) => + Effect.gen(function* () { + const bootstrap = normalizeBootstrapForTurnStart(request.bootstrap); + const actualOwner = yield* input.snapshots.getThreadOwnerById(request.threadId); + if (Option.isSome(actualOwner) && actualOwner.value !== owner) { + return yield* new AgentsThreadOwnershipError({ + pluginId: input.pluginId, + threadId: request.threadId, + expectedOwner: owner, + actualOwner: actualOwner.value, + }); + } + // When the thread does not yet exist we create it explicitly here + // rather than via the turn-start bootstrap: the decider's + // thread.turn.start ignores bootstrap.createThread (that atomic path + // lives only in the WS entrypoint), so the create must be its own + // dispatch. If turn-start then fails, best-effort delete the thread we + // just created so we don't orphan a plugin-owned thread. + const createdThread = Option.isNone(actualOwner); + if (createdThread) { + if (!bootstrap?.createThread) { + return yield* new AgentsThreadOwnershipError({ + pluginId: input.pluginId, + threadId: request.threadId, + expectedOwner: owner, + actualOwner: null, + }); + } + yield* input.engine.dispatch({ + type: "thread.create", + commandId: nextCommandId("bootstrap-thread-create"), + threadId: request.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + owner, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: + bootstrap.createThread.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + branch: bootstrap.createThread.branch ?? null, + worktreePath: bootstrap.createThread.worktreePath ?? null, + createdAt: bootstrap.createThread.createdAt ?? nowIso(), + }); + } + const messageId = nextMessageId(); + const turnId = nextTurnId(); + turnAliases.set(String(turnId), { threadId: request.threadId, messageId }); + // Do NOT forward bootstrap.createThread into turn-start: the thread now + // exists, and the decider would ignore it anyway. + const turnBootstrap = createdThread ? undefined : bootstrap; + yield* input.engine + .dispatch({ + type: "thread.turn.start", + commandId: nextCommandId("turn-start"), + threadId: request.threadId, + message: { + messageId, + role: "user", + text: request.text, + attachments: [...(request.attachments ?? [])], + }, + ...(request.modelSelection !== undefined + ? { modelSelection: request.modelSelection } + : {}), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + ...(turnBootstrap !== undefined ? { bootstrap: turnBootstrap as any } : {}), + createdAt: nowIso(), + }) + .pipe( + Effect.tapError(() => + createdThread + ? input.engine + .dispatch({ + type: "thread.delete", + commandId: nextCommandId("thread-create-rollback"), + threadId: request.threadId, + }) + .pipe(Effect.ignore, Effect.andThen(Effect.sync(() => turnAliases.delete(String(turnId))))) + : Effect.void, + ), + ); + return { turnId, messageId }; + }), + + observeThread: (threadId) => + Stream.fromEffect( + Effect.gen(function* () { + yield* requireOwnedThread(threadId); + const [threadDetail, snapshotSequence] = yield* Effect.all([ + input.snapshots.getThreadDetailById(threadId), + input.snapshots + .getSnapshotSequence() + .pipe(Effect.map((snapshot) => snapshot.snapshotSequence)), + ]); + if (Option.isNone(threadDetail)) { + return yield* new AgentsThreadNotFoundError({ threadId }); + } + return { + snapshotSequence, + thread: threadDetail.value, + }; + }), + ).pipe( + Stream.map((snapshot) => ({ kind: "snapshot" as const, snapshot })), + Stream.concat( + input.engine.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && + event.aggregateId === threadId && + isThreadDetailEvent(event), + ), + Stream.map((event) => ({ kind: "event" as const, event })), + ), + ), + ), + + awaitTurn: (request) => + Effect.gen(function* () { + yield* requireOwnedThread(request.threadId); + const timeout = toTimeoutDuration(request.timeout); + const terminal = yield* awaitTerminalTurn(request.threadId, request.turnId).pipe( + Effect.timeoutOption(timeout), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new AgentsTurnAwaitTimeoutError({ + threadId: request.threadId, + turnId: request.turnId, + }), + ), + onSome: (row) => Effect.succeed(row), + }), + ), + ); + return yield* readAwaitResult(terminal); + }), + + listPendingRequests: (threadId) => + Effect.gen(function* () { + yield* requireOwnedThread(threadId); + const thread = yield* input.snapshots.getThreadDetailById(threadId); + if (Option.isNone(thread)) { + return yield* new AgentsThreadNotFoundError({ threadId }); + } + return thread.value.activities.flatMap((activity) => { + const pending = pendingRequestFromActivity(activity); + return pending ? [pending] : []; + }); + }), + + respondToApproval: (request) => + requireOwnedThread(request.threadId).pipe( + Effect.flatMap(() => + input.engine.dispatch({ + type: "thread.approval.respond", + commandId: nextCommandId("approval-respond"), + threadId: request.threadId, + requestId: request.requestId as any, + decision: request.decision, + createdAt: nowIso(), + }), + ), + Effect.asVoid, + ), + + respondToUserInput: (request) => + requireOwnedThread(request.threadId).pipe( + Effect.flatMap(() => + input.engine.dispatch({ + type: "thread.user-input.respond", + commandId: nextCommandId("user-input-respond"), + threadId: request.threadId, + requestId: request.requestId as any, + answers: request.answers, + createdAt: nowIso(), + }), + ), + Effect.asVoid, + ), + + interruptTurn: (request) => + requireOwnedThread(request.threadId).pipe( + Effect.flatMap(() => + input.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: nextCommandId("turn-interrupt"), + threadId: request.threadId, + ...(request.turnId !== undefined ? { turnId: request.turnId } : {}), + createdAt: nowIso(), + }), + ), + Effect.asVoid, + ), + + stopSession: ({ threadId }) => + requireOwnedThread(threadId).pipe( + Effect.flatMap(() => + input.engine.dispatch({ + type: "thread.session.stop", + commandId: nextCommandId("session-stop"), + threadId, + createdAt: nowIso(), + }), + ), + Effect.asVoid, + ), + + deleteThread: ({ threadId }) => + requireOwnedThread(threadId).pipe( + Effect.flatMap(() => + input.engine.dispatch({ + type: "thread.delete", + commandId: nextCommandId("thread-delete"), + threadId, + }), + ), + Effect.asVoid, + ), + }; +} diff --git a/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts index f11621e668f..e900c763d45 100644 --- a/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts +++ b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts @@ -34,10 +34,9 @@ it.effect("database executes parameterized SQL and rolls back failed transaction "one", "kept", ]); - const rows = yield* database.execute( - "SELECT id, value FROM p_test_plugin_items WHERE id = ?", - ["one"], - ); + const rows = yield* database.execute("SELECT id, value FROM p_test_plugin_items WHERE id = ?", [ + "one", + ]); assert.deepEqual(rows, [{ id: "one", value: "kept" }]); yield* database @@ -198,7 +197,10 @@ it.effect("projections read returns contract-shaped thread data with caps", () = assert.deepEqual(yield* capability.getThreadShellById("thread-1" as any), threadShell); assert.deepEqual(yield* capability.getThreadDetailById("thread-1" as any), threadDetail); - assert.equal((yield* capability.listTurnsByThreadId({ threadId: "thread-1" as any })).length, 1); + assert.equal( + (yield* capability.listTurnsByThreadId({ threadId: "thread-1" as any })).length, + 1, + ); assert.deepEqual( yield* capability.listMessagesByThreadId({ threadId: "thread-1" as any, limit: 1 }), [ @@ -232,7 +234,8 @@ it.effect("text generation delegates the existing one-shot operations", () => const capability = makeTextGenerationCapability({ generateCommitMessage: (input) => Effect.succeed({ subject: `commit:${input.branch}`, body: input.stagedSummary }), - generatePrContent: (input) => Effect.succeed({ title: input.headBranch, body: input.diffSummary }), + generatePrContent: (input) => + Effect.succeed({ title: input.headBranch, body: input.diffSummary }), generateBranchName: (input) => Effect.succeed({ branch: `feature/${input.message}` }), generateThreadTitle: (input) => Effect.succeed({ title: input.message.slice(0, 10) }), }); @@ -265,7 +268,11 @@ it.effect("text generation delegates the existing one-shot operations", () => { branch: "feature/work" }, ); assert.deepEqual( - yield* capability.generateThreadTitle({ cwd: "/repo", message: "hello world", modelSelection }), + yield* capability.generateThreadTitle({ + cwd: "/repo", + message: "hello world", + modelSelection, + }), { title: "hello worl" }, ); }), @@ -289,9 +296,23 @@ it.effect("source control exposes provider detection and existing GitHub CLI PR } as any, github: { listOpenPullRequests: () => - Effect.succeed([{ number: 1, title: "PR", url: "https://github.com/o/r/pull/1", baseRefName: "main", headRefName: "feature" }]), + Effect.succeed([ + { + number: 1, + title: "PR", + url: "https://github.com/o/r/pull/1", + baseRefName: "main", + headRefName: "feature", + }, + ]), getPullRequest: () => - Effect.succeed({ number: 2, title: "Detail", url: "https://github.com/o/r/pull/2", baseRefName: "main", headRefName: "fix" }), + Effect.succeed({ + number: 2, + title: "Detail", + url: "https://github.com/o/r/pull/2", + baseRefName: "main", + headRefName: "fix", + }), createPullRequest: (input: any) => Effect.sync(() => { createInputs.push(input); @@ -307,7 +328,11 @@ it.effect("source control exposes provider detection and existing GitHub CLI PR remoteUrl: "git@github.com:owner/repo.git", }); assert.equal((yield* capability.discoverProviders)[0]?.kind, "github"); - assert.equal((yield* capability.listOpenPullRequests({ cwd: "/repo", headSelector: "feature" }))[0]?.number, 1); + assert.equal( + (yield* capability.listOpenPullRequests({ cwd: "/repo", headSelector: "feature" }))[0] + ?.number, + 1, + ); assert.equal((yield* capability.getPullRequest({ cwd: "/repo", reference: "2" })).number, 2); yield* capability.createPullRequest({ cwd: "/repo", @@ -322,83 +347,88 @@ it.effect("source control exposes provider detection and existing GitHub CLI PR }), ); -it.effect("terminals spawn through a plugin-owned shell session and expose observe/input/kill", () => - Effect.gen(function* () { - const writes: string[] = []; - const closes: unknown[] = []; - const snapshot: TerminalSessionSnapshot = { - threadId: "plugin:terminal-plugin:run-1", - terminalId: "run-1", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "run", - updatedAt: "2026-07-03T00:00:00.000Z", - }; - const { capability, shutdown } = makeTerminalsCapability({ - pluginId: PluginId.make("terminal-plugin"), - manager: { - open: () => Effect.succeed(snapshot), - attachStream: (_input: any, listener: (event: TerminalAttachStreamEvent) => Effect.Effect) => - listener({ type: "snapshot", snapshot } satisfies TerminalAttachStreamEvent).pipe( - Effect.as(() => undefined), - ), - write: (input: any) => - Effect.sync(() => { - writes.push(input.data); - }), - close: (input: any) => - Effect.sync(() => { - closes.push(input); - }), - } as any, - }); +it.effect( + "terminals spawn through a plugin-owned shell session and expose observe/input/kill", + () => + Effect.gen(function* () { + const writes: string[] = []; + const closes: unknown[] = []; + const snapshot: TerminalSessionSnapshot = { + threadId: "plugin:terminal-plugin:run-1", + terminalId: "run-1", + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "run", + updatedAt: "2026-07-03T00:00:00.000Z", + }; + const { capability, shutdown } = makeTerminalsCapability({ + pluginId: PluginId.make("terminal-plugin"), + manager: { + open: () => Effect.succeed(snapshot), + attachStream: ( + _input: any, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + ) => + listener({ type: "snapshot", snapshot } satisfies TerminalAttachStreamEvent).pipe( + Effect.as(() => undefined), + ), + write: (input: any) => + Effect.sync(() => { + writes.push(input.data); + }), + close: (input: any) => + Effect.sync(() => { + closes.push(input); + }), + } as any, + }); - const spawned = yield* capability.spawn({ - terminalId: "run-1", - cwd: "/repo", - command: "echo", - args: ["hello world"], - }); - assert.deepEqual(spawned.handle, { - threadId: "plugin:terminal-plugin:run-1", - terminalId: "run-1", - }); - assert.deepEqual(writes, ["'echo' 'hello world'\n"]); + const spawned = yield* capability.spawn({ + terminalId: "run-1", + cwd: "/repo", + command: "echo", + args: ["hello world"], + }); + assert.deepEqual(spawned.handle, { + threadId: "plugin:terminal-plugin:run-1", + terminalId: "run-1", + }); + assert.deepEqual(writes, ["'echo' 'hello world'\n"]); - const events: TerminalAttachStreamEvent[] = []; - const unsubscribe = yield* capability.observe(spawned.handle, (event) => - Effect.sync(() => { - events.push(event); - }), - ); - unsubscribe(); - assert.equal(events[0]?.type, "snapshot"); + const events: TerminalAttachStreamEvent[] = []; + const unsubscribe = yield* capability.observe(spawned.handle, (event) => + Effect.sync(() => { + events.push(event); + }), + ); + unsubscribe(); + assert.equal(events[0]?.type, "snapshot"); - yield* capability.sendInput({ ...spawned.handle, data: "q" }); - yield* capability.kill({ ...spawned.handle, deleteHistory: true }); - assert.equal(writes.at(-1), "q"); - assert.deepEqual(closes, [{ ...spawned.handle, deleteHistory: true }]); + yield* capability.sendInput({ ...spawned.handle, data: "q" }); + yield* capability.kill({ ...spawned.handle, deleteHistory: true }); + assert.equal(writes.at(-1), "q"); + assert.deepEqual(closes, [{ ...spawned.handle, deleteHistory: true }]); - // A killed terminal is no longer tracked, so shutdown closes nothing. - yield* shutdown; - assert.equal(closes.length, 1); + // A killed terminal is no longer tracked, so shutdown closes nothing. + yield* shutdown; + assert.equal(closes.length, 1); - // A terminal left open IS closed by shutdown (the scope-close leak guard). - const leaked = yield* capability.spawn({ - terminalId: "run-2", - cwd: "/repo", - command: "sleep", - args: ["100"], - }); - yield* shutdown; - assert.deepEqual(closes.at(-1), { - threadId: leaked.handle.threadId, - terminalId: leaked.handle.terminalId, - }); - }), + // A terminal left open IS closed by shutdown (the scope-close leak guard). + const leaked = yield* capability.spawn({ + terminalId: "run-2", + cwd: "/repo", + command: "sleep", + args: ["100"], + }); + yield* shutdown; + assert.deepEqual(closes.at(-1), { + threadId: leaked.handle.threadId, + terminalId: leaked.handle.terminalId, + }); + }), ); diff --git a/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts index 5dfdd55cfff..a0cfc53c032 100644 --- a/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts +++ b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts @@ -1,7 +1,4 @@ -import type { - OrchestrationMessage, - OrchestrationThreadActivity, -} from "@t3tools/contracts"; +import type { OrchestrationMessage, OrchestrationThreadActivity } from "@t3tools/contracts"; import type { ProjectionsReadCapability } from "@t3tools/plugin-sdk"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; diff --git a/apps/server/src/plugins/capabilities/TerminalsCapability.ts b/apps/server/src/plugins/capabilities/TerminalsCapability.ts index 8a7770c79e9..40dfab6cdf0 100644 --- a/apps/server/src/plugins/capabilities/TerminalsCapability.ts +++ b/apps/server/src/plugins/capabilities/TerminalsCapability.ts @@ -78,11 +78,9 @@ export function makeTerminalsCapability(input: { // Suspend so the live set is read at teardown time, not at construction. const shutdown = Effect.suspend(() => - Effect.forEach( - Array.from(live.values()), - (handle) => closeHandle(handle).pipe(Effect.ignore), - { discard: true }, - ), + Effect.forEach(Array.from(live.values()), (handle) => closeHandle(handle).pipe(Effect.ignore), { + discard: true, + }), ); return { capability, shutdown }; diff --git a/apps/server/src/plugins/capabilities/VcsCapability.test.ts b/apps/server/src/plugins/capabilities/VcsCapability.test.ts new file mode 100644 index 00000000000..f00171192ed --- /dev/null +++ b/apps/server/src/plugins/capabilities/VcsCapability.test.ts @@ -0,0 +1,224 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { CheckpointRef, type VcsError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import { describe, expect } from "vite-plus/test"; + +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import * as ServerConfig from "../../config.ts"; +import { makeVcsCapability, PluginVcsPathError } from "./VcsCapability.ts"; + +const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "plugin-vcs-capability-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); +const TestLayer = Layer.mergeAll(GitVcsDriver.layer, CheckpointStore.layer).pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +function makeTmpDir( + prefix = "plugin-vcs-test-", +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); +} + +function writeTextFile( + filePath: string, + contents: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); +} + +function git( + cwd: string, + args: ReadonlyArray, +): Effect.Effect { + return Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "VcsCapability.test.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); +} + +function initRepoWithCommit( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> { + return Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["checkout", "-b", "main"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(NodePath.join(cwd, "README.md"), "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); +} + +it.layer(TestLayer)("VcsCapability", (it) => { + describe("git operations", () => { + it.effect("creates, lists, and removes worktrees with absolute path validation", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + const worktreeParent = yield* makeTmpDir("plugin-vcs-worktree-parent-"); + const worktreePath = NodePath.join(worktreeParent, "worktree"); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + + const rejected = yield* Effect.exit(vcs.status({ worktreePath: "relative/path" })); + expect(rejected._tag).toBe("Failure"); + if (rejected._tag === "Failure") { + expect(String(rejected.cause)).toContain(PluginVcsPathError.name); + } + + const created = yield* vcs.createWorktree({ + repoRoot: repo, + ref: "HEAD", + path: worktreePath, + newBranch: "feature/worktree", + }); + expect(created.worktree.path).toBe(worktreePath); + + const listed = yield* vcs.listWorktrees({ repoRoot: repo }); + const fileSystem = yield* FileSystem.FileSystem; + const canonicalWorktreePath = yield* fileSystem.realPath(worktreePath); + const canonicalListedPaths = yield* Effect.forEach(listed.worktrees, (worktree) => + fileSystem.realPath(worktree.path), + ); + expect(canonicalListedPaths.includes(canonicalWorktreePath)).toBe(true); + + yield* vcs.removeWorktree({ repoRoot: repo, path: worktreePath, force: true }); + const afterRemove = yield* vcs.listWorktrees({ repoRoot: repo }); + const canonicalAfterRemovePaths = yield* Effect.forEach( + afterRemove.worktrees, + (worktree) => fileSystem.realPath(worktree.path), + ); + expect(canonicalAfterRemovePaths.includes(canonicalWorktreePath)).toBe(false); + }), + ), + ); + + it.effect("creates branches, stages and commits, reads diffs, and pushes when configured", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + const remote = yield* makeTmpDir("plugin-vcs-remote-"); + yield* initRepoWithCommit(repo); + yield* git(remote, ["init", "--bare"]); + yield* git(repo, ["remote", "add", "origin", remote]); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + + yield* vcs.createBranch({ worktreePath: repo, branch: "feature/commit", switch: true }); + yield* writeTextFile(NodePath.join(repo, "README.md"), "# changed\n"); + yield* writeTextFile(NodePath.join(repo, "feature.txt"), "feature\n"); + const workingDiff = yield* vcs.workingTreeDiff({ worktreePath: repo }); + expect(workingDiff.diff).toContain("README.md"); + + const commit = yield* vcs.commit({ + worktreePath: repo, + subject: "Add feature", + body: "", + }); + expect(commit.status).toBe("created"); + if (commit.status === "created") { + expect(commit.commitSha.length).toBeGreaterThan(6); + } + + const range = yield* vcs.diffRefs({ worktreePath: repo, fromRef: "main", toRef: "HEAD" }); + expect(range.diff).toContain("feature.txt"); + + const push = yield* vcs.push({ worktreePath: repo, remoteName: "origin" }); + expect(push.status).toBe("pushed"); + }), + ), + ); + + it.effect("surfaces merge conflicts as a value", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + + yield* vcs.createBranch({ worktreePath: repo, branch: "left", switch: true }); + yield* writeTextFile(NodePath.join(repo, "README.md"), "left\n"); + yield* vcs.commit({ worktreePath: repo, subject: "left", body: "" }); + yield* git(repo, ["checkout", "main"]); + yield* vcs.createBranch({ worktreePath: repo, branch: "right", switch: true }); + yield* writeTextFile(NodePath.join(repo, "README.md"), "right\n"); + yield* vcs.commit({ worktreePath: repo, subject: "right", body: "" }); + + const result = yield* vcs.merge({ worktreePath: repo, ref: "left" }); + expect(result.status).toBe("conflict"); + if (result.status === "conflict") { + expect(result.conflictedFiles).toEqual(["README.md"]); + } + }), + ), + ); + + it.effect("round-trips checkpoints through the existing CheckpointStore surface", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/plugin-vcs-test/turn/1"); + + yield* writeTextFile(NodePath.join(repo, "README.md"), "# changed\n"); + yield* vcs.createCheckpoint({ worktreePath: repo, checkpointRef }); + expect(yield* vcs.hasCheckpoint({ worktreePath: repo, checkpointRef })).toBe(true); + + yield* writeTextFile(NodePath.join(repo, "README.md"), "# after\n"); + const restored = yield* vcs.restoreCheckpoint({ worktreePath: repo, checkpointRef }); + expect(restored.restored).toBe(true); + const fileSystem = yield* FileSystem.FileSystem; + expect(yield* fileSystem.readFileString(NodePath.join(repo, "README.md"))).toBe( + "# changed\n", + ); + + yield* vcs.deleteCheckpoints({ worktreePath: repo, checkpointRefs: [checkpointRef] }); + expect(yield* vcs.hasCheckpoint({ worktreePath: repo, checkpointRef })).toBe(false); + }), + ), + ); + }); +}); diff --git a/apps/server/src/plugins/capabilities/VcsCapability.ts b/apps/server/src/plugins/capabilities/VcsCapability.ts new file mode 100644 index 00000000000..c45eb1f7096 --- /dev/null +++ b/apps/server/src/plugins/capabilities/VcsCapability.ts @@ -0,0 +1,329 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import { GitCommandError } from "@t3tools/contracts"; +import type { VcsCapability, VcsWorktreeSummary } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { CheckpointStore } from "../../checkpointing/CheckpointStore.ts"; +import type * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; + +export class PluginVcsPathError extends Schema.TaggedErrorClass()( + "PluginVcsPathError", + { + field: Schema.String, + path: Schema.String, + }, +) { + override get message(): string { + return `VCS path '${this.field}' must be absolute: ${this.path}`; + } +} + +const requireAbsolute = (field: string, path: string): Effect.Effect => + NodePath.isAbsolute(path) + ? Effect.succeed(path) + : Effect.fail(new PluginVcsPathError({ field, path })); + +function parseWorktreeList(stdout: string): ReadonlyArray { + const worktrees: VcsWorktreeSummary[] = []; + let current: { + path?: string; + branch?: string | null; + head?: string | null; + detached?: boolean; + bare?: boolean; + } = {}; + + const flush = () => { + if (!current.path) { + current = {}; + return; + } + worktrees.push({ + path: current.path, + branch: current.branch ?? null, + head: current.head ?? null, + detached: current.detached ?? false, + bare: current.bare ?? false, + }); + current = {}; + }; + + for (const line of stdout.split("\n")) { + if (line.trim().length === 0) { + flush(); + continue; + } + if (line.startsWith("worktree ")) { + current.path = line.slice("worktree ".length); + } else if (line.startsWith("HEAD ")) { + current.head = line.slice("HEAD ".length); + } else if (line.startsWith("branch refs/heads/")) { + current.branch = line.slice("branch refs/heads/".length); + } else if (line === "detached") { + current.detached = true; + } else if (line === "bare") { + current.bare = true; + } + } + flush(); + return worktrees; +} + +function gitCommandError(input: { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly exitCode?: number | undefined; + readonly stdout?: string | undefined; + readonly stderr?: string | undefined; + readonly detail: string; +}) { + return new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + argumentCount: input.args.length, + ...(input.exitCode !== undefined ? { exitCode: input.exitCode } : {}), + ...(input.stdout !== undefined ? { stdoutLength: input.stdout.length } : {}), + ...(input.stderr !== undefined ? { stderrLength: input.stderr.length } : {}), + detail: input.detail, + }); +} + +export function makeVcsCapability(input: { + readonly git: GitVcsDriver.GitVcsDriver["Service"]; + readonly checkpoints: CheckpointStore["Service"]; +}): VcsCapability { + const executeDiff = (request: { + readonly worktreePath: string; + readonly args: ReadonlyArray; + }) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.git.execute({ + operation: "PluginVcsCapability.diff", + cwd, + args: request.args, + maxOutputBytes: 10_000_000, + appendTruncationMarker: true, + }), + ), + Effect.map((result) => ({ diff: result.stdout })), + ); + + return { + status: ({ worktreePath }) => + requireAbsolute("worktreePath", worktreePath).pipe( + Effect.flatMap((cwd) => input.git.status({ cwd })), + ), + + listWorktrees: ({ repoRoot }) => + requireAbsolute("repoRoot", repoRoot).pipe( + Effect.flatMap((cwd) => + input.git.execute({ + operation: "PluginVcsCapability.listWorktrees", + cwd, + args: ["worktree", "list", "--porcelain"], + }), + ), + Effect.map((result) => ({ worktrees: parseWorktreeList(result.stdout) })), + ), + + createWorktree: (request) => + Effect.gen(function* () { + const cwd = yield* requireAbsolute("repoRoot", request.repoRoot); + const path = yield* requireAbsolute("path", request.path); + return yield* input.git.createWorktree({ + cwd, + refName: request.ref, + newRefName: request.newBranch, + baseRefName: request.baseRef, + path, + }); + }), + + removeWorktree: (request) => + Effect.gen(function* () { + const cwd = yield* requireAbsolute("repoRoot", request.repoRoot); + const path = yield* requireAbsolute("path", request.path); + yield* input.git.removeWorktree({ + cwd, + path, + force: request.force, + }); + }), + + createBranch: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.git.createRef({ + cwd, + refName: request.branch, + switchRef: request.switch, + }), + ), + ), + + switchRef: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.git.switchRef({ + cwd, + refName: request.ref, + }), + ), + ), + + commit: (request) => + Effect.gen(function* () { + const cwd = yield* requireAbsolute("worktreePath", request.worktreePath); + const context = yield* input.git.prepareCommitContext(cwd, request.filePaths); + if (context === null) { + return { status: "skipped_no_changes" as const }; + } + const result = yield* input.git.commit(cwd, request.subject, request.body ?? ""); + return { + status: "created" as const, + commitSha: result.commitSha, + }; + }), + + merge: (request) => + Effect.gen(function* () { + const cwd = yield* requireAbsolute("worktreePath", request.worktreePath); + const args = ["merge", request.ref]; + const result = yield* input.git.execute({ + operation: "PluginVcsCapability.merge", + cwd, + args, + allowNonZeroExit: true, + maxOutputBytes: 1_000_000, + appendTruncationMarker: true, + }); + if (result.exitCode === 0) { + const commitSha = yield* input.git + .execute({ + operation: "PluginVcsCapability.merge.revParseHead", + cwd, + args: ["rev-parse", "HEAD"], + }) + .pipe(Effect.map((head) => head.stdout.trim())); + return { + status: "merged" as const, + commitSha, + stdout: result.stdout, + stderr: result.stderr, + }; + } + + const conflicts = yield* input.git.execute({ + operation: "PluginVcsCapability.merge.conflicts", + cwd, + args: ["diff", "--name-only", "--diff-filter=U"], + allowNonZeroExit: true, + }); + const conflictedFiles = conflicts.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (conflictedFiles.length > 0) { + return { + status: "conflict" as const, + conflictedFiles, + stdout: result.stdout, + stderr: result.stderr, + }; + } + + return yield* gitCommandError({ + operation: "PluginVcsCapability.merge", + cwd, + args, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + detail: result.stderr.trim() || "git merge failed", + }); + }), + + push: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.git.pushCurrentBranch(cwd, request.fallbackBranch ?? null, { + remoteName: request.remoteName ?? null, + }), + ), + ), + + workingTreeDiff: (request) => + executeDiff({ + worktreePath: request.worktreePath, + args: [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + ...(request.staged ? ["--cached"] : []), + ...(request.ignoreWhitespace ? ["--ignore-all-space"] : []), + ], + }), + + diffRefs: (request) => + executeDiff({ + worktreePath: request.worktreePath, + args: [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + ...(request.ignoreWhitespace ? ["--ignore-all-space"] : []), + `${request.fromRef}..${request.toRef}`, + ], + }), + + createCheckpoint: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.checkpoints.captureCheckpoint({ + cwd, + checkpointRef: request.checkpointRef, + }), + ), + ), + + hasCheckpoint: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.checkpoints.hasCheckpointRef({ + cwd, + checkpointRef: request.checkpointRef, + }), + ), + ), + + restoreCheckpoint: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.checkpoints.restoreCheckpoint({ + cwd, + checkpointRef: request.checkpointRef, + fallbackToHead: request.fallbackToHead ?? false, + }), + ), + Effect.map((restored) => ({ restored })), + ), + + deleteCheckpoints: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.checkpoints.deleteCheckpointRefs({ + cwd, + checkpointRefs: request.checkpointRefs, + }), + ), + ), + }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e6daa678538..582769397c2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -308,8 +308,11 @@ const PluginRuntimeRegistryLayerLive = PluginRuntimeRegistry.layer; const PluginHttpRegistryLayerLive = PluginHttpRegistry.layer; const PluginLockfileStoreLayerLive = PluginLockfileStore.layer; const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( + OrchestrationLayerLive, PluginProjectionReadLayerLive, SourceControlProviderRegistryLayerLive, + GitVcsDriver.layer, + CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive)), GitHubCli.layer, TextGeneration.layer, TerminalLayerLive, diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index ed5b13b18b7..d2ed09bb09b 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1,6 +1,7 @@ import type { ChangeRequestState, ChatAttachment, + CheckpointRef, EnvironmentId, ExecutionEnvironmentDescriptor, MessageId, @@ -12,14 +13,24 @@ import type { OrchestrationProjectShell, OrchestrationThread, OrchestrationThreadActivity, + OrchestrationThreadStreamItem, OrchestrationThreadShell, + ProviderApprovalDecision, + ProviderInteractionMode, + ProviderUserInputAnswers, ProjectId, + RuntimeMode, + ServerProvider, SourceControlProviderDiscoveryItem, SourceControlProviderInfo, TerminalAttachStreamEvent, TerminalSessionSnapshot, ThreadId, TurnId, + VcsCreateRefResult, + VcsCreateWorktreeResult, + VcsStatusResult, + VcsSwitchRefResult, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -61,11 +72,176 @@ export interface PluginCapabilityUnavailable { } export interface AgentsCapability { - readonly list: Effect.Effect>; + /** + * List configured provider instances visible to orchestration. Available + * instances are returned as their public provider snapshots; unavailable + * entries describe settings that this host cannot materialize. + */ + readonly listInstances: () => Effect.Effect; + + /** + * Create a plugin-owned orchestration thread. The host injects + * `owner: "plugin:"`; plugin input cannot choose or override owner. + */ + readonly createThread: ( + input: AgentsCreateThreadInput, + ) => Effect.Effect; + + /** + * Start a turn on a plugin-owned thread through the orchestration command + * plane. `bootstrap.createThread`, when present, is owner-injected by the + * host before dispatch. + * + * NOTE: the returned `turnId` is a SESSION-LOCAL handle for `awaitTurn` + * within this server process's lifetime — it is not the durable projection + * turn id and does not survive a server restart. Persist your own + * correlation (the returned `messageId`, or observe the thread) if you need + * to resolve a turn's outcome across restarts. + */ + readonly startTurn: (input: AgentsStartTurnInput) => Effect.Effect; + + /** + * Observe a plugin-owned thread using the same snapshot + thread-detail + * event stream shape as `orchestration.subscribeThread`. + */ + readonly observeThread: ( + threadId: ThreadId, + ) => Stream.Stream; + + /** + * Wait for a projected turn row to reach `completed`, `error`, or + * `interrupted`, then return the final assistant text if one was projected. + * Timeout only fails this wait; it does not interrupt the provider turn. + * + * Accepts only a `turnId` returned by `startTurn` in the SAME server + * lifetime (see the session-local note there). A `turnId` from a prior + * process will not resolve and will time out. + */ + readonly awaitTurn: (input: AgentsAwaitTurnInput) => Effect.Effect; + + /** + * Convenience read for pending approval and user-input requests in + * `thread.activities[]`. The same requests are also visible via + * `observeThread` snapshots and events. + */ + readonly listPendingRequests: ( + threadId: ThreadId, + ) => Effect.Effect, Error>; + + /** + * Respond to a provider approval request on a plugin-owned thread. + */ + readonly respondToApproval: (input: AgentsRespondToApprovalInput) => Effect.Effect; + + /** + * Respond to a provider user-input request on a plugin-owned thread. + */ + readonly respondToUserInput: (input: AgentsRespondToUserInputInput) => Effect.Effect; + + /** + * Request interruption of the active turn for a plugin-owned thread. + */ + readonly interruptTurn: (input: AgentsInterruptTurnInput) => Effect.Effect; + + /** + * Stop the provider session for a plugin-owned thread. + */ + readonly stopSession: (input: AgentsThreadInput) => Effect.Effect; + + /** + * Delete a plugin-owned thread. + */ + readonly deleteThread: (input: AgentsThreadInput) => Effect.Effect; } export interface VcsCapability { - readonly status: (input: { readonly cwd: string }) => Effect.Effect; + /** + * Read Git status for an absolute repository or worktree path. + * + * VCS is a full-trust capability: the host validates paths are absolute, but + * does not scope them to plugin data. Plugins should operate in their own + * worktrees. + */ + readonly status: (input: VcsWorktreeInput) => Effect.Effect; + + /** + * List Git worktrees for an absolute repository root. + */ + readonly listWorktrees: (input: VcsRepoInput) => Effect.Effect; + + /** + * Create a Git worktree for a ref. No lease concept is exposed because the + * backing VCS layer does not implement leases. + */ + readonly createWorktree: ( + input: VcsCreateWorktreeFacadeInput, + ) => Effect.Effect; + + /** + * Remove a Git worktree by absolute path. + */ + readonly removeWorktree: (input: VcsRemoveWorktreeFacadeInput) => Effect.Effect; + + /** + * Create a local branch and optionally switch to it. + */ + readonly createBranch: (input: VcsCreateBranchInput) => Effect.Effect; + + /** + * Switch the current worktree to a local or remote ref. + */ + readonly switchRef: (input: VcsSwitchRefFacadeInput) => Effect.Effect; + + /** + * Stage selected paths, or all changes when `filePaths` is omitted, then + * create a commit. No-change commits are surfaced as a skipped value. + */ + readonly commit: (input: VcsCommitInput) => Effect.Effect; + + /** + * Merge a ref into the current worktree. Merge conflicts are returned as + * `{ status: "conflict" }` instead of being thrown. + */ + readonly merge: (input: VcsMergeInput) => Effect.Effect; + + /** + * Push the current branch when the Git driver can resolve a remote. + */ + readonly push: (input: VcsPushInput) => Effect.Effect; + + /** + * Read the working-tree patch for an absolute worktree path. + */ + readonly workingTreeDiff: (input: VcsWorkingTreeDiffInput) => Effect.Effect; + + /** + * Read a patch between two refs. + */ + readonly diffRefs: (input: VcsDiffRefsInput) => Effect.Effect; + + /** + * Capture a filesystem checkpoint at a caller-provided Git ref. + */ + readonly createCheckpoint: (input: VcsCheckpointInput) => Effect.Effect; + + /** + * Check whether a checkpoint ref exists. The backing CheckpointStore has no + * list operation, so the SDK intentionally exposes existence checks instead + * of inventing checkpoint listing. + */ + readonly hasCheckpoint: (input: VcsCheckpointInput) => Effect.Effect; + + /** + * Restore workspace and staging state from a checkpoint ref. + */ + readonly restoreCheckpoint: ( + input: VcsRestoreCheckpointInput, + ) => Effect.Effect; + + /** + * Delete checkpoint refs. Missing refs are tolerated by the backing store. + */ + readonly deleteCheckpoints: (input: VcsDeleteCheckpointsInput) => Effect.Effect; } export interface TerminalsCapability { @@ -129,13 +305,17 @@ export interface ProjectionsReadCapability { * Read a single active thread shell by id. The lookup is intentionally * id-keyed and not owner-filtered. */ - readonly getThreadShellById: (threadId: ThreadId) => Effect.Effect; + readonly getThreadShellById: ( + threadId: ThreadId, + ) => Effect.Effect; /** * Read a single active thread detail snapshot by id. The lookup is * intentionally id-keyed and not owner-filtered. */ - readonly getThreadDetailById: (threadId: ThreadId) => Effect.Effect; + readonly getThreadDetailById: ( + threadId: ThreadId, + ) => Effect.Effect; /** * List projected turn rows for a thread, including pending placeholders. @@ -231,14 +411,17 @@ export interface SourceControlCapability { /** * Detect the source-control provider context for a repository root. */ - readonly detectProvider: ( - input: { readonly cwd: string }, - ) => Effect.Effect; + readonly detectProvider: (input: { + readonly cwd: string; + }) => Effect.Effect; /** * List configured source-control providers and auth availability. */ - readonly discoverProviders: Effect.Effect, Error>; + readonly discoverProviders: Effect.Effect< + ReadonlyArray, + Error + >; /** * List open GitHub pull requests for a head selector. This exposes the @@ -273,7 +456,9 @@ export interface SourceControlCapability { /** * Read the default branch reported by the GitHub CLI for the current repo. */ - readonly getDefaultBranch: (input: { readonly cwd: string }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; /** * Check out a GitHub pull request by number, URL, or branch reference. @@ -353,6 +538,209 @@ export interface ProjectionTurnRecord { readonly checkpointFiles: ReadonlyArray; } +export interface AgentsListInstancesResult { + readonly available: ReadonlyArray; + readonly unavailable: ReadonlyArray; +} + +export interface AgentsCreateThreadInput { + readonly projectId: ProjectId; + readonly title: string; + readonly modelSelection: ModelSelection; + readonly runtimeMode?: RuntimeMode | undefined; + readonly interactionMode?: ProviderInteractionMode | undefined; + readonly branch?: string | null | undefined; + readonly worktreePath?: string | null | undefined; +} + +export interface AgentsCreateThreadResult { + readonly threadId: ThreadId; +} + +export interface AgentsBootstrapCreateThreadInput extends AgentsCreateThreadInput { + readonly createdAt?: string | undefined; +} + +export interface AgentsStartTurnBootstrapInput { + readonly createThread?: AgentsBootstrapCreateThreadInput | undefined; + readonly prepareWorktree?: + | { + readonly projectCwd: string; + readonly baseBranch: string; + readonly branch?: string | undefined; + readonly startFromOrigin?: boolean | undefined; + } + | undefined; + readonly runSetupScript?: boolean | undefined; +} + +export interface AgentsStartTurnInput { + readonly threadId: ThreadId; + readonly text: string; + readonly attachments?: ReadonlyArray | undefined; + readonly modelSelection?: ModelSelection | undefined; + readonly bootstrap?: AgentsStartTurnBootstrapInput | undefined; +} + +export interface AgentsStartTurnResult { + readonly turnId: TurnId; + readonly messageId: MessageId; +} + +export interface AgentsAwaitTurnInput { + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly timeout?: string | number | undefined; +} + +export interface AgentsAwaitTurnResult { + readonly state: "completed" | "error" | "interrupted"; + readonly assistantText: string | null; + readonly stopReason?: string | undefined; + readonly errorMessage?: string | undefined; +} + +export interface AgentsPendingRequest { + readonly kind: "approval.requested" | "user-input.requested"; + readonly requestId: string; + readonly activity: OrchestrationThreadActivity; +} + +export interface AgentsThreadInput { + readonly threadId: ThreadId; +} + +export interface AgentsInterruptTurnInput extends AgentsThreadInput { + readonly turnId?: TurnId | undefined; +} + +export interface AgentsRespondToApprovalInput extends AgentsThreadInput { + readonly requestId: string; + readonly decision: ProviderApprovalDecision; +} + +export interface AgentsRespondToUserInputInput extends AgentsThreadInput { + readonly requestId: string; + readonly answers: ProviderUserInputAnswers; +} + +export interface VcsRepoInput { + readonly repoRoot: string; +} + +export interface VcsWorktreeInput { + readonly worktreePath: string; +} + +export interface VcsWorktreeSummary { + readonly path: string; + readonly branch: string | null; + readonly head: string | null; + readonly detached: boolean; + readonly bare: boolean; +} + +export interface VcsListWorktreesResult { + readonly worktrees: ReadonlyArray; +} + +export interface VcsCreateWorktreeFacadeInput extends VcsRepoInput { + readonly ref: string; + readonly path: string; + readonly newBranch?: string | undefined; + readonly baseRef?: string | undefined; +} + +export interface VcsRemoveWorktreeFacadeInput extends VcsRepoInput { + readonly path: string; + readonly force?: boolean | undefined; +} + +export interface VcsCreateBranchInput extends VcsWorktreeInput { + readonly branch: string; + readonly switch?: boolean | undefined; +} + +export interface VcsSwitchRefFacadeInput extends VcsWorktreeInput { + readonly ref: string; +} + +export interface VcsCommitInput extends VcsWorktreeInput { + readonly subject: string; + readonly body?: string | undefined; + readonly filePaths?: ReadonlyArray | undefined; +} + +export type VcsCommitResult = + | { + readonly status: "created"; + readonly commitSha: string; + } + | { + readonly status: "skipped_no_changes"; + }; + +export interface VcsMergeInput extends VcsWorktreeInput { + readonly ref: string; +} + +export type VcsMergeResult = + | { + readonly status: "merged"; + readonly commitSha: string; + readonly stdout: string; + readonly stderr: string; + } + | { + readonly status: "conflict"; + readonly conflictedFiles: ReadonlyArray; + readonly stdout: string; + readonly stderr: string; + }; + +export interface VcsPushInput extends VcsWorktreeInput { + readonly fallbackBranch?: string | null | undefined; + readonly remoteName?: string | null | undefined; +} + +export interface VcsPushResult { + readonly status: "pushed" | "skipped_up_to_date"; + readonly branch: string; + readonly upstreamBranch?: string | undefined; + readonly setUpstream?: boolean | undefined; +} + +export interface VcsWorkingTreeDiffInput extends VcsWorktreeInput { + readonly staged?: boolean | undefined; + readonly ignoreWhitespace?: boolean | undefined; +} + +export interface VcsDiffRefsInput extends VcsWorktreeInput { + readonly fromRef: string; + readonly toRef: string; + readonly ignoreWhitespace?: boolean | undefined; +} + +export interface VcsDiffResult { + readonly diff: string; +} + +export interface VcsCheckpointInput extends VcsWorktreeInput { + readonly checkpointRef: CheckpointRef; +} + +export interface VcsRestoreCheckpointInput extends VcsCheckpointInput { + readonly fallbackToHead?: boolean | undefined; +} + +export interface VcsRestoreCheckpointResult { + readonly restored: boolean; +} + +export interface VcsDeleteCheckpointsInput extends VcsWorktreeInput { + readonly checkpointRefs: ReadonlyArray; +} + export interface SourceControlProviderDetectionResult { readonly provider: SourceControlProviderInfo | null; readonly remoteName: string | null; @@ -487,7 +875,12 @@ export interface PluginHttpRequest { export interface PluginHttpResponse { readonly status: number; readonly headers?: Readonly> | undefined; - readonly body?: string | Uint8Array | ReadonlyArray | Readonly> | null; + readonly body?: + | string + | Uint8Array + | ReadonlyArray + | Readonly> + | null; } export interface PluginServiceContext { From 72f7a42b83b0d1e5872cee6ce9677db5ca792c0c Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 06:48:29 -0400 Subject: [PATCH 10/75] Add web plugin plumbing: bundle serving, import map, host shims Lets a dynamically-imported plugin web bundle resolve the host's singleton react / react-dom / effect / @effect/atom-react / @t3tools/plugin-sdk-web and load same-origin. No UI host or plugin routes yet; zero-plugin behavior is unchanged. - PluginWebRoutes: serves /plugins/:id/:version/{web,assets}/* from the plugin dir (per-segment decode, ../sep/null rejection, web|assets allowlist, double-realpath containment, lockfile version gate, immutable cache, nosniff) and /plugin-host/*.js shim modules - shared/pluginHostWeb: the import map + data-driven shim generation from static export-name manifests, and an idempotent index.html head injection; http.ts injects into both static branches (index.html only), a Vite transformIndexHtml gives dev parity - apps/web publishes the singletons on globalThis.__T3_PLUGIN_HOST__ as the first statement of main.tsx, exposes whenPluginHostReady - @t3tools/plugin-sdk-web: barrel re-exporting the design system, ChatMarkdown, the pickers, and the atom adapter, built with react and effect external Reviewed by Claude + Grok. Applied: a shim-export drift guard that diffs every shim list against the real host singleton object, an all-modules shim-identity test, and prominent docs that plugin web bundles must import effect from the barrel (subpaths are not in the import map - a deliberate v1 constraint). Implemented by GPT-5.5 via codex exec (assembly-line slice 2b-1). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/http.ts | 32 +- .../src/plugins/PluginWebRoutes.test.ts | 191 ++++++ apps/server/src/plugins/PluginWebRoutes.ts | 224 +++++++ apps/server/src/server.test.ts | 34 ++ apps/server/src/server.ts | 3 + apps/web/package.json | 1 + apps/web/src/main.tsx | 1 + apps/web/src/plugins/hostSingletons.test.ts | 97 +++ apps/web/src/plugins/hostSingletons.ts | 66 ++ apps/web/src/plugins/pluginSdkAtomReact.ts | 20 + apps/web/vite.config.ts | 20 + packages/plugin-sdk-web/README.md | 42 ++ packages/plugin-sdk-web/package.json | 27 + packages/plugin-sdk-web/src/atomAdapter.ts | 17 + packages/plugin-sdk-web/src/externals.ts | 12 + packages/plugin-sdk-web/src/index.test.tsx | 28 + packages/plugin-sdk-web/src/index.ts | 120 ++++ packages/plugin-sdk-web/tsconfig.json | 16 + packages/plugin-sdk-web/vite.config.ts | 28 + packages/shared/package.json | 4 + packages/shared/src/pluginHostWeb.test.ts | 44 ++ packages/shared/src/pluginHostWeb.ts | 562 ++++++++++++++++++ 22 files changed, 1579 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/plugins/PluginWebRoutes.test.ts create mode 100644 apps/server/src/plugins/PluginWebRoutes.ts create mode 100644 apps/web/src/plugins/hostSingletons.test.ts create mode 100644 apps/web/src/plugins/hostSingletons.ts create mode 100644 apps/web/src/plugins/pluginSdkAtomReact.ts create mode 100644 packages/plugin-sdk-web/README.md create mode 100644 packages/plugin-sdk-web/package.json create mode 100644 packages/plugin-sdk-web/src/atomAdapter.ts create mode 100644 packages/plugin-sdk-web/src/externals.ts create mode 100644 packages/plugin-sdk-web/src/index.test.tsx create mode 100644 packages/plugin-sdk-web/src/index.ts create mode 100644 packages/plugin-sdk-web/tsconfig.json create mode 100644 packages/plugin-sdk-web/vite.config.ts create mode 100644 packages/shared/src/pluginHostWeb.test.ts create mode 100644 packages/shared/src/pluginHostWeb.ts diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index fc7a9ef13a2..cd36b442887 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -4,6 +4,7 @@ import { AuthOrchestrationReadScope, EnvironmentHttpApi, } from "@t3tools/contracts"; +import { injectPluginHostHeadHtml } from "@t3tools/shared/pluginHostWeb"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -45,6 +46,8 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { @@ -248,6 +251,20 @@ export const staticAndDevRouteLayer = HttpRouter.add( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const staticRoot = path.resolve(staticDir); + const serveIndexHtml = (indexPath: string) => + Effect.gen(function* () { + const indexData = yield* fileSystem + .readFile(indexPath) + .pipe(Effect.orElseSucceed(() => null)); + if (!indexData) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const html = textDecoder.decode(indexData); + return HttpServerResponse.uint8Array(textEncoder.encode(injectPluginHostHeadHtml(html)), { + status: 200, + contentType: "text/html; charset=utf-8", + }); + }); const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); @@ -282,16 +299,11 @@ export const staticAndDevRouteLayer = HttpRouter.add( const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); if (!fileInfo || fileInfo.type !== "File") { const indexPath = path.resolve(staticRoot, "index.html"); - const indexData = yield* fileSystem - .readFile(indexPath) - .pipe(Effect.orElseSucceed(() => null)); - if (!indexData) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - return HttpServerResponse.uint8Array(indexData, { - status: 200, - contentType: "text/html; charset=utf-8", - }); + return yield* serveIndexHtml(indexPath); + } + + if (path.basename(filePath).toLowerCase() === "index.html") { + return yield* serveIndexHtml(filePath); } const contentType = Mime.getType(filePath) ?? "application/octet-stream"; diff --git a/apps/server/src/plugins/PluginWebRoutes.test.ts b/apps/server/src/plugins/PluginWebRoutes.test.ts new file mode 100644 index 00000000000..f4c9194e18d --- /dev/null +++ b/apps/server/src/plugins/PluginWebRoutes.test.ts @@ -0,0 +1,191 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId, type PluginLockfilePlugin } from "@t3tools/contracts/plugin"; +import { + PLUGIN_WEB_BUNDLE_CACHE_CONTROL, + PLUGIN_WEB_SHIM_CACHE_CONTROL, +} from "@t3tools/shared/pluginHostWeb"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { FetchHttpClient, HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import { pluginVersionDir } from "./PluginPaths.ts"; +import { pluginWebRouteLayer } from "./PluginWebRoutes.ts"; + +const pluginId = PluginId.make("web-plugin"); + +const canBindLoopback = async () => { + const NodeNet = await import("node:net"); + return await new Promise((resolve) => { + const server = NodeNet.createServer(); + server.once("error", () => { + resolve(false); + }); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + server.close(() => resolve(true)); + }); + }); +}; + +const loopbackAvailable = await canBindLoopback(); + +const nodeHttpServerLayer = Layer.unwrap( + Effect.promise(() => import("node:http")).pipe( + Effect.map((NodeHttp) => + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + ), +); + +const makePlugin = (overrides: Partial = {}): PluginLockfilePlugin => ({ + version: "1.0.0", + sha256: "sha", + sourceId: "local", + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + ...overrides, +}); + +const makeRouteLayer = () => + HttpRouter.serve(pluginWebRouteLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge(PluginLockfileStoreLayer.layer), + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-web-routes-" })), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(nodeHttpServerLayer), + Layer.provideMerge(FetchHttpClient.layer), + ); + +const routeUrl = (pathname: string) => + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + assert.fail(`Expected TCP address, got ${String(address)}`); + } + return `http://127.0.0.1:${address.port}${pathname}`; + }); + +const getPath = (pathname: string) => + Effect.gen(function* () { + const url = yield* routeUrl(pathname); + return yield* HttpClient.get(url); + }); + +const installPluginFile = (input: { + readonly plugin?: Partial; + readonly relativePath: string; + readonly contents: string; +}) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const store = yield* PluginLockfileStore; + const plugin = makePlugin(input.plugin); + yield* store.updatePlugin(pluginId, () => Effect.succeed(plugin)); + const versionDir = pluginVersionDir(config.pluginsDir, pluginId, plugin.version, path.join); + const filePath = path.join(versionDir, input.relativePath); + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, input.contents); + return { filePath, versionDir }; + }); + +if (loopbackAvailable) { + it.layer(makeRouteLayer())("plugin web route layer", (it) => { + it.effect("serves installed plugin web bundles with immutable cache headers", () => + Effect.gen(function* () { + yield* installPluginFile({ + relativePath: "web/entry.js", + contents: "export const ok = true;\n", + }); + + const response = yield* getPath("/plugins/web-plugin/1.0.0/web/entry.js"); + + assert.equal(response.status, 200); + assert.match(response.headers["content-type"] ?? "", /^text\/javascript/u); + assert.equal(response.headers["cache-control"], PLUGIN_WEB_BUNDLE_CACHE_CONTROL); + assert.equal(response.headers["x-content-type-options"], "nosniff"); + assert.equal(yield* response.text, "export const ok = true;\n"); + }), + ); + + it.effect("serves installed disabled plugin bundles", () => + Effect.gen(function* () { + yield* installPluginFile({ + plugin: { enabled: false, state: "disabled" }, + relativePath: "assets/panel.css", + contents: ".panel { color: red; }\n", + }); + + const response = yield* getPath("/plugins/web-plugin/1.0.0/assets/panel.css"); + + assert.equal(response.status, 200); + assert.match(response.headers["content-type"] ?? "", /^text\/css/u); + assert.equal(yield* response.text, ".panel { color: red; }\n"); + }), + ); + + it.effect("returns 404 for unknown plugins", () => + Effect.gen(function* () { + const response = yield* getPath("/plugins/missing-plugin/1.0.0/web/entry.js"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + + it.effect("rejects textual traversal and symlink escapes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { versionDir } = yield* installPluginFile({ + relativePath: "web/entry.js", + contents: "export const ok = true;\n", + }); + const outsideFile = path.join(path.dirname(versionDir), "outside.js"); + yield* fileSystem.writeFileString(outsideFile, "export const secret = true;\n"); + yield* fileSystem.symlink(outsideFile, path.join(versionDir, "web", "escape.js")); + + const traversal = yield* getPath("/plugins/web-plugin/1.0.0/web/%2e%2e/outside.js"); + const escape = yield* getPath("/plugins/web-plugin/1.0.0/web/escape.js"); + + assert.equal(traversal.status, 404); + assert.equal(escape.status, 404); + }), + ); + + it.effect("serves host shim modules as JavaScript with short cache headers", () => + Effect.gen(function* () { + const response = yield* getPath("/plugin-host/react.js"); + const source = yield* response.text; + + assert.equal(response.status, 200); + assert.match(response.headers["content-type"] ?? "", /^text\/javascript/u); + assert.equal(response.headers["cache-control"], PLUGIN_WEB_SHIM_CACHE_CONTROL); + assert.include(source, 'globalThis.__T3_PLUGIN_HOST__["react"]'); + assert.include(source, "export const useState = m.useState;"); + }), + ); + }); +} else { + describe.skip("plugin web live route layer", () => { + it("skips live router assertions when local TCP bind is unavailable", () => {}); + }); +} diff --git a/apps/server/src/plugins/PluginWebRoutes.ts b/apps/server/src/plugins/PluginWebRoutes.ts new file mode 100644 index 00000000000..e9b4652e728 --- /dev/null +++ b/apps/server/src/plugins/PluginWebRoutes.ts @@ -0,0 +1,224 @@ +import { PLUGIN_ID_PATTERN_SOURCE, type PluginId } from "@t3tools/contracts/plugin"; +import { + getPluginHostShimSource, + pluginHostModuleFromPath, + PLUGIN_WEB_BUNDLE_CACHE_CONTROL, + PLUGIN_WEB_SHIM_CACHE_CONTROL, +} from "@t3tools/shared/pluginHostWeb"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { pluginVersionDir } from "./PluginPaths.ts"; + +const PLUGIN_WEB_ROUTE_PREFIX = "/plugins/"; +const PLUGIN_HOST_ROUTE_PREFIX = "/plugin-host/"; +const PLUGIN_ID_PATTERN = new RegExp(`^${PLUGIN_ID_PATTERN_SOURCE}$`, "u"); + +const notFound = () => HttpServerResponse.text("Not Found", { status: 404 }); + +function decodeSegment(segment: string): string | null { + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +function hasInvalidPathSegment(segment: string): boolean { + return ( + segment.length === 0 || + segment === "." || + segment === ".." || + segment.includes("/") || + segment.includes("\\") || + segment.includes("\0") + ); +} + +function parsePluginWebPath(pathname: string): { + readonly pluginId: PluginId; + readonly version: string; + readonly relativePath: string; +} | null { + if (!pathname.startsWith(PLUGIN_WEB_ROUTE_PREFIX)) return null; + const rawParts = pathname.slice(PLUGIN_WEB_ROUTE_PREFIX.length).split("/"); + if (rawParts.length < 3) return null; + + const pluginId = decodeSegment(rawParts[0] ?? ""); + const version = decodeSegment(rawParts[1] ?? ""); + if ( + pluginId === null || + version === null || + !PLUGIN_ID_PATTERN.test(pluginId) || + hasInvalidPathSegment(version) + ) { + return null; + } + + const fileParts = rawParts.slice(2).map(decodeSegment); + if (fileParts.some((part) => part === null || hasInvalidPathSegment(part))) { + return null; + } + const safeParts = fileParts as Array; + if (safeParts[0] !== "web" && safeParts[0] !== "assets") { + return null; + } + + return { + pluginId: pluginId as PluginId, + version, + relativePath: safeParts.join("/"), + }; +} + +function isWithinRoot(root: string, candidate: string, separator: string): boolean { + return ( + candidate === root || + candidate.startsWith(root.endsWith(separator) ? root : `${root}${separator}`) + ); +} + +function contentTypeFor(filePath: string, extname: (path: string) => string): string { + switch (extname(filePath).toLowerCase()) { + case ".js": + case ".mjs": + return "text/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + case ".map": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".avif": + return "image/avif"; + case ".ico": + return "image/x-icon"; + case ".wasm": + return "application/wasm"; + default: + return "application/octet-stream"; + } +} + +const pluginBundleRouteLayer = HttpRouter.add( + "GET", + `${PLUGIN_WEB_ROUTE_PREFIX}*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return notFound(); + } + + const parsed = parsePluginWebPath(url.value.pathname); + if (!parsed) { + return notFound(); + } + + const lockfileStore = yield* PluginLockfileStore; + const lockfile = yield* lockfileStore.readLockfile.pipe( + Effect.catch((cause) => + Effect.logWarning("Could not read plugin lockfile for web bundle route", { cause }).pipe( + Effect.as(null), + ), + ), + ); + const lockfileEntry = lockfile?.plugins[parsed.pluginId]; + if (!lockfileEntry || lockfileEntry.version !== parsed.version) { + return notFound(); + } + + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const versionDir = pluginVersionDir( + config.pluginsDir, + parsed.pluginId, + parsed.version, + path.join, + ); + const versionDirRealPath = yield* fileSystem + .realPath(versionDir) + .pipe(Effect.orElseSucceed(() => null)); + if (!versionDirRealPath) { + return notFound(); + } + + const candidatePath = path.resolve(versionDir, parsed.relativePath); + const candidateRealPath = yield* fileSystem + .realPath(candidatePath) + .pipe(Effect.orElseSucceed(() => null)); + if (!candidateRealPath || !isWithinRoot(versionDirRealPath, candidateRealPath, path.sep)) { + return notFound(); + } + + const stat = yield* fileSystem.stat(candidateRealPath).pipe(Effect.orElseSucceed(() => null)); + if (!stat || stat.type !== "File") { + return notFound(); + } + + const data = yield* fileSystem + .readFile(candidateRealPath) + .pipe(Effect.orElseSucceed(() => null)); + if (!data) { + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + } + + return HttpServerResponse.uint8Array(data, { + status: 200, + contentType: contentTypeFor(candidateRealPath, path.extname), + headers: { + "Cache-Control": PLUGIN_WEB_BUNDLE_CACHE_CONTROL, + "X-Content-Type-Options": "nosniff", + }, + }); + }), +); + +const pluginHostShimRouteLayer = HttpRouter.add( + "GET", + `${PLUGIN_HOST_ROUTE_PREFIX}*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return notFound(); + } + const rawPath = url.value.pathname.slice(PLUGIN_HOST_ROUTE_PREFIX.length); + const decodedPath = decodeSegment(rawPath); + if (!decodedPath || decodedPath.includes("\0") || decodedPath.includes("..")) { + return notFound(); + } + const moduleName = pluginHostModuleFromPath(decodedPath); + if (!moduleName) { + return notFound(); + } + + return HttpServerResponse.text(getPluginHostShimSource(moduleName), { + status: 200, + contentType: "text/javascript; charset=utf-8", + headers: { + "Cache-Control": PLUGIN_WEB_SHIM_CACHE_CONTROL, + "X-Content-Type-Options": "nosniff", + }, + }); + }), +); + +export const pluginWebRouteLayer = Layer.mergeAll(pluginBundleRouteLayer, pluginHostShimRouteLayer); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fcf419cb91e..60ee4b4a9af 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3,6 +3,7 @@ import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { PLUGIN_HOST_IMPORT_MAP_MARKER } from "@t3tools/shared/pluginHostWeb"; import { AuthAccessTokenType, @@ -116,6 +117,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; +import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as Data from "effect/Data"; @@ -755,6 +757,7 @@ const buildAppUnderTest = (options?: { ), ), Layer.provideMerge(PluginHttpRegistry.layer), + Layer.provide(PluginLockfileStore.layer), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -1269,6 +1272,37 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("injects plugin host import map into index responses only", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" }); + yield* fileSystem.writeFileString( + path.join(staticDir, "index.html"), + "T3shell", + ); + yield* fileSystem.writeFileString(path.join(staticDir, "app.js"), "console.log('asset');"); + + yield* buildAppUnderTest({ config: { staticDir } }); + + const root = yield* HttpClient.get("/"); + const rootHtml = yield* root.text; + assert.equal(root.status, 200); + assert.include(rootHtml, PLUGIN_HOST_IMPORT_MAP_MARKER); + assert.equal(rootHtml.match(new RegExp(PLUGIN_HOST_IMPORT_MAP_MARKER, "g"))?.length, 1); + + const fallback = yield* HttpClient.get("/deep/link"); + const fallbackHtml = yield* fallback.text; + assert.equal(fallback.status, 200); + assert.include(fallbackHtml, PLUGIN_HOST_IMPORT_MAP_MARKER); + assert.equal(fallbackHtml.match(new RegExp(PLUGIN_HOST_IMPORT_MAP_MARKER, "g"))?.length, 1); + + const asset = yield* HttpClient.get("/app.js"); + assert.equal(asset.status, 200); + assert.equal(yield* asset.text, "console.log('asset');"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("redirects to dev URL when configured", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 582769397c2..d38d73ded58 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,6 +35,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import { pluginHttpRouteLayer } from "./plugins/PluginHttpRoutes.ts"; +import { pluginWebRouteLayer } from "./plugins/PluginWebRoutes.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; import * as PluginMigrator from "./plugins/PluginMigrator.ts"; @@ -339,6 +340,7 @@ const PluginLayerLive = Layer.mergeAll( PluginRpcDispatcherLayerLive, PluginCatalogLayerLive, PluginHttpRegistryLayerLive, + PluginLockfileStoreLayerLive, ); const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( @@ -416,6 +418,7 @@ export const makeRoutesLayer = Layer.mergeAll( otlpTracesProxyRouteLayer, assetRouteLayer, pluginHttpRouteLayer, + pluginWebRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/web/package.json b/apps/web/package.json index 5a1579a478b..83a23b17215 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk-web": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 453649bfdc5..4351a010a0f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,4 @@ +import "./plugins/hostSingletons"; import React from "react"; import ReactDOM from "react-dom/client"; import { ClerkProvider } from "@clerk/react"; diff --git a/apps/web/src/plugins/hostSingletons.test.ts b/apps/web/src/plugins/hostSingletons.test.ts new file mode 100644 index 00000000000..5fd4513b807 --- /dev/null +++ b/apps/web/src/plugins/hostSingletons.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + getPluginHostShimSource, + pluginHostShimExportNames, +} from "@t3tools/shared/pluginHostWeb"; + +import { getPluginHost, whenPluginHostReady } from "./hostSingletons"; + +describe("hostSingletons", () => { + it("publishes the host singleton global with plugin import-map keys", async () => { + const host = getPluginHost(); + await expect(whenPluginHostReady).resolves.toBe(host); + + expect(Object.keys(host).sort()).toEqual([ + "@effect/atom-react", + "@t3tools/plugin-sdk-web", + "effect", + "react", + "react-dom", + "react-dom/client", + "react/jsx-dev-runtime", + "react/jsx-runtime", + ]); + expect(host.react.version).toBe("19.2.6"); + expect(typeof host["@t3tools/plugin-sdk-web"].hostCompat).toBe("object"); + }); + + it("react shim modules re-export the host singleton identities", async () => { + const currentHost = getPluginHost(); + const hostReact = { + default: { marker: "react-default" }, + useState: () => ["state"], + version: "test-react", + }; + globalThis.__T3_PLUGIN_HOST__ = { + ...currentHost, + react: hostReact as unknown as typeof currentHost.react, + }; + + const source = getPluginHostShimSource("react"); + const shim = (await import( + /* @vite-ignore */ `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}#react-shim-test` + )) as typeof import("react") & { readonly default: unknown }; + + expect(shim.default).toBe(hostReact.default); + expect(shim.useState).toBe(hostReact.useState); + expect(shim.version).toBe("test-react"); + + globalThis.__T3_PLUGIN_HOST__ = currentHost; + }); + + // Drift guard: the shim export-name lists are static snapshots of the host + // modules. If a dependency bump adds or removes an export, a shim's + // `export const X = m.X` silently yields undefined. Assert the snapshots + // still match the modules the host actually ships (this app package has the + // real deps, so a bump that drops a listed export fails CI here). + it("shim export names match the host modules the app actually ships", async () => { + const host = getPluginHost(); + for (const [specifier, names] of Object.entries(pluginHostShimExportNames)) { + const module = host[specifier as keyof typeof host] as Record; + const actual = new Set(Object.keys(module)); + const missing = names.filter((name) => !actual.has(name)); + expect(missing, `stale shim exports for ${specifier}`).toEqual([]); + } + }); + + // The generated shim must re-export the SAME identities the host holds for + // every module — not just react — so a plugin gets one effect/atom/sdk-web + // instance shared with the host. + it("every host module's shim re-exports the host's own identity", async () => { + const currentHost = getPluginHost(); + const marker = Symbol("host-identity"); + for (const [specifier, names] of Object.entries(pluginHostShimExportNames)) { + const probeName = names[0]; + if (!probeName) continue; + const realModule = currentHost[specifier as keyof typeof currentHost] as Record< + string, + unknown + >; + const stub = { ...realModule, [probeName]: { [marker]: specifier } }; + globalThis.__T3_PLUGIN_HOST__ = { + ...currentHost, + [specifier]: stub, + } as typeof currentHost; + + const source = getPluginHostShimSource(specifier as keyof typeof pluginHostShimExportNames); + const shim = (await import( + /* @vite-ignore */ `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}#${encodeURIComponent(specifier)}` + )) as Record; + + expect(shim[probeName], `shim identity mismatch for ${specifier}.${probeName}`).toBe( + stub[probeName], + ); + } + globalThis.__T3_PLUGIN_HOST__ = currentHost; + }); +}); diff --git a/apps/web/src/plugins/hostSingletons.ts b/apps/web/src/plugins/hostSingletons.ts new file mode 100644 index 00000000000..e5f525705e6 --- /dev/null +++ b/apps/web/src/plugins/hostSingletons.ts @@ -0,0 +1,66 @@ +import * as atomReact from "@effect/atom-react"; +import * as pluginSdkWeb from "@t3tools/plugin-sdk-web"; +import * as effect from "effect"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import * as ReactDOMClient from "react-dom/client"; +import * as jsxDevRuntime from "react/jsx-dev-runtime"; +import * as jsxRuntime from "react/jsx-runtime"; + +export interface PluginHostSingletons { + readonly react: typeof React; + readonly "react-dom": typeof ReactDOM; + readonly "react-dom/client": typeof ReactDOMClient; + readonly "react/jsx-runtime": typeof jsxRuntime; + readonly "react/jsx-dev-runtime": typeof jsxDevRuntime; + readonly "@effect/atom-react": typeof atomReact; + readonly effect: typeof effect; + readonly "@t3tools/plugin-sdk-web": typeof pluginSdkWeb; +} + +declare global { + // Host ESM shims read this object synchronously after the SPA boot module publishes it. + // eslint-disable-next-line no-var + var __T3_PLUGIN_HOST__: PluginHostSingletons | undefined; + // eslint-disable-next-line no-var + var __T3_PLUGIN_HOST_READY__: Promise | undefined; + // eslint-disable-next-line no-var + var __T3_PLUGIN_HOST_READY_RESOLVE__: ((host: PluginHostSingletons) => void) | undefined; +} + +let resolvePluginHostReady: (host: PluginHostSingletons) => void; + +export const whenPluginHostReady = + globalThis.__T3_PLUGIN_HOST_READY__ ?? + new Promise((resolve) => { + resolvePluginHostReady = resolve; + globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__ = resolve; + }); + +if (!globalThis.__T3_PLUGIN_HOST_READY__) { + globalThis.__T3_PLUGIN_HOST_READY__ = whenPluginHostReady; +} else { + resolvePluginHostReady = globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__ ?? (() => {}); +} + +const pluginHost: PluginHostSingletons = { + react: React, + "react-dom": ReactDOM, + "react-dom/client": ReactDOMClient, + "react/jsx-runtime": jsxRuntime, + "react/jsx-dev-runtime": jsxDevRuntime, + "@effect/atom-react": atomReact, + effect, + "@t3tools/plugin-sdk-web": pluginSdkWeb, +}; + +globalThis.__T3_PLUGIN_HOST__ = pluginHost; +globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__?.(pluginHost); +resolvePluginHostReady!(pluginHost); + +export function getPluginHost(): PluginHostSingletons { + if (!globalThis.__T3_PLUGIN_HOST__) { + throw new Error("T3 plugin host singletons have not been published."); + } + return globalThis.__T3_PLUGIN_HOST__; +} diff --git a/apps/web/src/plugins/pluginSdkAtomReact.ts b/apps/web/src/plugins/pluginSdkAtomReact.ts new file mode 100644 index 00000000000..f1f3ccc7e55 --- /dev/null +++ b/apps/web/src/plugins/pluginSdkAtomReact.ts @@ -0,0 +1,20 @@ +export { + HydrationBoundary, + RegistryContext, + RegistryProvider, + TypeId, + make, + scheduleTask, + useAtom, + useAtomInitialValues, + useAtomMount, + useAtomRef, + useAtomRefProp, + useAtomRefPropValue, + useAtomRefresh, + useAtomSet, + useAtomSubscribe, + useAtomSuspense, + useAtomValue, +} from "@effect/atom-react"; +export { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index bb6347bf1ac..e897039312e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,7 @@ import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; +import { injectPluginHostHeadHtml } from "@t3tools/shared/pluginHostWeb"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config"; import "vite-plus/test/config"; @@ -87,9 +88,19 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); +function pluginHostIndexHtmlPlugin() { + return { + name: "t3-plugin-host-index-html", + transformIndexHtml(html: string) { + return injectPluginHostHeadHtml(html); + }, + }; +} + export default defineConfig(() => { return { plugins: [ + pluginHostIndexHtmlPlugin(), tanstackRouter(), react(), babel({ @@ -156,6 +167,15 @@ export default defineConfig(() => { target: devProxyTarget, changeOrigin: true, }, + // Dev uses the backend as the single source for plugin shims and same-origin bundles. + "/plugin-host": { + target: devProxyTarget, + changeOrigin: true, + }, + "/plugins": { + target: devProxyTarget, + changeOrigin: true, + }, }, } : {}), diff --git a/packages/plugin-sdk-web/README.md b/packages/plugin-sdk-web/README.md new file mode 100644 index 00000000000..0392a0950e7 --- /dev/null +++ b/packages/plugin-sdk-web/README.md @@ -0,0 +1,42 @@ +# @t3tools/plugin-sdk-web + +> **Import `effect` from the barrel, not subpaths.** `import { Effect, Schema } from "effect"` ✅ — +> `import * as Effect from "effect/Effect"` ❌ does not resolve in a plugin web bundle. The host +> import map enumerates bare specifiers only. See [Importing `effect`](#importing-effect). + +Thin host-surface barrel for plugin web bundles. Plugin builds should treat these runtime modules +as externals and let the host import map resolve them at runtime: + +- `react` +- `react-dom` +- `react-dom/client` +- `react/jsx-runtime` +- `react/jsx-dev-runtime` +- `@effect/atom-react` +- `effect` +- `@t3tools/plugin-sdk-web` + +## Importing `effect` + +The host import map maps the **bare `effect` specifier only** — not its subpaths. Import effect +modules from the barrel: + +```ts +import { Effect, Stream, Option } from "effect"; // ✅ resolves via the host import map +``` + +Do **not** import effect subpaths in a plugin web bundle: + +```ts +import * as Effect from "effect/Effect"; // ❌ not in the import map — fails to resolve in the browser +``` + +(This differs from server plugins, where a Node resolve hook handles subpaths. Web plugins rely on +the native browser import map, which enumerates the bare specifier.) + +## Tailwind + +Tailwind v4 utilities are emitted by scanning the host build. A separately-built plugin cannot +assume arbitrary Tailwind utility classes will exist in the host CSS. Use host CSS variables such +as `--background`, `--color-*`, and `.dark`, use the exported host design-system components, or +ship compiled plugin CSS for plugin-local classes. diff --git a/packages/plugin-sdk-web/package.json b/packages/plugin-sdk-web/package.json new file mode 100644 index 00000000000..d5ea5d0a938 --- /dev/null +++ b/packages/plugin-sdk-web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@t3tools/plugin-sdk-web", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "vp build", + "typecheck": "tsgo --noEmit", + "test": "vp test run" + }, + "dependencies": { + "@t3tools/client-runtime": "workspace:*", + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*" + }, + "peerDependencies": { + "@effect/atom-react": "4.0.0-beta.78", + "effect": "4.0.0-beta.78", + "react": "19.2.6", + "react-dom": "19.2.6" + } +} diff --git a/packages/plugin-sdk-web/src/atomAdapter.ts b/packages/plugin-sdk-web/src/atomAdapter.ts new file mode 100644 index 00000000000..641e61681df --- /dev/null +++ b/packages/plugin-sdk-web/src/atomAdapter.ts @@ -0,0 +1,17 @@ +import { appAtomRegistry } from "../../../apps/web/src/rpc/atomRegistry.ts"; +import { connectionAtomRuntime } from "../../../apps/web/src/connection/runtime.ts"; + +export function getAppAtomRegistry() { + return appAtomRegistry; +} + +export function getConnectionAtomRuntime() { + return connectionAtomRuntime; +} + +export function createPluginAtoms() { + return { + registry: appAtomRegistry, + runtime: connectionAtomRuntime, + }; +} diff --git a/packages/plugin-sdk-web/src/externals.ts b/packages/plugin-sdk-web/src/externals.ts new file mode 100644 index 00000000000..8985ce2e593 --- /dev/null +++ b/packages/plugin-sdk-web/src/externals.ts @@ -0,0 +1,12 @@ +export const pluginSdkWebExternalDependencies = [ + "@effect/atom-react", + "effect", + "react", + "react-dom", +] as const; + +export function isPluginSdkWebExternal(id: string): boolean { + return pluginSdkWebExternalDependencies.some((dependency) => { + return id === dependency || id.startsWith(`${dependency}/`); + }); +} diff --git a/packages/plugin-sdk-web/src/index.test.tsx b/packages/plugin-sdk-web/src/index.test.tsx new file mode 100644 index 00000000000..b7d4b7b7949 --- /dev/null +++ b/packages/plugin-sdk-web/src/index.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + Button, + ChatMarkdown, + ProviderModelPicker, + TraitsPicker, + createPluginAtoms, + hostCompat, + pluginSdkWebExternalDependencies, +} from "./index"; + +describe("plugin-sdk-web", () => { + it("re-exports the host web surface", () => { + expect(typeof Button).toBe("function"); + expect(typeof ChatMarkdown).toBe("object"); + expect(typeof ProviderModelPicker).toBe("object"); + expect(typeof TraitsPicker).toBe("object"); + expect(typeof createPluginAtoms).toBe("function"); + expect(hostCompat.hostApiVersion).toBe("1.0.0"); + }); + + it("keeps host singleton dependencies external for plugin builds", () => { + expect(pluginSdkWebExternalDependencies).toEqual( + expect.arrayContaining(["@effect/atom-react", "effect", "react", "react-dom"]), + ); + }); +}); diff --git a/packages/plugin-sdk-web/src/index.ts b/packages/plugin-sdk-web/src/index.ts new file mode 100644 index 00000000000..6194fb8f22f --- /dev/null +++ b/packages/plugin-sdk-web/src/index.ts @@ -0,0 +1,120 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; +import { HOST_API_VERSION } from "@t3tools/contracts/plugin"; +import { pluginSdkWebExternalDependencies } from "./externals"; + +export { pluginSdkWebExternalDependencies, isPluginSdkWebExternal } from "./externals"; +export { createPluginAtoms, getAppAtomRegistry, getConnectionAtomRuntime } from "./atomAdapter"; + +export { + HydrationBoundary, + RegistryContext, + RegistryProvider, + TypeId, + make, + scheduleTask, + useAtom, + useAtomInitialValues, + useAtomMount, + useAtomRef, + useAtomRefProp, + useAtomRefPropValue, + useAtomRefresh, + useAtomSet, + useAtomSubscribe, + useAtomSuspense, + useAtomValue, +} from "../../../apps/web/src/plugins/pluginSdkAtomReact.ts"; +export { + AsyncResult, + Atom, + AtomRegistry, +} from "../../../apps/web/src/plugins/pluginSdkAtomReact.ts"; + +export * from "../../../apps/web/src/components/ui/alert.tsx"; +export * from "../../../apps/web/src/components/ui/alert-dialog.tsx"; +export * from "../../../apps/web/src/components/ui/badge.tsx"; +export * from "../../../apps/web/src/components/ui/button.tsx"; +export * from "../../../apps/web/src/components/ui/card.tsx"; +export * from "../../../apps/web/src/components/ui/checkbox.tsx"; +export * from "../../../apps/web/src/components/ui/command.tsx"; +export * from "../../../apps/web/src/components/ui/dialog.tsx"; +export * from "../../../apps/web/src/components/ui/empty.tsx"; +export * from "../../../apps/web/src/components/ui/field.tsx"; +export * from "../../../apps/web/src/components/ui/input.tsx"; +export * from "../../../apps/web/src/components/ui/label.tsx"; +export * from "../../../apps/web/src/components/ui/menu.tsx"; +export * from "../../../apps/web/src/components/ui/popover.tsx"; +export * from "../../../apps/web/src/components/ui/scroll-area.tsx"; +export * from "../../../apps/web/src/components/ui/select.tsx"; +export * from "../../../apps/web/src/components/ui/separator.tsx"; +export * from "../../../apps/web/src/components/ui/sheet.tsx"; +export * from "../../../apps/web/src/components/ui/sidebar.tsx"; +export * from "../../../apps/web/src/components/ui/spinner.tsx"; +export * from "../../../apps/web/src/components/ui/switch.tsx"; +export * from "../../../apps/web/src/components/ui/textarea.tsx"; +export * from "../../../apps/web/src/components/ui/toast.tsx"; +export * from "../../../apps/web/src/components/ui/tooltip.tsx"; +export { default as ChatMarkdown } from "../../../apps/web/src/components/ChatMarkdown.tsx"; +export { ProviderModelPicker } from "../../../apps/web/src/components/chat/ProviderModelPicker.tsx"; +export { + TraitsMenuContent, + TraitsPicker, + shouldRenderTraitsControls, +} from "../../../apps/web/src/components/chat/TraitsPicker.tsx"; +export { useAtomCommand } from "../../../apps/web/src/state/use-atom-command.ts"; +export { useAtomQueryRunner } from "../../../apps/web/src/state/use-atom-query-runner.ts"; + +export const hostCompat = { + hostApiVersion: HOST_API_VERSION, + importMapExternals: pluginSdkWebExternalDependencies, +} as const; + +export interface PluginUiContext { + readonly pluginId: PluginId; +} + +export type PluginComponent = (props: Props) => unknown; + +export interface PluginRouteRegistration { + readonly id: string; + readonly path: string; + readonly title: string; + readonly component: PluginComponent; +} + +export interface PluginSidebarSection { + readonly id: string; + readonly title: string; + readonly items: ReadonlyArray<{ + readonly id: string; + readonly title: string; + readonly routePath: string; + readonly icon?: PluginComponent<{ readonly className?: string }>; + }>; +} + +export interface PluginSettingsPage { + readonly id: string; + readonly title: string; + readonly component: PluginComponent; +} + +export interface PluginCommand { + readonly id: string; + readonly title: string; + readonly description?: string; + readonly run: (context: PluginUiContext) => void | Promise; +} + +/** + * Tailwind v4 caveat: host builds emit utilities by scanning host source. + * Separately-built plugins should use host CSS variables and these exported + * host components, or ship their own compiled CSS for plugin-local classes. + */ +export interface PluginWebRegistration { + readonly routes?: ReadonlyArray; + readonly sidebarSections?: ReadonlyArray; + readonly settingsPages?: ReadonlyArray; + readonly commands?: ReadonlyArray; + readonly providers?: (context: PluginUiContext) => unknown; +} diff --git a/packages/plugin-sdk-web/tsconfig.json b/packages/plugin-sdk-web/tsconfig.json new file mode 100644 index 00000000000..54ab0faf6cb --- /dev/null +++ b/packages/plugin-sdk-web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../apps/web/tsconfig.json", + "compilerOptions": { + "composite": false, + "module": "Preserve", + "moduleResolution": "Bundler", + "erasableSyntaxOnly": false, + "verbatimModuleSyntax": false, + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "paths": { + "~/*": ["../../apps/web/src/*"] + } + }, + "include": ["src", "vite.config.ts", "../../apps/web/src/*.d.ts"] +} diff --git a/packages/plugin-sdk-web/vite.config.ts b/packages/plugin-sdk-web/vite.config.ts new file mode 100644 index 00000000000..e7c3e654e1d --- /dev/null +++ b/packages/plugin-sdk-web/vite.config.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; + +import { isPluginSdkWebExternal } from "./src/externals"; + +const webSrc = fileURLToPath(new URL("../../apps/web/src", import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "~": webSrc, + }, + dedupe: ["react", "react-dom"], + }, + build: { + lib: { + entry: "src/index.ts", + formats: ["es"], + fileName: "index", + }, + rollupOptions: { + external: isPluginSdkWebExternal, + }, + }, + test: { + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..7d58ff53f84 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -182,6 +182,10 @@ "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" + }, + "./pluginHostWeb": { + "types": "./src/pluginHostWeb.ts", + "import": "./src/pluginHostWeb.ts" } }, "scripts": { diff --git a/packages/shared/src/pluginHostWeb.test.ts b/packages/shared/src/pluginHostWeb.test.ts new file mode 100644 index 00000000000..2d26976f024 --- /dev/null +++ b/packages/shared/src/pluginHostWeb.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + PLUGIN_HOST_IMPORT_MAP_MARKER, + getPluginHostShimSource, + injectPluginHostHeadHtml, + pluginHostImportMap, +} from "./pluginHostWeb.ts"; + +describe("pluginHostWeb", () => { + it("injects the import map and bootstrap before head close once", () => { + const html = "T3"; + + const injected = injectPluginHostHeadHtml(html); + const reinjected = injectPluginHostHeadHtml(injected); + + expect(injected).toContain(PLUGIN_HOST_IMPORT_MAP_MARKER); + expect(injected.indexOf(PLUGIN_HOST_IMPORT_MAP_MARKER)).toBeLessThan( + injected.indexOf(""), + ); + expect(reinjected.match(new RegExp(PLUGIN_HOST_IMPORT_MAP_MARKER, "g"))?.length).toBe(1); + }); + + it("maps host singleton specifiers to same-origin shim modules", () => { + expect(pluginHostImportMap.imports).toMatchObject({ + "@effect/atom-react": "/plugin-host/@effect/atom-react.js", + "@t3tools/plugin-sdk-web": "/plugin-host/@t3tools/plugin-sdk-web.js", + effect: "/plugin-host/effect.js", + react: "/plugin-host/react.js", + "react-dom": "/plugin-host/react-dom.js", + "react-dom/client": "/plugin-host/react-dom/client.js", + "react/jsx-dev-runtime": "/plugin-host/react/jsx-dev-runtime.js", + "react/jsx-runtime": "/plugin-host/react/jsx-runtime.js", + }); + }); + + it("generates static named exports for shim modules", () => { + const source = getPluginHostShimSource("react"); + + expect(source).toContain('const m = globalThis.__T3_PLUGIN_HOST__["react"];'); + expect(source).toContain("export default m.default ?? m;"); + expect(source).toContain("export const useState = m.useState;"); + }); +}); diff --git a/packages/shared/src/pluginHostWeb.ts b/packages/shared/src/pluginHostWeb.ts new file mode 100644 index 00000000000..613d76764d8 --- /dev/null +++ b/packages/shared/src/pluginHostWeb.ts @@ -0,0 +1,562 @@ +export const PLUGIN_HOST_IMPORT_MAP_MARKER = "data-t3-plugin-host-importmap"; +export const PLUGIN_HOST_BOOTSTRAP_MARKER = "data-t3-plugin-host-bootstrap"; +export const PLUGIN_WEB_BUNDLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; +export const PLUGIN_WEB_SHIM_CACHE_CONTROL = "public, max-age=60"; + +/** + * The runtime import map handed to plugin web bundles. It maps ONLY the bare + * module specifiers below — NOT effect subpaths. + * + * Plugin web bundles must import effect from the barrel + * (`import { Effect, Schema } from "effect"`), never a subpath + * (`import * as Effect from "effect/Effect"` does not resolve in the browser). + * This is a deliberate v1 constraint: web plugins resolve shared modules + * through the native browser import map, which enumerates bare specifiers + * only. (Server plugins differ — a Node resolve hook handles subpaths there.) + * If per-subpath support is ever needed, add `effect/` entries here AND + * generate a shim per subpath that forwards that subpath's own members. + */ +export const pluginHostImportMap = { + imports: { + react: "/plugin-host/react.js", + "react-dom": "/plugin-host/react-dom.js", + "react-dom/client": "/plugin-host/react-dom/client.js", + "react/jsx-runtime": "/plugin-host/react/jsx-runtime.js", + "react/jsx-dev-runtime": "/plugin-host/react/jsx-dev-runtime.js", + "@effect/atom-react": "/plugin-host/@effect/atom-react.js", + effect: "/plugin-host/effect.js", + "@t3tools/plugin-sdk-web": "/plugin-host/@t3tools/plugin-sdk-web.js", + }, +} as const; + +export type PluginHostModuleSpecifier = keyof typeof pluginHostImportMap.imports; + +export const pluginHostModuleSpecifiers = Object.keys( + pluginHostImportMap.imports, +) as ReadonlyArray; + +const reactExports = [ + "Activity", + "Children", + "Component", + "Fragment", + "Profiler", + "PureComponent", + "StrictMode", + "Suspense", + "__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE", + "__COMPILER_RUNTIME", + "act", + "cache", + "cacheSignal", + "captureOwnerStack", + "cloneElement", + "createContext", + "createElement", + "createRef", + "forwardRef", + "isValidElement", + "lazy", + "memo", + "startTransition", + "unstable_useCacheRefresh", + "use", + "useActionState", + "useCallback", + "useContext", + "useDebugValue", + "useDeferredValue", + "useEffect", + "useEffectEvent", + "useId", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useMemo", + "useOptimistic", + "useReducer", + "useRef", + "useState", + "useSyncExternalStore", + "useTransition", + "version", +] as const; + +const reactDomExports = [ + "__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE", + "createPortal", + "flushSync", + "preconnect", + "prefetchDNS", + "preinit", + "preinitModule", + "preload", + "preloadModule", + "requestFormReset", + "unstable_batchedUpdates", + "useFormState", + "useFormStatus", + "version", +] as const; + +const reactDomClientExports = ["createRoot", "hydrateRoot", "version"] as const; +const jsxRuntimeExports = ["Fragment", "jsx", "jsxs"] as const; +const jsxDevRuntimeExports = ["Fragment", "jsxDEV"] as const; + +const atomReactExports = [ + "HydrationBoundary", + "RegistryContext", + "RegistryProvider", + "TypeId", + "make", + "scheduleTask", + "useAtom", + "useAtomInitialValues", + "useAtomMount", + "useAtomRef", + "useAtomRefProp", + "useAtomRefPropValue", + "useAtomRefresh", + "useAtomSet", + "useAtomSubscribe", + "useAtomSuspense", + "useAtomValue", +] as const; + +const effectExports = [ + "Array", + "BigDecimal", + "BigInt", + "Boolean", + "Brand", + "Cache", + "Cause", + "Channel", + "ChannelSchema", + "Chunk", + "Clock", + "Combiner", + "Config", + "ConfigProvider", + "Console", + "Context", + "Cron", + "Crypto", + "Data", + "DateTime", + "Deferred", + "Differ", + "Duration", + "Effect", + "Effectable", + "Encoding", + "Equal", + "Equivalence", + "ErrorReporter", + "ExecutionPlan", + "Exit", + "Fiber", + "FiberHandle", + "FiberMap", + "FiberSet", + "FileSystem", + "Filter", + "Formatter", + "Function", + "Graph", + "HKT", + "Hash", + "HashMap", + "HashRing", + "HashSet", + "Inspectable", + "Iterable", + "JsonPatch", + "JsonPointer", + "JsonSchema", + "Latch", + "Layer", + "LayerMap", + "LogLevel", + "Logger", + "ManagedRuntime", + "Match", + "Metric", + "MutableHashMap", + "MutableHashSet", + "MutableList", + "MutableRef", + "Newtype", + "NonEmptyIterable", + "Number", + "Optic", + "Option", + "Order", + "Ordering", + "PartitionedSemaphore", + "Path", + "Pipeable", + "PlatformError", + "Pool", + "Predicate", + "PrimaryKey", + "PubSub", + "Pull", + "Queue", + "Random", + "RcMap", + "RcRef", + "Record", + "Redactable", + "Redacted", + "Reducer", + "Ref", + "References", + "RegExp", + "Request", + "RequestResolver", + "Resource", + "Result", + "Runtime", + "Schedule", + "Scheduler", + "Schema", + "SchemaAST", + "SchemaGetter", + "SchemaIssue", + "SchemaParser", + "SchemaRepresentation", + "SchemaTransformation", + "SchemaUtils", + "Scope", + "ScopedCache", + "ScopedRef", + "Semaphore", + "Sink", + "Stdio", + "Stream", + "String", + "Struct", + "SubscriptionRef", + "Symbol", + "SynchronizedRef", + "Take", + "Terminal", + "Tracer", + "Trie", + "Tuple", + "TxChunk", + "TxDeferred", + "TxHashMap", + "TxHashSet", + "TxPriorityQueue", + "TxPubSub", + "TxQueue", + "TxReentrantLock", + "TxRef", + "TxSemaphore", + "TxSubscriptionRef", + "Types", + "UndefinedOr", + "Unify", + "Utils", + "absurd", + "cast", + "flow", + "hole", + "identity", + "pipe", +] as const; + +const pluginSdkWebExports = [ + "Alert", + "AlertAction", + "AlertDescription", + "AlertDialog", + "AlertDialogBackdrop", + "AlertDialogClose", + "AlertDialogContent", + "AlertDialogCreateHandle", + "AlertDialogDescription", + "AlertDialogFooter", + "AlertDialogHeader", + "AlertDialogOverlay", + "AlertDialogPopup", + "AlertDialogPortal", + "AlertDialogTitle", + "AlertDialogTrigger", + "AlertDialogViewport", + "AlertTitle", + "AnchoredToastProvider", + "Badge", + "Button", + "Card", + "CardAction", + "CardContent", + "CardDescription", + "CardFooter", + "CardFrame", + "CardFrameDescription", + "CardFrameFooter", + "CardFrameHeader", + "CardFrameTitle", + "CardHeader", + "CardPanel", + "CardTitle", + "ChatMarkdown", + "Checkbox", + "Command", + "CommandCollection", + "CommandDialog", + "CommandDialogPopup", + "CommandDialogTrigger", + "CommandEmpty", + "CommandFooter", + "CommandGroup", + "CommandGroupLabel", + "CommandInput", + "CommandItem", + "CommandList", + "CommandPanel", + "CommandSeparator", + "CommandShortcut", + "Dialog", + "DialogBackdrop", + "DialogClose", + "DialogContent", + "DialogCreateHandle", + "DialogDescription", + "DialogFooter", + "DialogHeader", + "DialogOverlay", + "DialogPanel", + "DialogPopup", + "DialogPortal", + "DialogTitle", + "DialogTrigger", + "DialogViewport", + "DropdownMenu", + "DropdownMenuCheckboxItem", + "DropdownMenuContent", + "DropdownMenuCreateHandle", + "DropdownMenuGroup", + "DropdownMenuItem", + "DropdownMenuLabel", + "DropdownMenuPortal", + "DropdownMenuRadioGroup", + "DropdownMenuRadioItem", + "DropdownMenuSeparator", + "DropdownMenuShortcut", + "DropdownMenuSub", + "DropdownMenuSubContent", + "DropdownMenuSubTrigger", + "DropdownMenuTrigger", + "Empty", + "EmptyContent", + "EmptyDescription", + "EmptyHeader", + "EmptyMedia", + "EmptyTitle", + "Field", + "FieldControl", + "FieldDescription", + "FieldError", + "FieldItem", + "FieldLabel", + "FieldValidity", + "HydrationBoundary", + "Input", + "Label", + "Menu", + "MenuCheckboxItem", + "MenuCreateHandle", + "MenuGroup", + "MenuGroupLabel", + "MenuItem", + "MenuPortal", + "MenuPopup", + "MenuRadioGroup", + "MenuRadioItem", + "MenuSeparator", + "MenuShortcut", + "MenuSub", + "MenuSubPopup", + "MenuSubTrigger", + "MenuTrigger", + "Popover", + "PopoverClose", + "PopoverContent", + "PopoverCreateHandle", + "PopoverDescription", + "PopoverPopup", + "PopoverTitle", + "PopoverTrigger", + "ProviderModelPicker", + "RegistryContext", + "RegistryProvider", + "ScrollArea", + "ScrollBar", + "Select", + "SelectButton", + "SelectContent", + "SelectGroup", + "SelectGroupLabel", + "SelectItem", + "SelectPopup", + "SelectSeparator", + "SelectTrigger", + "SelectValue", + "Separator", + "Sheet", + "SheetBackdrop", + "SheetClose", + "SheetContent", + "SheetDescription", + "SheetFooter", + "SheetHeader", + "SheetOverlay", + "SheetPanel", + "SheetPopup", + "SheetPortal", + "SheetTitle", + "SheetTrigger", + "Sidebar", + "SidebarContent", + "SidebarFooter", + "SidebarGroup", + "SidebarGroupAction", + "SidebarGroupContent", + "SidebarGroupLabel", + "SidebarHeader", + "SidebarInput", + "SidebarInset", + "SidebarMenu", + "SidebarMenuAction", + "SidebarMenuBadge", + "SidebarMenuButton", + "SidebarMenuItem", + "SidebarMenuSkeleton", + "SidebarMenuSub", + "SidebarMenuSubButton", + "SidebarMenuSubItem", + "SidebarProvider", + "SidebarRail", + "SidebarSeparator", + "SidebarTrigger", + "Spinner", + "Switch", + "Textarea", + "ToastProvider", + "Tooltip", + "TooltipCreateHandle", + "TooltipPopup", + "TooltipProvider", + "TooltipTrigger", + "TraitsMenuContent", + "TraitsPicker", + "TypeId", + "anchoredToastManager", + "AsyncResult", + "Atom", + "AtomRegistry", + "badgeVariants", + "buttonVariants", + "createPluginAtoms", + "getAppAtomRegistry", + "getConnectionAtomRuntime", + "hostCompat", + "isPluginSdkWebExternal", + "make", + "pluginSdkWebExternalDependencies", + "scheduleTask", + "selectTriggerVariants", + "shouldRenderTraitsControls", + "stackedThreadToast", + "toastManager", + "useAtom", + "useAtomCommand", + "useAtomInitialValues", + "useAtomMount", + "useAtomQueryRunner", + "useAtomRef", + "useAtomRefProp", + "useAtomRefPropValue", + "useAtomRefresh", + "useAtomSet", + "useAtomSubscribe", + "useAtomSuspense", + "useAtomValue", + "useSidebar", + "useSidebarVisibility", +] as const; + +export const pluginHostShimExportNames = { + react: reactExports, + "react-dom": reactDomExports, + "react-dom/client": reactDomClientExports, + "react/jsx-runtime": jsxRuntimeExports, + "react/jsx-dev-runtime": jsxDevRuntimeExports, + "@effect/atom-react": atomReactExports, + effect: effectExports, + "@t3tools/plugin-sdk-web": pluginSdkWebExports, +} satisfies Record>; + +export function pluginHostModulePath(moduleName: PluginHostModuleSpecifier): string { + return pluginHostImportMap.imports[moduleName].slice("/plugin-host/".length); +} + +export function pluginHostModuleFromPath(pathname: string): PluginHostModuleSpecifier | null { + if (!pathname.endsWith(".js")) return null; + const moduleName = pathname.slice(0, -".js".length); + return pluginHostModuleSpecifiers.includes(moduleName as PluginHostModuleSpecifier) + ? (moduleName as PluginHostModuleSpecifier) + : null; +} + +export function getPluginHostShimSource(moduleName: PluginHostModuleSpecifier): string { + const exportLines = pluginHostShimExportNames[moduleName] + .map((name) => `export const ${name} = m.${name};`) + .join("\n"); + return [ + `const m = globalThis.__T3_PLUGIN_HOST__[${JSON.stringify(moduleName)}];`, + `if (!m) throw new Error(${JSON.stringify(`T3 plugin host module ${moduleName} is not ready.`)});`, + "export default m.default ?? m;", + exportLines, + "", + ].join("\n"); +} + +export const pluginHostBootstrapSource = ` +const readyKey = "__T3_PLUGIN_HOST_READY__"; +const resolveKey = "__T3_PLUGIN_HOST_READY_RESOLVE__"; +if (!globalThis[readyKey]) { + globalThis[readyKey] = new Promise((resolve) => { + globalThis[resolveKey] = resolve; + }); +} +if (globalThis.__T3_PLUGIN_HOST__) { + globalThis[resolveKey]?.(globalThis.__T3_PLUGIN_HOST__); +} +`.trim(); + +export function buildPluginHostHeadInjection(): string { + const importMapJson = JSON.stringify(pluginHostImportMap); + return [ + "", + ``, + ``, + "", + ].join("\n"); +} + +export function injectPluginHostHeadHtml(html: string): string { + if (html.includes(PLUGIN_HOST_IMPORT_MAP_MARKER)) { + return html; + } + const injection = `\n${buildPluginHostHeadInjection()}\n`; + const headCloseMatch = /<\/head\s*>/iu.exec(html); + if (!headCloseMatch || headCloseMatch.index === undefined) { + return `${injection}${html}`; + } + return `${html.slice(0, headCloseMatch.index)}${injection}${html.slice(headCloseMatch.index)}`; +} From b5f2240a2ee3626e17abcd859399a5777ff20699 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 06:48:38 -0400 Subject: [PATCH 11/75] Format AgentsCapability review-fix edits Whitespace-only; applies the repo formatter to the hand-applied slice-2a-3b review fixes. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/plugins/capabilities/AgentsCapability.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/plugins/capabilities/AgentsCapability.ts b/apps/server/src/plugins/capabilities/AgentsCapability.ts index 7b2b07dcf70..fa87972eabd 100644 --- a/apps/server/src/plugins/capabilities/AgentsCapability.ts +++ b/apps/server/src/plugins/capabilities/AgentsCapability.ts @@ -363,7 +363,10 @@ export function makeAgentsCapability(input: { commandId: nextCommandId("thread-create-rollback"), threadId: request.threadId, }) - .pipe(Effect.ignore, Effect.andThen(Effect.sync(() => turnAliases.delete(String(turnId))))) + .pipe( + Effect.ignore, + Effect.andThen(Effect.sync(() => turnAliases.delete(String(turnId)))), + ) : Effect.void, ), ); From e12dcc495a900c1ed357c77678f4a97ccb488ecb Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 08:23:31 -0400 Subject: [PATCH 12/75] Add PluginUiHost: load and render plugin web bundles Consumes the web plumbing to dynamically load each active plugin's web bundle, hand it a UI registration context, and render its routes, sidebar sections, and settings pages. The plugin list refreshes live on a new plugins.stateChanged lifecycle event. No marketplace/install UI yet; zero-plugin behavior is unchanged. - PluginUiHost: after whenPluginHostReady, imports active hasWeb plugins and runs their register(ctx); per-plugin failure is contained (shell survives, plugin marked failed); a single-flight guard prevents overlapping syncs from double-importing; inactive plugins are pruned from the renderable registry - state/plugins: pluginListAtom off subscribeServerLifecycle, refreshing on plugins.stateChanged; per-plugin pluginRpc (call/subscribe) - server: PluginHost publishes plugins.stateChanged on active/failed/ disabled transitions via the lifecycle event stream - splat routes for plugin app pages and settings pages; PluginSidebar Sections (renders nothing when no plugins); extensible settings nav - plugin-sdk-web: defineWebPlugin + the registration context types Reviewed by Claude + Grok. MUST fixed: registered components are rendered with createElement (own React fiber), not called as functions - a function call ran plugin hooks on the host route's fiber and broke the Rules of Hooks. SHOULD fixed: single-flight sync guard. Implemented by GPT-5.5 via codex exec (assembly-line slice 2b-2). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/plugins/PluginHost.test.ts | 28 ++ apps/server/src/plugins/PluginHost.ts | 77 +++-- apps/server/src/serverLifecycleEvents.ts | 7 +- apps/web/src/components/Sidebar.tsx | 2 + .../settings/SettingsSidebarNav.test.ts | 42 +++ .../settings/SettingsSidebarNav.tsx | 29 +- .../src/plugins/PluginSidebarSections.test.ts | 36 ++ .../web/src/plugins/PluginSidebarSections.tsx | 54 +++ apps/web/src/plugins/PluginUiHost.test.tsx | 156 +++++++++ apps/web/src/plugins/PluginUiHost.tsx | 311 ++++++++++++++++++ apps/web/src/plugins/hostSingletons.test.ts | 5 +- apps/web/src/routeTree.gen.ts | 43 +++ apps/web/src/routes/__root.tsx | 2 + .../_chat.$environmentId.p.$pluginId.$.tsx | 62 ++++ apps/web/src/routes/settings.$.tsx | 84 +++++ apps/web/src/state/plugins.test.ts | 68 ++++ apps/web/src/state/plugins.ts | 138 ++++++++ .../client-runtime/src/state/server.test.ts | 34 +- packages/client-runtime/src/state/server.ts | 31 +- packages/contracts/src/server.test.ts | 24 +- packages/contracts/src/server.ts | 18 + packages/plugin-sdk-web/src/index.test.tsx | 29 ++ packages/plugin-sdk-web/src/index.ts | 75 ++++- packages/shared/src/pluginHostWeb.ts | 1 + 24 files changed, 1295 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsSidebarNav.test.ts create mode 100644 apps/web/src/plugins/PluginSidebarSections.test.ts create mode 100644 apps/web/src/plugins/PluginSidebarSections.tsx create mode 100644 apps/web/src/plugins/PluginUiHost.test.tsx create mode 100644 apps/web/src/plugins/PluginUiHost.tsx create mode 100644 apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx create mode 100644 apps/web/src/routes/settings.$.tsx create mode 100644 apps/web/src/state/plugins.test.ts create mode 100644 apps/web/src/state/plugins.ts diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index 7186c391995..ad25a71c581 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts/plugin"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -19,6 +20,7 @@ import * as NodeURL from "node:url"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; @@ -52,6 +54,7 @@ const testLayerBase = PluginHostModule.layer.pipe( Layer.provideMerge(PluginMigrator.layer), Layer.provideMerge(PluginRuntimeRegistryLayer.layer), Layer.provideMerge(PluginHttpRegistry.layer), + Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provideMerge( Layer.mock(ServerSecretStore.ServerSecretStore)({ get: unexpectedCapabilityUse, @@ -432,6 +435,31 @@ layer("PluginHost", (it) => { }), ); + it.effect("publishes plugin state changes on the server lifecycle stream", () => + Effect.gen(function* () { + const pluginId = PluginId.make("lifecycle-plugin"); + const host = yield* PluginHostModule.PluginHost; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + + const eventFiber = yield* lifecycleEvents.stream.pipe( + Stream.filter((event) => event.type === "plugins" && event.payload.pluginId === pluginId), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* installPlugin({ pluginId, entrySource: "throw new Error('lifecycle boom');" }); + yield* host.start; + + const events = Array.from(yield* Fiber.join(eventFiber)); + assert.deepEqual(events[0]?.payload, { + kind: "plugin-state-changed", + pluginId, + state: "failed", + }); + }), + ); + it.effect("marks failed imports without failing host startup", () => Effect.gen(function* () { const pluginId = PluginId.make("failed-plugin"); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 81c6090dd4b..1aec0644fee 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -5,6 +5,7 @@ import { type PluginId, type PluginLockfile, type PluginLockfilePlugin, + type PluginState, } from "@t3tools/contracts/plugin"; import type { PluginDefinition, @@ -34,6 +35,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; @@ -339,6 +341,25 @@ export const make = Effect.fn("PluginHost.make")(function* () { const sourceControlRegistry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const github = yield* GitHubCli.GitHubCli; const terminals = yield* TerminalManager.TerminalManager; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + + const publishPluginStateChanged = (pluginId: PluginId, state: PluginState) => + lifecycleEvents + .publish({ + version: 1, + type: "plugins", + payload: { + kind: "plugin-state-changed", + pluginId, + state, + }, + }) + .pipe(Effect.ignoreCause({ log: true }), Effect.asVoid); + + const markFailure = (pluginId: PluginId, message: string) => + updateFailure(store, pluginId, message).pipe( + Effect.tap(() => publishPluginStateChanged(pluginId, "failed")), + ); const readManifest = (pluginDir: string) => fs @@ -356,9 +377,11 @@ export const make = Effect.fn("PluginHost.make")(function* () { }); } if (!hostApiSatisfies(manifest.hostApi, HOST_API_VERSION)) { - yield* store.updatePlugin(pluginId, ({ current }) => - Effect.succeed(current ? { ...current, state: "disabled-by-host" } : undefined), - ); + yield* store + .updatePlugin(pluginId, ({ current }) => + Effect.succeed(current ? { ...current, state: "disabled-by-host" } : undefined), + ) + .pipe(Effect.tap(() => publishPluginStateChanged(pluginId, "disabled-by-host"))); yield* Effect.logWarning("Plugin disabled by host API version mismatch", { pluginId, requested: manifest.hostApi, @@ -374,7 +397,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { const serverEntry = manifest.entries.server; const serverEntryPath = path.join(pluginDir, serverEntry); if (!(yield* fs.exists(pluginDir)) || !(yield* fs.exists(serverEntryPath))) { - yield* updateFailure(store, pluginId, "plugin directory or server entry is missing"); + yield* markFailure(pluginId, "plugin directory or server entry is missing"); return; } @@ -479,8 +502,10 @@ export const make = Effect.fn("PluginHost.make")(function* () { if (Exit.isFailure(exit)) { yield* Scope.close(scope, exit); const message = Cause.pretty(exit.cause); - yield* updateFailure(store, pluginId, message); + yield* markFailure(pluginId, message); yield* Effect.logWarning("Plugin activation failed", { pluginId, cause: message }); + } else { + yield* publishPluginStateChanged(pluginId, "active"); } }); @@ -493,34 +518,34 @@ export const make = Effect.fn("PluginHost.make")(function* () { } if (entry.state === "pending-upgrade") { if (!entry.staged) { - yield* updateFailure( - store, - pluginId, - "pending upgrade is missing staged plugin metadata", - ); + yield* markFailure(pluginId, "pending upgrade is missing staged plugin metadata"); return false; } const staged = entry.staged; - yield* store.updatePlugin(pluginId, ({ current }) => - Effect.succeed(current ? upgradeLockfileEntry(current, staged) : undefined), - ); + yield* store + .updatePlugin(pluginId, ({ current }) => + Effect.succeed(current ? upgradeLockfileEntry(current, staged) : undefined), + ) + .pipe(Effect.tap(() => publishPluginStateChanged(pluginId, "active"))); return true; } if (entry.activation.activatingSince !== null) { const crashCount = entry.activation.crashCount + 1; if (crashCount >= 2) { - yield* store.updatePlugin(pluginId, ({ current }) => - Effect.succeed( - current - ? { - ...current, - state: "failed", - lastError: "disabled after repeated crashes", - activation: { activatingSince: null, crashCount }, - } - : undefined, - ), - ); + yield* store + .updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + state: "failed", + lastError: "disabled after repeated crashes", + activation: { activatingSince: null, crashCount }, + } + : undefined, + ), + ) + .pipe(Effect.tap(() => publishPluginStateChanged(pluginId, "failed"))); return false; } yield* store.updatePlugin(pluginId, ({ current }) => @@ -571,7 +596,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { if (!currentEntry?.enabled || currentEntry.state !== "active") continue; yield* loadPlugin(pluginId, currentEntry).pipe( Effect.catchCause((cause) => - updateFailure(store, pluginId, Cause.pretty(cause)).pipe( + markFailure(pluginId, Cause.pretty(cause)).pipe( Effect.andThen( Effect.logWarning("Plugin activation failed before scope acquisition", { pluginId, diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 855d03490ef..5d6900f1719 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -8,7 +8,8 @@ import * as Stream from "effect/Stream"; type LifecycleEventInput = | Omit, "sequence"> - | Omit, "sequence">; + | Omit, "sequence"> + | Omit, "sequence">; interface SnapshotState { readonly sequence: number; @@ -42,7 +43,9 @@ const make = Effect.gen(function* () { const nextEvents = nextEvent.type === "welcome" ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")] - : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")]; + : nextEvent.type === "ready" + ? [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")] + : current.events; return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), snapshot: Ref.get(state), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..9700e14c626 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -126,6 +126,7 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { PluginSidebarSections } from "../plugins/PluginSidebarSections"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, @@ -3742,6 +3743,7 @@ export default function Sidebar() { projectsLength={projects.length} /> + diff --git a/apps/web/src/components/settings/SettingsSidebarNav.test.ts b/apps/web/src/components/settings/SettingsSidebarNav.test.ts new file mode 100644 index 00000000000..5ef5a1f207e --- /dev/null +++ b/apps/web/src/components/settings/SettingsSidebarNav.test.ts @@ -0,0 +1,42 @@ +import { PluginId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT } from "../../plugins/PluginUiHost"; +import { getSettingsNavItems } from "./SettingsSidebarNav"; + +describe("SettingsSidebarNav plugin entries", () => { + it("keeps the core settings navigation unchanged when no plugins register pages", () => { + expect(getSettingsNavItems(EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT).map((item) => item.to)).toEqual([ + "/settings/general", + "/settings/keybindings", + "/settings/providers", + "/settings/source-control", + "/settings/connections", + "/settings/archived", + ]); + }); + + it("adds registered plugin settings pages after core items", () => { + expect( + getSettingsNavItems({ + ...EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT, + settingsPages: [ + { + pluginId: PluginId.make("fixture-plugin"), + id: "general", + title: "Fixture", + component: () => null, + }, + ], + }).map((item) => item.to), + ).toEqual([ + "/settings/general", + "/settings/keybindings", + "/settings/providers", + "/settings/source-control", + "/settings/connections", + "/settings/archived", + "/settings/fixture-plugin/general", + ]); + }); +}); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..357fb16bf6f 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -6,9 +6,11 @@ import { GitBranchIcon, KeyboardIcon, Link2Icon, + PuzzleIcon, Settings2Icon, } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import { SidebarContent, @@ -21,18 +23,20 @@ import { useSidebar, } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; +import { pluginUiRegistryAtom, type PluginUiRegistrySnapshot } from "../../plugins/PluginUiHost"; -export type SettingsSectionPath = +export type CoreSettingsSectionPath = | "/settings/general" | "/settings/keybindings" | "/settings/providers" | "/settings/source-control" | "/settings/connections" | "/settings/archived"; +export type SettingsSectionPath = CoreSettingsSectionPath | `/settings/${string}`; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; - to: SettingsSectionPath; + to: CoreSettingsSectionPath; icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, @@ -43,16 +47,33 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; +export function getSettingsNavItems(snapshot: PluginUiRegistrySnapshot): ReadonlyArray<{ + readonly label: string; + readonly to: SettingsSectionPath; + readonly icon: ComponentType<{ className?: string }>; +}> { + return [ + ...SETTINGS_NAV_ITEMS, + ...snapshot.settingsPages.map((page) => ({ + label: page.title, + to: `/settings/${page.pluginId}/${page.id}` as const, + icon: PuzzleIcon, + })), + ]; +} + export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); + const pluginRegistry = useAtomValue(pluginUiRegistryAtom); + const navItems = getSettingsNavItems(pluginRegistry); const handleSectionClick = useCallback( (to: SettingsSectionPath) => { if (isMobile) { setOpenMobile(false); } - void navigate({ to, replace: true }); + void navigate({ to: to as never, replace: true }); }, [isMobile, navigate, setOpenMobile], ); @@ -72,7 +93,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { - {SETTINGS_NAV_ITEMS.map((item) => { + {navItems.map((item) => { const Icon = item.icon; const isActive = pathname === item.to; return ( diff --git a/apps/web/src/plugins/PluginSidebarSections.test.ts b/apps/web/src/plugins/PluginSidebarSections.test.ts new file mode 100644 index 00000000000..d18af91201c --- /dev/null +++ b/apps/web/src/plugins/PluginSidebarSections.test.ts @@ -0,0 +1,36 @@ +import { PluginId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT } from "./PluginUiHost"; +import { getVisiblePluginSidebarSections } from "./PluginSidebarSections"; + +describe("PluginSidebarSections", () => { + it("renders no sidebar sections for the zero-plugin registry", () => { + expect(getVisiblePluginSidebarSections(EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT)).toEqual([]); + }); + + it("returns registered sidebar sections in registry order", () => { + const pluginId = PluginId.make("fixture-plugin"); + + expect( + getVisiblePluginSidebarSections({ + ...EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT, + sidebarSections: [ + { + pluginId, + id: "main", + title: "Fixture", + render: () => null, + }, + ], + }), + ).toEqual([ + { + pluginId, + id: "main", + title: "Fixture", + render: expect.any(Function), + }, + ]); + }); +}); diff --git a/apps/web/src/plugins/PluginSidebarSections.tsx b/apps/web/src/plugins/PluginSidebarSections.tsx new file mode 100644 index 00000000000..a03d9d315bf --- /dev/null +++ b/apps/web/src/plugins/PluginSidebarSections.tsx @@ -0,0 +1,54 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createElement, type FunctionComponent } from "react"; +import type { PluginSidebarSectionRenderProps } from "@t3tools/plugin-sdk-web"; + +import { useActiveEnvironmentId } from "../state/entities"; +import { + PluginSurfaceErrorBoundary, + pluginUiRegistryAtom, + type PluginUiRegistrySnapshot, +} from "./PluginUiHost"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuItem, +} from "../components/ui/sidebar"; + +export function getVisiblePluginSidebarSections(snapshot: PluginUiRegistrySnapshot) { + return snapshot.sidebarSections; +} + +export function PluginSidebarSections() { + const snapshot = useAtomValue(pluginUiRegistryAtom); + const environmentId = useActiveEnvironmentId(); + const sections = getVisiblePluginSidebarSections(snapshot); + + if (sections.length === 0) { + return null; + } + + return ( + <> + {sections.map((section) => { + const routeBasePath = + environmentId === null ? null : `/${environmentId}/p/${section.pluginId}`; + return ( + + {section.title} + + + + {createElement( + section.render as FunctionComponent, + { pluginId: section.pluginId, environmentId, routeBasePath }, + )} + + + + + ); + })} + + ); +} diff --git a/apps/web/src/plugins/PluginUiHost.test.tsx b/apps/web/src/plugins/PluginUiHost.test.tsx new file mode 100644 index 00000000000..77786339de6 --- /dev/null +++ b/apps/web/src/plugins/PluginUiHost.test.tsx @@ -0,0 +1,156 @@ +import { PluginId, type PluginInfo } from "@t3tools/contracts"; +import { defineWebPlugin } from "@t3tools/plugin-sdk-web"; +import { describe, expect, it } from "vite-plus/test"; +import * as Stream from "effect/Stream"; + +import { + createPluginUiHostState, + getPluginWebEntryUrl, + resolvePluginRouteRegistration, + resolvePluginSettingsPageRegistration, + syncPluginUiHostRegistrations, +} from "./PluginUiHost"; + +const fixturePluginId = PluginId.make("fixture-plugin"); +const failingPluginId = PluginId.make("failing-plugin"); + +function pluginInfo(overrides: Partial = {}): PluginInfo { + return { + id: fixturePluginId, + name: "Fixture", + version: "1.2.3", + state: "active", + capabilities: [], + hasWeb: true, + lastError: null, + ...overrides, + }; +} + +describe("PluginUiHost", () => { + it("awaits host readiness before importing and captures plugin registrations", async () => { + const state = createPluginUiHostState(); + const order: string[] = []; + const rpcCalls: string[] = []; + + const snapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo()], + waitForHost: async () => { + order.push("ready"); + }, + importWebPlugin: async (url) => { + order.push(`import:${url}`); + return { + default: defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ + path: "overview", + component: () => null, + }); + ctx.registerSidebarSection({ + id: "main", + title: "Main", + render: () => null, + }); + ctx.registerSettingsPage({ + id: "general", + title: "General", + component: () => null, + }); + void ctx.rpc.call("ping"); + }, + }), + }; + }, + createRpc: (pluginId) => ({ + call: (method) => { + rpcCalls.push(`${pluginId}:${method}`); + return Promise.resolve(null); + }, + subscribe: (method, payload) => Stream.make({ method, payload }), + }), + }); + + expect(order).toEqual(["ready", "import:/plugins/fixture-plugin/1.2.3/web/index.js"]); + expect(rpcCalls).toEqual(["fixture-plugin:ping"]); + expect(snapshot.routes).toHaveLength(1); + expect(snapshot.sidebarSections).toHaveLength(1); + expect(snapshot.settingsPages).toHaveLength(1); + expect(snapshot.failures).toEqual({}); + }); + + it("contains failing imports and removes surfaces when a plugin leaves active state", async () => { + const state = createPluginUiHostState(); + + const activeSnapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo()], + waitForHost: async () => undefined, + importWebPlugin: async () => ({ + default: defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ path: "overview", component: () => null }); + }, + }), + }), + }); + expect(activeSnapshot.routes).toHaveLength(1); + + const failedSnapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo({ id: failingPluginId, name: "Failing", version: "2.0.0" })], + waitForHost: async () => undefined, + importWebPlugin: async () => { + throw new Error("boom"); + }, + }); + expect(failedSnapshot.routes).toHaveLength(0); + expect(failedSnapshot.failures[failingPluginId]).toContain("boom"); + + const emptySnapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [], + waitForHost: async () => undefined, + importWebPlugin: async () => { + throw new Error("should not import"); + }, + }); + expect(emptySnapshot.routes).toEqual([]); + expect(emptySnapshot.failures).toEqual({}); + }); + + it("resolves registered plugin routes and settings pages without crashing unknown paths", async () => { + const state = createPluginUiHostState(); + const snapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo()], + waitForHost: async () => undefined, + importWebPlugin: async () => ({ + default: defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ path: "overview", component: () => null }); + ctx.registerSettingsPage({ + id: "general", + title: "General", + component: () => null, + }); + }, + }), + }), + }); + + expect(resolvePluginRouteRegistration(snapshot, fixturePluginId, "overview")?.path).toBe( + "overview", + ); + expect(resolvePluginRouteRegistration(snapshot, fixturePluginId, "missing")).toBeNull(); + expect(resolvePluginSettingsPageRegistration(snapshot, fixturePluginId, "general")?.id).toBe( + "general", + ); + expect(resolvePluginSettingsPageRegistration(snapshot, fixturePluginId, "missing")).toBeNull(); + }); + + it("uses the conventional same-origin web entry URL", () => { + expect(getPluginWebEntryUrl(pluginInfo())).toBe("/plugins/fixture-plugin/1.2.3/web/index.js"); + }); +}); diff --git a/apps/web/src/plugins/PluginUiHost.tsx b/apps/web/src/plugins/PluginUiHost.tsx new file mode 100644 index 00000000000..352629f3365 --- /dev/null +++ b/apps/web/src/plugins/PluginUiHost.tsx @@ -0,0 +1,311 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { + PluginCommandRegistration, + PluginRouteRegistration, + PluginSettingsPageRegistration, + PluginSidebarSectionRegistration, + PluginUiContext, + PluginWebDefinition, + PluginWebRpc, +} from "@t3tools/plugin-sdk-web"; +import type { PluginId, PluginInfo } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { Component, useEffect, useRef, type ErrorInfo, type ReactNode } from "react"; + +import { pluginListAtom, pluginRpc } from "../state/plugins"; +import { whenPluginHostReady } from "./hostSingletons"; + +export interface RegisteredPluginRoute extends PluginRouteRegistration { + readonly pluginId: PluginId; +} + +export interface RegisteredPluginSidebarSection extends PluginSidebarSectionRegistration { + readonly pluginId: PluginId; +} + +export interface RegisteredPluginSettingsPage extends PluginSettingsPageRegistration { + readonly pluginId: PluginId; +} + +export interface RegisteredPluginCommand extends PluginCommandRegistration { + readonly pluginId: PluginId; +} + +export interface PluginUiRegistrySnapshot { + readonly routes: ReadonlyArray; + readonly sidebarSections: ReadonlyArray; + readonly settingsPages: ReadonlyArray; + readonly commands: ReadonlyArray; + readonly failures: Readonly>; +} + +interface LoadedPlugin { + readonly pluginId: PluginId; + readonly version: string; + readonly routes: ReadonlyArray; + readonly sidebarSections: ReadonlyArray; + readonly settingsPages: ReadonlyArray; + readonly commands: ReadonlyArray; + readonly failure: string | null; +} + +export interface PluginUiHostState { + readonly loaded: Map; +} + +export const EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT: PluginUiRegistrySnapshot = Object.freeze({ + routes: Object.freeze([]), + sidebarSections: Object.freeze([]), + settingsPages: Object.freeze([]), + commands: Object.freeze([]), + failures: Object.freeze({}), +}); + +export const pluginUiRegistryAtom = Atom.make( + EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT, +).pipe(Atom.keepAlive, Atom.withLabel("web-plugins:ui-registry")); + +export function createPluginUiHostState(): PluginUiHostState { + return { loaded: new Map() }; +} + +function normalizePluginPath(path: string): string { + return path + .split("/") + .filter((part) => part.length > 0) + .join("/"); +} + +function formatPluginError(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 ? error.message : String(error); +} + +function snapshotFromState(state: PluginUiHostState): PluginUiRegistrySnapshot { + const routes: Array = []; + const sidebarSections: Array = []; + const settingsPages: Array = []; + const commands: Array = []; + const failures: Record = {}; + + for (const loaded of state.loaded.values()) { + if (loaded.failure !== null) { + failures[loaded.pluginId] = loaded.failure; + continue; + } + routes.push(...loaded.routes); + sidebarSections.push(...loaded.sidebarSections); + settingsPages.push(...loaded.settingsPages); + commands.push(...loaded.commands); + } + + return { routes, sidebarSections, settingsPages, commands, failures }; +} + +export function getPluginWebEntryUrl(plugin: Pick): string { + // Slice 2b-2 uses the bundle convention served by PluginWebRoutes. + return `/plugins/${encodeURIComponent(plugin.id)}/${encodeURIComponent(plugin.version)}/web/index.js`; +} + +function makePluginLogger(pluginId: PluginId): PluginUiContext["logger"] { + const prefix = `[plugin:${pluginId}]`; + return { + debug: (message, data) => console.debug(prefix, message, data), + info: (message, data) => console.info(prefix, message, data), + warn: (message, data) => console.warn(prefix, message, data), + error: (message, data) => console.error(prefix, message, data), + }; +} + +function getDefinition(module: unknown): PluginWebDefinition { + const candidate = + typeof module === "object" && module !== null && "default" in module + ? (module as { readonly default?: unknown }).default + : null; + if ( + typeof candidate !== "object" || + candidate === null || + !("register" in candidate) || + typeof candidate.register !== "function" + ) { + throw new Error("Plugin web entry does not default-export a defineWebPlugin-shaped object."); + } + return candidate as PluginWebDefinition; +} + +async function maybeAwait(value: void | Promise): Promise { + await value; +} + +export interface SyncPluginUiHostRegistrationsInput { + readonly state: PluginUiHostState; + readonly plugins: ReadonlyArray; + readonly waitForHost: () => Promise; + readonly importWebPlugin: (url: string) => Promise; + readonly createRpc?: (pluginId: PluginId) => PluginWebRpc; +} + +export async function syncPluginUiHostRegistrations({ + state, + plugins, + waitForHost, + importWebPlugin, + createRpc = pluginRpc, +}: SyncPluginUiHostRegistrationsInput): Promise { + const activeWebPlugins = plugins.filter((plugin) => plugin.state === "active" && plugin.hasWeb); + const activeKeys = new Set(activeWebPlugins.map((plugin) => `${plugin.id}@${plugin.version}`)); + + for (const [pluginId, loaded] of state.loaded.entries()) { + if (!activeKeys.has(`${pluginId}@${loaded.version}`)) { + state.loaded.delete(pluginId); + } + } + + const pluginsToLoad = activeWebPlugins.filter((plugin) => !state.loaded.has(plugin.id)); + if (pluginsToLoad.length > 0) { + await waitForHost(); + } + + for (const plugin of pluginsToLoad) { + const routes: Array = []; + const sidebarSections: Array = []; + const settingsPages: Array = []; + const commands: Array = []; + + try { + const module = await importWebPlugin(getPluginWebEntryUrl(plugin)); + const definition = getDefinition(module); + const ctx: PluginUiContext = { + pluginId: plugin.id, + rpc: createRpc(plugin.id), + logger: makePluginLogger(plugin.id), + registerRoute: (registration) => { + routes.push({ + ...registration, + pluginId: plugin.id, + path: normalizePluginPath(registration.path), + }); + }, + registerSidebarSection: (registration) => { + sidebarSections.push({ ...registration, pluginId: plugin.id }); + }, + registerSettingsPage: (registration) => { + settingsPages.push({ ...registration, pluginId: plugin.id }); + }, + registerCommand: (registration) => { + commands.push({ ...registration, pluginId: plugin.id }); + }, + }; + await maybeAwait(definition.register(ctx)); + state.loaded.set(plugin.id, { + pluginId: plugin.id, + version: plugin.version, + routes, + sidebarSections, + settingsPages, + commands, + failure: null, + }); + } catch (error) { + const message = formatPluginError(error); + console.error(`[plugin:${plugin.id}] failed to register web UI`, error); + state.loaded.set(plugin.id, { + pluginId: plugin.id, + version: plugin.version, + routes: [], + sidebarSections: [], + settingsPages: [], + commands: [], + failure: message, + }); + } + } + + return snapshotFromState(state); +} + +export function resolvePluginRouteRegistration( + snapshot: PluginUiRegistrySnapshot, + pluginId: PluginId, + path: string | null | undefined, +): RegisteredPluginRoute | null { + const normalizedPath = normalizePluginPath(path ?? ""); + return ( + snapshot.routes.find((route) => route.pluginId === pluginId && route.path === normalizedPath) ?? + null + ); +} + +export function resolvePluginSettingsPageRegistration( + snapshot: PluginUiRegistrySnapshot, + pluginId: PluginId, + pageId: string | null | undefined, +): RegisteredPluginSettingsPage | null { + const normalizedPageId = normalizePluginPath(pageId ?? ""); + return ( + snapshot.settingsPages.find( + (page) => page.pluginId === pluginId && page.id === normalizedPageId, + ) ?? null + ); +} + +export function PluginUiHost() { + const plugins = useAtomValue(pluginListAtom); + const setRegistry = useAtomSet(pluginUiRegistryAtom); + const stateRef = useRef(createPluginUiHostState()); + // Single-flight the sync: syncs mutate the shared loaded Map, and two + // overlapping runs could import + register the same plugin twice. Chain each + // run after the previous so they never interleave; the latest plugins list + // always gets applied last. + const syncChainRef = useRef>(Promise.resolve()); + + useEffect(() => { + let cancelled = false; + const run = syncChainRef.current.then(() => + syncPluginUiHostRegistrations({ + state: stateRef.current, + plugins, + waitForHost: () => whenPluginHostReady, + importWebPlugin: (url) => import(/* @vite-ignore */ url), + }).then((snapshot) => { + if (!cancelled) { + setRegistry(snapshot); + } + }), + ); + syncChainRef.current = run.catch((error) => { + console.error("[plugin-ui-host] registry sync failed", error); + }); + + return () => { + cancelled = true; + }; + }, [plugins, setRegistry]); + + return null; +} + +export class PluginSurfaceErrorBoundary extends Component< + { readonly children: ReactNode; readonly label: string }, + { readonly error: Error | null } +> { + override state = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo) { + console.error(`[plugin-ui] ${this.props.label} crashed`, error, info); + } + + override render() { + if (this.state.error) { + return ( +
+ Plugin surface failed to render. +
+ ); + } + return this.props.children; + } +} diff --git a/apps/web/src/plugins/hostSingletons.test.ts b/apps/web/src/plugins/hostSingletons.test.ts index 5fd4513b807..152acc334ab 100644 --- a/apps/web/src/plugins/hostSingletons.test.ts +++ b/apps/web/src/plugins/hostSingletons.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { - getPluginHostShimSource, - pluginHostShimExportNames, -} from "@t3tools/shared/pluginHostWeb"; +import { getPluginHostShimSource, pluginHostShimExportNames } from "@t3tools/shared/pluginHostWeb"; import { getPluginHost, whenPluginHostReady } from "./hostSingletons"; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..f5c54de403a 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,8 +20,10 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsSplatRouteImport } from './routes/settings.$' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +import { Route as ChatEnvironmentIdPPluginIdSplatRouteImport } from './routes/_chat.$environmentId.p.$pluginId.$' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -77,6 +79,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const SettingsSplatRoute = SettingsSplatRouteImport.update({ + id: '/$', + path: '/$', + getParentRoute: () => SettingsRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -88,11 +95,18 @@ const ChatEnvironmentIdThreadIdRoute = path: '/$environmentId/$threadId', getParentRoute: () => ChatRoute, } as any) +const ChatEnvironmentIdPPluginIdSplatRoute = + ChatEnvironmentIdPPluginIdSplatRouteImport.update({ + id: '/$environmentId/p/$pluginId/$', + path: '/$environmentId/p/$pluginId/$', + getParentRoute: () => ChatRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/$': typeof SettingsSplatRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -102,10 +116,12 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/$environmentId/p/$pluginId/$': typeof ChatEnvironmentIdPPluginIdSplatRoute } export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/$': typeof SettingsSplatRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -116,12 +132,14 @@ export interface FileRoutesByTo { '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/$environmentId/p/$pluginId/$': typeof ChatEnvironmentIdPPluginIdSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/$': typeof SettingsSplatRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -132,6 +150,7 @@ export interface FileRoutesById { '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute + '/_chat/$environmentId/p/$pluginId/$': typeof ChatEnvironmentIdPPluginIdSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -139,6 +158,7 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -148,10 +168,12 @@ export interface FileRouteTypes { | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/$environmentId/p/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -162,11 +184,13 @@ export interface FileRouteTypes { | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/$environmentId/p/$pluginId/$' id: | '__root__' | '/_chat' | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -177,6 +201,7 @@ export interface FileRouteTypes { | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/_chat/$environmentId/p/$pluginId/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -264,6 +289,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/settings/$': { + id: '/settings/$' + path: '/$' + fullPath: '/settings/$' + preLoaderRoute: typeof SettingsSplatRouteImport + parentRoute: typeof SettingsRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -278,6 +310,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport parentRoute: typeof ChatRoute } + '/_chat/$environmentId/p/$pluginId/$': { + id: '/_chat/$environmentId/p/$pluginId/$' + path: '/$environmentId/p/$pluginId/$' + fullPath: '/$environmentId/p/$pluginId/$' + preLoaderRoute: typeof ChatEnvironmentIdPPluginIdSplatRouteImport + parentRoute: typeof ChatRoute + } } } @@ -285,17 +324,20 @@ interface ChatRouteChildren { ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute + ChatEnvironmentIdPPluginIdSplatRoute: typeof ChatEnvironmentIdPPluginIdSplatRoute } const ChatRouteChildren: ChatRouteChildren = { ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, + ChatEnvironmentIdPPluginIdSplatRoute: ChatEnvironmentIdPPluginIdSplatRoute, } const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { + SettingsSplatRoute: typeof SettingsSplatRoute SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute @@ -306,6 +348,7 @@ interface SettingsRouteChildren { } const SettingsRouteChildren: SettingsRouteChildren = { + SettingsSplatRoute: SettingsSplatRoute, SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 36de3b95706..ddb2891226e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { CommandPalette } from "../components/CommandPalette"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { PluginUiHost } from "../plugins/PluginUiHost"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { Button } from "../components/ui/button"; import { @@ -132,6 +133,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} diff --git a/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx new file mode 100644 index 00000000000..fbf45e6796e --- /dev/null +++ b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx @@ -0,0 +1,62 @@ +import { useAtomValue } from "@effect/atom-react"; +import { PluginId } from "@t3tools/contracts"; +import { createFileRoute } from "@tanstack/react-router"; +import { createElement, type FunctionComponent } from "react"; +import type { PluginRouteComponentProps } from "@t3tools/plugin-sdk-web"; + +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SidebarInset } from "../components/ui/sidebar"; +import { + PluginSurfaceErrorBoundary, + pluginUiRegistryAtom, + resolvePluginRouteRegistration, +} from "../plugins/PluginUiHost"; + +function splatFromParams(params: Record): string { + const value = params._splat ?? params["*"]; + return typeof value === "string" ? value : ""; +} + +function PluginRouteNotFound() { + return ( + + + + Plugin page not found + The plugin route is not registered. + + + + ); +} + +function PluginRouteView() { + const params = Route.useParams(); + const snapshot = useAtomValue(pluginUiRegistryAtom); + const pluginId = PluginId.make(params.pluginId); + const route = resolvePluginRouteRegistration(snapshot, pluginId, splatFromParams(params)); + + if (!route) { + return ; + } + + // Render the plugin component as its OWN React element (createElement), not + // by calling it as a function: a function call would run the plugin's hooks + // on this route's fiber and break the Rules of Hooks when the resolved + // component changes. As an element it gets its own fiber and the error + // boundary actually wraps the mounted component. + return ( + + + {createElement(route.component as FunctionComponent, { + pluginId: route.pluginId, + path: route.path, + })} + + + ); +} + +export const Route = createFileRoute("/_chat/$environmentId/p/$pluginId/$")({ + component: PluginRouteView, +}); diff --git a/apps/web/src/routes/settings.$.tsx b/apps/web/src/routes/settings.$.tsx new file mode 100644 index 00000000000..f7410ae0794 --- /dev/null +++ b/apps/web/src/routes/settings.$.tsx @@ -0,0 +1,84 @@ +import { useAtomValue } from "@effect/atom-react"; +import { PluginId } from "@t3tools/contracts"; +import { createFileRoute } from "@tanstack/react-router"; +import { createElement, type FunctionComponent } from "react"; +import type { PluginSettingsComponentProps } from "@t3tools/plugin-sdk-web"; + +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SettingsPageContainer } from "../components/settings/settingsLayout"; +import { + PluginSurfaceErrorBoundary, + pluginUiRegistryAtom, + resolvePluginSettingsPageRegistration, +} from "../plugins/PluginUiHost"; + +function splatFromParams(params: Record): string { + const value = params._splat ?? params["*"]; + return typeof value === "string" ? value : ""; +} + +function parseSettingsSplat(splat: string): { + readonly pluginId: PluginId; + readonly pageId: string; +} | null { + const parts = splat.split("/"); + const pluginIdIndex = parts.findIndex((part) => part.length > 0); + if (pluginIdIndex < 0) { + return null; + } + const rawPluginId = parts[pluginIdIndex]; + const pageId = parts + .slice(pluginIdIndex + 1) + .filter((part) => part.length > 0) + .join("/"); + if (!rawPluginId || pageId.length === 0) { + return null; + } + return { + pluginId: PluginId.make(rawPluginId), + pageId, + }; +} + +function PluginSettingsNotFound() { + return ( + + + + Plugin settings not found + The plugin settings page is not registered. + + + + ); +} + +function PluginSettingsRouteView() { + const params = Route.useParams(); + const parsed = parseSettingsSplat(splatFromParams(params)); + const snapshot = useAtomValue(pluginUiRegistryAtom); + const page = parsed + ? resolvePluginSettingsPageRegistration(snapshot, parsed.pluginId, parsed.pageId) + : null; + + if (!page) { + return ; + } + + // Render as an element (its own fiber) so plugin hooks work — see the note + // in the plugin route splat. + return ( + + + {createElement(page.component as FunctionComponent, { + pluginId: page.pluginId, + pageId: page.id, + })} + + + ); +} + +export const Route = createFileRoute("/settings/$")({ + component: PluginSettingsRouteView, +}); diff --git a/apps/web/src/state/plugins.test.ts b/apps/web/src/state/plugins.test.ts new file mode 100644 index 00000000000..3eaf842155c --- /dev/null +++ b/apps/web/src/state/plugins.test.ts @@ -0,0 +1,68 @@ +import { PluginId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { makePluginListStream, pluginRpc } from "./plugins"; + +const pluginId = PluginId.make("fixture-plugin"); + +describe("web plugin state", () => { + it.effect("loads plugin list initially and refreshes on plugin lifecycle changes", () => + Effect.gen(function* () { + let calls = 0; + const lists = yield* makePluginListStream( + Stream.make( + { + version: 1 as const, + sequence: 1, + type: "ready" as const, + payload: { at: "2026-07-03T00:00:00.000Z", environment: {} as never }, + }, + { + version: 1 as const, + sequence: 2, + type: "plugins" as const, + payload: { + kind: "plugin-state-changed" as const, + pluginId, + state: "active" as const, + }, + }, + ), + Effect.sync(() => { + calls += 1; + return [ + { + id: pluginId, + name: "Fixture", + version: "1.0.0", + state: "active" as const, + capabilities: [], + hasWeb: true, + lastError: null, + }, + ]; + }), + ).pipe(Stream.runCollect); + + expect(calls).toBe(2); + expect(Array.from(lists)).toHaveLength(2); + }), + ); + + it("binds plugin RPC helpers to one plugin id", () => { + const calls: Array = []; + const rpc = pluginRpc(pluginId, { + call: (id, method, payload) => { + calls.push([id, method, payload] as const); + return Promise.resolve({ ok: true }); + }, + subscribe: (id, method, payload) => Stream.make({ id, method, payload }), + }); + + void rpc.call("echo", { value: 1 }); + + expect(calls).toEqual([[pluginId, "echo", { value: 1 }]]); + }); +}); diff --git a/apps/web/src/state/plugins.ts b/apps/web/src/state/plugins.ts new file mode 100644 index 00000000000..fcd14231f47 --- /dev/null +++ b/apps/web/src/state/plugins.ts @@ -0,0 +1,138 @@ +import { + type PluginId, + type PluginInfo, + type ServerLifecycleStreamEvent, + WS_METHODS, +} from "@t3tools/contracts"; +import { callPlugin, listPlugins, subscribePlugin } from "@t3tools/client-runtime/rpc"; +import { + createEnvironmentRpcSubscriptionAtomFamily, + executeAtomQuery, + runInEnvironment, + runStreamInEnvironment, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { isPluginStateChangedLifecycleEvent } from "@t3tools/client-runtime/state/server"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; + +const EMPTY_PLUGIN_LIST: ReadonlyArray = Object.freeze([]); + +export function makePluginListStream( + lifecycleEvents: Stream.Stream, + loadPlugins: Effect.Effect, E, R>, +): Stream.Stream, E, R> { + const reloads = lifecycleEvents.pipe( + Stream.filter(isPluginStateChangedLifecycleEvent), + Stream.mapEffect(() => loadPlugins), + ); + return Stream.concat(Stream.fromEffect(loadPlugins), reloads); +} + +const environmentPluginListResultAtom = createEnvironmentRpcSubscriptionAtomFamily( + connectionAtomRuntime, + { + label: "web-plugins:list", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => { + const loadPlugins = listPlugins().pipe( + Effect.map((result) => result.plugins), + Effect.catchCause((cause) => + Effect.logWarning("Could not refresh plugin list", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(EMPTY_PLUGIN_LIST)), + ), + ); + return makePluginListStream(stream, loadPlugins); + }, + }, +); + +export const environmentPluginListAtom = Atom.family((environmentId: string) => + Atom.make( + (get): ReadonlyArray => + Option.getOrElse( + AsyncResult.value( + get( + environmentPluginListResultAtom({ + environmentId: environmentId as never, + input: {}, + }), + ), + ), + () => EMPTY_PLUGIN_LIST, + ), + ).pipe(Atom.withLabel(`web-plugins:list:${environmentId}`)), +); + +export const pluginListAtom = Atom.make((get): ReadonlyArray => { + const environmentId = get(primaryEnvironmentIdAtom); + if (environmentId === null) { + return EMPTY_PLUGIN_LIST; + } + return get(environmentPluginListAtom(environmentId)); +}).pipe(Atom.withLabel("web-plugins:list")); + +export interface PluginRpcDependencies { + readonly call?: (pluginId: PluginId, method: string, payload?: unknown) => Promise; + readonly subscribe?: ( + pluginId: PluginId, + method: string, + payload?: unknown, + ) => Stream.Stream; +} + +async function defaultPluginCall( + pluginId: PluginId, + method: string, + payload?: unknown, +): Promise { + const environmentId = appAtomRegistry.get(primaryEnvironmentIdAtom); + if (environmentId === null) { + throw new Error("Plugin RPC is unavailable before the primary environment is connected."); + } + + const atom = connectionAtomRuntime + .atom(runInEnvironment(environmentId, callPlugin(pluginId, method, payload))) + .pipe(Atom.withLabel(`web-plugins:rpc:${pluginId}:${method}`)); + const result = await executeAtomQuery(appAtomRegistry, atom, { + reportDefect: false, + reportFailure: false, + }); + if (result._tag === "Success") { + return result.value; + } + throw squashAtomCommandFailure(result); +} + +function defaultPluginSubscribe( + pluginId: PluginId, + method: string, + payload?: unknown, +): Stream.Stream { + const environmentId = appAtomRegistry.get(primaryEnvironmentIdAtom); + if (environmentId === null) { + return Stream.fail( + new Error("Plugin RPC is unavailable before the primary environment is connected."), + ); + } + return runStreamInEnvironment(environmentId, subscribePlugin(pluginId, method, payload)); +} + +export function pluginRpc(pluginId: PluginId, dependencies: PluginRpcDependencies = {}) { + const call = dependencies.call ?? defaultPluginCall; + const subscribe = dependencies.subscribe ?? defaultPluginSubscribe; + return { + call: (method: string, payload?: unknown) => call(pluginId, method, payload), + subscribe: (method: string, payload?: unknown) => subscribe(pluginId, method, payload), + }; +} + +export { WS_METHODS }; diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 4b9564e031c..961899efb4b 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,8 +1,16 @@ -import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { + PluginId, + type ServerConfig, + type ServerLifecycleWelcomePayload, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { applyServerConfigProjection, projectServerWelcome } from "./server.ts"; +import { + applyServerConfigProjection, + isPluginStateChangedLifecycleEvent, + projectServerWelcome, +} from "./server.ts"; const CONFIG = { availableEditors: [], @@ -45,10 +53,30 @@ describe("server state projection", () => { }); const [afterReady, emitted] = projectServerWelcome(afterWelcome, { type: "ready", - payload: {}, + payload: {} as never, }); expect(Option.getOrThrow(afterReady)).toBe(welcome); expect(emitted).toEqual([]); }); + + it("detects plugin state change lifecycle events without disturbing welcome projection", () => { + const pluginId = PluginId.make("fixture-plugin"); + const event = { + version: 1 as const, + sequence: 1, + type: "plugins" as const, + payload: { + kind: "plugin-state-changed" as const, + pluginId, + state: "active" as const, + }, + }; + + const [next, emitted] = projectServerWelcome(Option.none(), event); + + expect(isPluginStateChangedLifecycleEvent(event)).toBe(true); + expect(Option.isNone(next)).toBe(true); + expect(emitted).toEqual([]); + }); }); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index eb784183793..0e11af9a355 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -2,6 +2,8 @@ import { type EnvironmentId, type ServerConfig, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, + type ServerLifecyclePluginStateChangedPayload, type ServerLifecycleWelcomePayload, WS_METHODS, } from "@t3tools/contracts"; @@ -70,10 +72,7 @@ export function projectServerConfig( export function projectServerWelcome( current: Option.Option, - event: { - readonly type: "welcome" | "ready"; - readonly payload: unknown; - }, + event: Pick, ): readonly [ Option.Option, ReadonlyArray, @@ -85,6 +84,21 @@ export function projectServerWelcome( return [Option.some(welcome), [welcome]]; } +export function isPluginStateChangedLifecycleEvent( + event: Pick, +): event is { + readonly type: "plugins"; + readonly payload: ServerLifecyclePluginStateChangedPayload; +} { + return ( + event.type === "plugins" && + typeof event.payload === "object" && + event.payload !== null && + "kind" in event.payload && + event.payload.kind === "plugin-state-changed" + ); +} + export function createServerEnvironmentAtoms( runtime: Atom.AtomRuntime, options: { @@ -154,6 +168,15 @@ export function createServerEnvironmentAtoms( Stream.mapAccum(Option.none, projectServerWelcome), ), }), + pluginStateChanges: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:plugin-state-changes", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => + stream.pipe( + Stream.filter(isPluginStateChangedLifecycleEvent), + Stream.map((event) => event.payload), + ), + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index c906f86f4dc..0b4d4401691 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,9 +1,10 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { ServerProvider } from "./server.ts"; +import { ServerLifecycleStreamEvent, ServerProvider } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); +const decodeServerLifecycleEvent = Schema.decodeUnknownSync(ServerLifecycleStreamEvent); describe("ServerProvider", () => { it("defaults capability arrays when decoding provider snapshots", () => { @@ -72,3 +73,24 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); }); + +describe("ServerLifecycleStreamEvent", () => { + it("decodes plugin state change events", () => { + const parsed = decodeServerLifecycleEvent({ + version: 1, + sequence: 3, + type: "plugins", + payload: { + kind: "plugin-state-changed", + pluginId: "fixture-plugin", + state: "active", + }, + }); + + expect(parsed.type).toBe("plugins"); + if (parsed.type === "plugins") { + expect(parsed.payload.pluginId).toBe("fixture-plugin"); + expect(parsed.payload.state).toBe("active"); + } + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..1bf7bc6235f 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -20,6 +20,7 @@ import { EditorId } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; +import { PluginId, PluginState } from "./plugin.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -540,9 +541,26 @@ export const ServerLifecycleStreamReadyEvent = Schema.Struct({ }); export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type; +export const ServerLifecyclePluginStateChangedPayload = Schema.Struct({ + kind: Schema.Literal("plugin-state-changed"), + pluginId: PluginId, + state: PluginState, +}); +export type ServerLifecyclePluginStateChangedPayload = + typeof ServerLifecyclePluginStateChangedPayload.Type; + +export const ServerLifecycleStreamPluginsEvent = Schema.Struct({ + version: Schema.Literal(1), + sequence: NonNegativeInt, + type: Schema.Literal("plugins"), + payload: ServerLifecyclePluginStateChangedPayload, +}); +export type ServerLifecycleStreamPluginsEvent = typeof ServerLifecycleStreamPluginsEvent.Type; + export const ServerLifecycleStreamEvent = Schema.Union([ ServerLifecycleStreamWelcomeEvent, ServerLifecycleStreamReadyEvent, + ServerLifecycleStreamPluginsEvent, ]); export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type; diff --git a/packages/plugin-sdk-web/src/index.test.tsx b/packages/plugin-sdk-web/src/index.test.tsx index b7d4b7b7949..1fd55ea6a80 100644 --- a/packages/plugin-sdk-web/src/index.test.tsx +++ b/packages/plugin-sdk-web/src/index.test.tsx @@ -6,6 +6,7 @@ import { ProviderModelPicker, TraitsPicker, createPluginAtoms, + defineWebPlugin, hostCompat, pluginSdkWebExternalDependencies, } from "./index"; @@ -25,4 +26,32 @@ describe("plugin-sdk-web", () => { expect.arrayContaining(["@effect/atom-react", "effect", "react", "react-dom"]), ); }); + + it("returns defineWebPlugin definitions unchanged", () => { + const definition = defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ + path: "overview", + component: () => null, + }); + ctx.registerSidebarSection({ + id: "main", + title: "Main", + render: () => null, + }); + ctx.registerSettingsPage({ + id: "settings", + title: "Settings", + component: () => null, + }); + ctx.registerCommand({ + id: "refresh", + title: "Refresh", + run: () => undefined, + }); + }, + }); + + expect(typeof definition.register).toBe("function"); + }); }); diff --git a/packages/plugin-sdk-web/src/index.ts b/packages/plugin-sdk-web/src/index.ts index 6194fb8f22f..b33150a66e2 100644 --- a/packages/plugin-sdk-web/src/index.ts +++ b/packages/plugin-sdk-web/src/index.ts @@ -1,5 +1,6 @@ import type { PluginId } from "@t3tools/contracts/plugin"; import { HOST_API_VERSION } from "@t3tools/contracts/plugin"; +import type * as Stream from "effect/Stream"; import { pluginSdkWebExternalDependencies } from "./externals"; export { pluginSdkWebExternalDependencies, isPluginSdkWebExternal } from "./externals"; @@ -71,41 +72,81 @@ export const hostCompat = { export interface PluginUiContext { readonly pluginId: PluginId; + readonly rpc: PluginWebRpc; + readonly logger: PluginWebLogger; + readonly registerRoute: (registration: PluginRouteRegistration) => void; + readonly registerSidebarSection: (registration: PluginSidebarSectionRegistration) => void; + readonly registerSettingsPage: (registration: PluginSettingsPageRegistration) => void; + readonly registerCommand: (registration: PluginCommandRegistration) => void; } -export type PluginComponent = (props: Props) => unknown; +export interface PluginWebLogger { + readonly debug: (message: string, data?: unknown) => void; + readonly info: (message: string, data?: unknown) => void; + readonly warn: (message: string, data?: unknown) => void; + readonly error: (message: string, data?: unknown) => void; +} + +export interface PluginWebRpc { + readonly call: (method: string, payload?: unknown) => Promise; + readonly subscribe: ( + method: string, + payload?: unknown, + ) => Stream.Stream; +} + +export type PluginComponent> = (props: Props) => unknown; + +export interface PluginRouteComponentProps { + readonly pluginId: PluginId; + readonly path: string; +} export interface PluginRouteRegistration { - readonly id: string; readonly path: string; - readonly title: string; - readonly component: PluginComponent; + readonly component: PluginComponent; } -export interface PluginSidebarSection { +export interface PluginSidebarSectionRenderProps { + readonly pluginId: PluginId; + readonly environmentId: string | null; + readonly routeBasePath: string | null; +} + +export interface PluginSidebarSectionRegistration { readonly id: string; readonly title: string; - readonly items: ReadonlyArray<{ - readonly id: string; - readonly title: string; - readonly routePath: string; - readonly icon?: PluginComponent<{ readonly className?: string }>; - }>; + readonly render: (props: PluginSidebarSectionRenderProps) => unknown; +} + +export interface PluginSettingsComponentProps { + readonly pluginId: PluginId; + readonly pageId: string; } -export interface PluginSettingsPage { +export interface PluginSettingsPageRegistration { readonly id: string; readonly title: string; - readonly component: PluginComponent; + readonly component: PluginComponent; } -export interface PluginCommand { +export interface PluginCommandRegistration { readonly id: string; readonly title: string; readonly description?: string; readonly run: (context: PluginUiContext) => void | Promise; } +export interface PluginWebDefinition { + readonly register: (context: PluginUiContext) => void | Promise; +} + +export function defineWebPlugin( + definition: Definition, +): Definition { + return definition; +} + /** * Tailwind v4 caveat: host builds emit utilities by scanning host source. * Separately-built plugins should use host CSS variables and these exported @@ -113,8 +154,8 @@ export interface PluginCommand { */ export interface PluginWebRegistration { readonly routes?: ReadonlyArray; - readonly sidebarSections?: ReadonlyArray; - readonly settingsPages?: ReadonlyArray; - readonly commands?: ReadonlyArray; + readonly sidebarSections?: ReadonlyArray; + readonly settingsPages?: ReadonlyArray; + readonly commands?: ReadonlyArray; readonly providers?: (context: PluginUiContext) => unknown; } diff --git a/packages/shared/src/pluginHostWeb.ts b/packages/shared/src/pluginHostWeb.ts index 613d76764d8..f8982135812 100644 --- a/packages/shared/src/pluginHostWeb.ts +++ b/packages/shared/src/pluginHostWeb.ts @@ -462,6 +462,7 @@ const pluginSdkWebExports = [ "badgeVariants", "buttonVariants", "createPluginAtoms", + "defineWebPlugin", "getAppAtomRegistry", "getConnectionAtomRuntime", "hostCompat", From 56910ad45fc1e7273802c3cd3c2b7756081c37fa Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 09:08:42 -0400 Subject: [PATCH 13/75] Add plugin marketplace and install lifecycle (server) The server side of the marketplace: fetch indexes and run the full install/upgrade/uninstall/enable lifecycle as compile-time management RPCs under the plugins:manage scope. No web UI yet. - PluginMarketplace: resolve + fetch + parse marketplace.json (HTTPS or owner/repo shorthand; file:// only under T3_PLUGIN_DEV), cached, byte capped, per-source error isolation; tarball URL scheme gated the same way before any download - PluginInstaller: download -> verify sha256 -> safe-extract (hand- written gunzip + tar parser, no new dependency) -> validate manifest -> stage under a TTL token -> confirm moves atomically and hot- activates; upgrade stages pending-upgrade; uninstall stages pending-remove; setEnabled; checkUpdates - PluginManagementRpcHandlers wired into ws.ts (all plugins:manage) + server.ts layers; client-runtime typed helpers - Tar extraction rejects traversal, absolute paths, links, unsupported entries, oversize, and decompression bombs; malicious-tar tests Reviewed by Claude + Grok. MUST fixes: streaming gunzip with a hard output cap so a decompression bomb is aborted mid-inflate instead of OOMing; remove any pre-existing destination before the staging rename and clean up the stage token on a failed confirm. SHOULD fix: id collision is checked against the real DB table prefix (p__) shared with the migrator, so distinct ids like chat/chatbot are no longer falsely rejected. Implemented by GPT-5.5 via codex exec (assembly-line slice 2c-1). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/plugins/PluginHost.ts | 70 +- .../src/plugins/PluginInstaller.test.ts | 558 +++++++++++ apps/server/src/plugins/PluginInstaller.ts | 911 ++++++++++++++++++ .../server/src/plugins/PluginLockfileStore.ts | 18 + .../PluginManagementRpcHandlers.test.ts | 107 ++ .../plugins/PluginManagementRpcHandlers.ts | 165 ++++ .../src/plugins/PluginMarketplace.test.ts | 174 ++++ apps/server/src/plugins/PluginMarketplace.ts | 305 ++++++ apps/server/src/plugins/PluginMigrator.ts | 4 +- .../plugins/readHttpResponseBytesCapped.ts | 37 + apps/server/src/server.test.ts | 22 + apps/server/src/server.ts | 18 + apps/server/src/ws.ts | 85 ++ .../client-runtime/src/rpc/client.test.ts | 112 +++ packages/client-runtime/src/rpc/client.ts | 77 ++ packages/contracts/src/plugin.test.ts | 52 +- packages/contracts/src/plugin.ts | 166 ++++ packages/contracts/src/rpc.ts | 113 +++ 18 files changed, 2990 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/plugins/PluginInstaller.test.ts create mode 100644 apps/server/src/plugins/PluginInstaller.ts create mode 100644 apps/server/src/plugins/PluginManagementRpcHandlers.test.ts create mode 100644 apps/server/src/plugins/PluginManagementRpcHandlers.ts create mode 100644 apps/server/src/plugins/PluginMarketplace.test.ts create mode 100644 apps/server/src/plugins/PluginMarketplace.ts create mode 100644 apps/server/src/plugins/readHttpResponseBytesCapped.ts diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 1aec0644fee..57884b3956c 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; @@ -66,6 +67,7 @@ import { pluginDataDir, pluginManifestPath, pluginVersionDir } from "./PluginPat import { PluginRuntimeRegistry } from "./PluginRuntimeRegistry.ts"; const APP_VERSION = packageJson.version; +const PRESERVE_DATA_MARKER = ".preserve-data-on-remove"; const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); const healthyActivationDelay = () => { @@ -97,6 +99,8 @@ export class PluginHost extends Context.Service< PluginHost, { readonly start: Effect.Effect; + readonly activatePlugin: (pluginId: PluginId) => Effect.Effect; + readonly deactivatePlugin: (pluginId: PluginId) => Effect.Effect; } >()("t3/plugins/PluginHost") {} @@ -509,10 +513,72 @@ export const make = Effect.fn("PluginHost.make")(function* () { } }); + const activatePlugin: PluginHost["Service"]["activatePlugin"] = (pluginId) => + Effect.gen(function* () { + if (process.env.T3_NO_PLUGINS === "1") { + yield* Effect.logInfo("Plugin host disabled by T3_NO_PLUGINS", { pluginId }); + return; + } + const active = yield* registry.get(pluginId); + if (Option.isSome(active)) return; + const lockfile = yield* store.readLockfile.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Plugin hot activation could not read lockfile", { + pluginId, + cause: Cause.pretty(cause), + }).pipe(Effect.as({ plugins: {}, sources: [] })), + ), + ); + const entry = getLockfilePlugin(lockfile, pluginId); + if (!entry?.enabled || entry.state !== "active") return; + yield* loader.ensureHostSingletonResolution; + yield* loadPlugin(pluginId, entry).pipe( + Effect.catchCause((cause) => + markFailure(pluginId, Cause.pretty(cause)).pipe( + Effect.andThen( + Effect.logWarning("Plugin hot activation failed", { + pluginId, + cause: Cause.pretty(cause), + }), + ), + Effect.ignore, + ), + ), + ); + }); + + const deactivatePlugin: PluginHost["Service"]["deactivatePlugin"] = (pluginId) => + Effect.gen(function* () { + const runtime = yield* registry.get(pluginId); + if (Option.isNone(runtime)) return; + yield* Scope.close(runtime.value.scope, Exit.void).pipe(Effect.ignore); + yield* registry.remove(pluginId); + yield* httpRegistry.remove(pluginId).pipe(Effect.ignore); + yield* publishPluginStateChanged(pluginId, "disabled"); + }); + const reconcilePendingState = (pluginId: PluginId, entry: PluginLockfilePlugin) => Effect.gen(function* () { if (entry.state === "pending-remove") { - yield* fs.remove(path.join(config.pluginsDir, pluginId), { recursive: true, force: true }); + const pluginRoot = path.join(config.pluginsDir, pluginId); + const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const markerPath = path.join(pluginRoot, PRESERVE_DATA_MARKER); + const preserveData = yield* fs.exists(markerPath).pipe(Effect.orElseSucceed(() => false)); + const preservedDataDir = path.join( + config.pluginsDir, + `.preserved-${pluginId}-${yield* clock.currentTimeMillis}`, + ); + if (preserveData && (yield* fs.exists(dataDir).pipe(Effect.orElseSucceed(() => false)))) { + yield* fs.rename(dataDir, preservedDataDir); + } + yield* fs.remove(pluginRoot, { recursive: true, force: true }); + if ( + preserveData && + (yield* fs.exists(preservedDataDir).pipe(Effect.orElseSucceed(() => false))) + ) { + yield* fs.makeDirectory(pluginRoot, { recursive: true }); + yield* fs.rename(preservedDataDir, dataDir); + } yield* store.removePlugin(pluginId); return false; } @@ -610,7 +676,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { } }).pipe(Effect.ignoreCause({ log: true })); - return PluginHost.of({ start }); + return PluginHost.of({ start, activatePlugin, deactivatePlugin }); }); export const layer = Layer.effect(PluginHost, make()); diff --git a/apps/server/src/plugins/PluginInstaller.test.ts b/apps/server/src/plugins/PluginInstaller.test.ts new file mode 100644 index 00000000000..16d86b1c647 --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.test.ts @@ -0,0 +1,558 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + PluginId, + PluginManifest, + type PluginManifest as PluginManifestType, +} from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeZlib from "node:zlib"; + +import * as ServerConfig from "../config.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import { PluginHost } from "./PluginHost.ts"; +import { PluginInstaller } from "./PluginInstaller.ts"; +import * as PluginInstallerModule from "./PluginInstaller.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import { MarketplaceIndex } from "./PluginMarketplace.ts"; +import * as PluginMarketplaceModule from "./PluginMarketplace.ts"; + +const pluginId = PluginId.make("test-plugin"); +const sourceId = "src-test"; +const tarballUrl = "https://market.test/test-plugin-1.0.0.tgz"; + +const encodeManifestJson = Schema.encodeSync(Schema.fromJsonString(PluginManifest)); +const encodeMarketplaceJson = Schema.encodeSync(Schema.fromJsonString(MarketplaceIndex)); + +const manifest = (overrides: Partial = {}): PluginManifestType => ({ + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + hostApi: "^1.0.0", + capabilities: ["agents"], + entries: { server: "server/index.js" }, + ...overrides, +}); + +const textEncoder = new TextEncoder(); + +function checksum(header: Uint8Array): number { + let sum = 0; + for (const byte of header) sum += byte; + return sum; +} + +function writeAscii(target: Uint8Array, offset: number, length: number, value: string) { + target.set(textEncoder.encode(value).slice(0, length), offset); +} + +function writeOctal(target: Uint8Array, offset: number, length: number, value: number) { + writeAscii(target, offset, length, value.toString(8).padStart(length - 1, "0")); +} + +function tarEntry(input: { + readonly name: string; + readonly body?: Uint8Array; + readonly type?: "0" | "2" | "5"; +}) { + const body = input.body ?? new Uint8Array(); + const header = new Uint8Array(512); + writeAscii(header, 0, 100, input.name); + writeOctal(header, 100, 8, input.type === "5" ? 0o755 : 0o644); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, body.byteLength); + writeOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + writeAscii(header, 156, 1, input.type ?? "0"); + writeAscii(header, 257, 6, "ustar"); + writeAscii(header, 263, 2, "00"); + writeOctal(header, 148, 8, checksum(header)); + const paddedSize = Math.ceil(body.byteLength / 512) * 512; + const padded = new Uint8Array(512 + paddedSize); + padded.set(header, 0); + padded.set(body, 512); + return padded; +} + +function tar(entries: ReadonlyArray[0]>): Uint8Array { + const chunks = [...entries.map(tarEntry), new Uint8Array(1024)]; + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +const sha256 = (bytes: Uint8Array) => NodeCrypto.createHash("sha256").update(bytes).digest("hex"); + +const tarballForManifest = (pluginManifest = manifest()) => + tarballForManifestJson(encodeManifestJson(pluginManifest)); + +const tarballForManifestJson = ( + manifestJson: string, + extraEntries: ReadonlyArray[0]> = [], +) => + tar([ + { + name: "manifest.json", + body: textEncoder.encode(manifestJson), + }, + { + name: "server/index.js", + body: textEncoder.encode("export default { register() { return {}; } };"), + }, + ...extraEntries, + ]); + +const marketplaceJson = (sha: string, version = "1.0.0") => ({ + plugins: [ + { + id: pluginId, + name: "Test Plugin", + description: "Adds tests.", + capabilities: ["agents" as const], + versions: [ + { + version, + tarball: tarballUrl, + sha256: sha, + hostApi: "^1.0.0", + publishedAt: "2026-07-03T00:00:00.000Z", + }, + ], + }, + ], +}); + +function installerLayer(input: { + readonly tarball: Uint8Array; + readonly marketplaceSha?: string; + readonly activated?: Array; +}) { + const platform = NodeServices.layer; + const config = ServerConfig.layerTest(process.cwd(), { prefix: "t3-installer-" }).pipe( + Layer.provide(platform), + ); + const marketplace = marketplaceJson(input.marketplaceSha ?? sha256(input.tarball)); + const marketplaceBody = encodeMarketplaceJson(marketplace); + const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = request.url.toString(); + if (url === "https://market.test/marketplace.json") { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(marketplaceBody, { headers: { "content-type": "application/json" } }), + ), + ); + } + if (url === tarballUrl) { + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(input.tarball))); + } + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 404 }))); + }), + ); + const host = Layer.succeed( + PluginHost, + PluginHost.of({ + start: Effect.void, + activatePlugin: (id) => + Effect.sync(() => { + input.activated?.push(id); + }), + deactivatePlugin: () => Effect.void, + }), + ); + const catalog = Layer.succeed( + PluginCatalog, + PluginCatalog.of({ + list: Effect.succeed([ + { + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + state: "active" as const, + capabilities: ["agents" as const], + hasWeb: false, + lastError: null, + }, + ]), + }), + ); + return PluginInstallerModule.layer.pipe( + Layer.provideMerge(PluginMarketplaceModule.layer), + Layer.provideMerge(PluginLockfileStoreLayer.layer), + Layer.provideMerge(host), + Layer.provideMerge(catalog), + Layer.provideMerge(http), + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(config), + Layer.provide(platform), + ); +} + +const seedSource = Effect.gen(function* () { + const store = yield* PluginLockfileStore; + yield* store.updateSources(() => + Effect.succeed([ + { + id: sourceId, + url: "https://market.test/marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }, + ]), + ); +}); + +it.effect("PluginInstaller rejects sha mismatches without staging", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const result = yield* Effect.result( + installer.beginInstall({ + sourceId, + pluginId, + version: "1.0.0", + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "checksum-mismatch"); + }).pipe( + Effect.provide( + installerLayer({ tarball: tarballForManifest(), marketplaceSha: "b".repeat(64) }), + ), + ), + ), +); + +it.effect("PluginInstaller rejects unsafe tar entries", () => { + const traversal = tar([ + { name: "manifest.json", body: textEncoder.encode(encodeManifestJson(manifest())) }, + { name: "../escape.js", body: textEncoder.encode("x") }, + ]); + return Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const result = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + + assert.isTrue(Result.isFailure(result)); + }).pipe(Effect.provide(installerLayer({ tarball: traversal }))), + ); +}); + +it.effect("PluginInstaller rejects symlinks", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const symlinkResult = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(symlinkResult)); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tar([ + { name: "manifest.json", body: textEncoder.encode(encodeManifestJson(manifest())) }, + { name: "server/link", type: "2" }, + ]), + }), + ), + ), + ), +); + +it.effect("PluginInstaller rejects oversize files and gzip compression bombs", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const oversizeResult = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(oversizeResult)); + if (Result.isFailure(oversizeResult)) + assert.equal(oversizeResult.failure.code, "extract-failed"); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifestJson(encodeManifestJson(manifest()), [ + { name: "assets/large.bin", body: new Uint8Array(16 * 1024 * 1024 + 1) }, + ]), + }), + ), + ), + ).pipe( + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const bombResult = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(bombResult)); + if (Result.isFailure(bombResult)) assert.equal(bombResult.failure.code, "extract-failed"); + }).pipe( + Effect.provide( + installerLayer({ + tarball: NodeZlib.gzipSync( + tarballForManifestJson(encodeManifestJson(manifest()), [ + { name: "assets/repeated.bin", body: new Uint8Array(1024 * 1024) }, + ]), + ), + }), + ), + ), + ), + ), + ), +); + +it.effect("PluginInstaller rejects invalid manifests before staging can be confirmed", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const idMismatch = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(idMismatch)); + if (Result.isFailure(idMismatch)) assert.equal(idMismatch.failure.code, "manifest-invalid"); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifest(manifest({ id: PluginId.make("other-plugin") })), + }), + ), + ), + ).pipe( + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const hostApiMismatch = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(hostApiMismatch)); + if (Result.isFailure(hostApiMismatch)) { + assert.equal(hostApiMismatch.failure.code, "manifest-invalid"); + } + }).pipe( + Effect.provide( + installerLayer({ tarball: tarballForManifest(manifest({ hostApi: "2.0.0" })) }), + ), + ), + ), + ), + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const unknownCapability = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(unknownCapability)); + if (Result.isFailure(unknownCapability)) { + assert.equal(unknownCapability.failure.code, "manifest-invalid"); + } + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifestJson( + JSON.stringify({ + ...manifest(), + capabilities: ["unknown"], + }), + ), + }), + ), + ), + ), + ), + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const webOnlyCapability = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(webOnlyCapability)); + if (Result.isFailure(webOnlyCapability)) { + assert.equal(webOnlyCapability.failure.code, "manifest-invalid"); + } + }).pipe( + Effect.provide( + installerLayer({ + tarball: tar([ + { + name: "manifest.json", + body: textEncoder.encode( + JSON.stringify({ + ...manifest(), + capabilities: ["agents"], + entries: { web: "web/index.js" }, + }), + ), + }, + { name: "web/index.js", body: textEncoder.encode("export default {};") }, + ]), + }), + ), + ), + ), + ), + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + yield* store.updatePlugin(PluginId.make("test"), () => + Effect.succeed({ + version: "1.0.0", + sha256: "old", + sourceId, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const prefixCollision = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(prefixCollision)); + if (Result.isFailure(prefixCollision)) { + assert.equal(prefixCollision.failure.code, "manifest-invalid"); + } + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest() }))), + ), + ), + ), +); + +it("plugin id collision follows the DB table prefix, not the raw id", () => { + // Same prefix / one a prefix of the other → collide. + assert.isTrue(PluginInstallerModule.pluginTablePrefixesCollide("test", "test")); + assert.isTrue(PluginInstallerModule.pluginTablePrefixesCollide("test", "test-plugin")); + assert.isTrue(PluginInstallerModule.pluginTablePrefixesCollide("a", "a-b")); + // Distinct ids whose prefixes are NOT prefixes of each other → no collision. + assert.isFalse(PluginInstallerModule.pluginTablePrefixesCollide("chat", "chatbot")); + assert.isFalse(PluginInstallerModule.pluginTablePrefixesCollide("board", "boards")); + assert.isFalse(PluginInstallerModule.pluginTablePrefixesCollide("a", "b")); +}); + +it.effect("PluginInstaller begin-confirm updates the lockfile and hot-activates", () => { + const activated: Array = []; + return Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + assert.equal(staged.capabilityDescriptions.agents, "Run AI agents"); + const result = yield* installer.confirmInstall(staged.stageToken); + const lockfile = yield* store.readLockfile; + + assert.equal(result.plugin.id, pluginId); + assert.equal(lockfile.plugins[pluginId]?.state, "active"); + assert.deepEqual(activated, [pluginId]); + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest(), activated }))), + ); +}); + +it.effect("PluginInstaller abort and expired tokens clean staging", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + yield* installer.abortInstall(staged.stageToken); + assert.isFalse(yield* fs.exists(path.join(config.pluginsDir, ".staging", staged.stageToken))); + + const expired = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + yield* TestClock.adjust("16 minutes"); + const result = yield* Effect.result(installer.confirmInstall(expired.stageToken)); + + assert.isTrue(Result.isFailure(result)); + assert.isFalse( + yield* fs.exists(path.join(config.pluginsDir, ".staging", expired.stageToken)), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(installerLayer({ tarball: tarballForManifest() }), NodeServices.layer), + ), + ), + ), +); + +it.effect("PluginInstaller stages upgrades and uninstall marks pending remove", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + yield* store.updatePlugin(pluginId, () => + Effect.succeed({ + version: "0.9.0", + sha256: "old", + sourceId, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const staged = yield* installer.beginUpgrade({ pluginId, version: "1.0.0" }); + yield* installer.confirmUpgrade(staged.stageToken); + let lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.state, "pending-upgrade"); + assert.equal(lockfile.plugins[pluginId]?.staged?.version, "1.0.0"); + + yield* installer.uninstall({ pluginId, removeData: false }); + lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.state, "pending-remove"); + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest() }))), + ), +); diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts new file mode 100644 index 00000000000..4f1f303962f --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -0,0 +1,911 @@ +import { + HOST_API_VERSION, + PluginCapability, + PluginId, + PluginManagementError, + PluginManifest, + hostApiSatisfies, + type MarketplaceVersion, + type PluginId as PluginIdType, + type PluginInfo, + type PluginInstallStaged, + type PluginLockfilePlugin, +} from "@t3tools/contracts/plugin"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeURL from "node:url"; +import * as NodeZlib from "node:zlib"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import { PluginHost } from "./PluginHost.ts"; +import { pluginSqlPrefix } from "./PluginMigrator.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { PluginMarketplace } from "./PluginMarketplace.ts"; +import { pluginManifestPath, pluginVersionDir } from "./PluginPaths.ts"; +import { readHttpResponseBytesCapped } from "./readHttpResponseBytesCapped.ts"; + +const DOWNLOAD_MAX_BYTES = 64 * 1024 * 1024; +const EXTRACT_TOTAL_MAX_BYTES = 128 * 1024 * 1024; +const EXTRACT_FILE_MAX_BYTES = 16 * 1024 * 1024; +const DECOMPRESSION_RATIO_MAX = 100; +const STAGE_TOKEN_TTL_MS = 15 * 60 * 1000; +const PRESERVE_DATA_MARKER = ".preserve-data-on-remove"; + +// Streaming gunzip with a HARD output cap: a bomb that expands past the cap is +// aborted mid-inflate, so peak memory stays ~cap + one chunk instead of the +// full (multi-GB) decompressed size. A one-shot gunzip would allocate the +// whole output before any size check could run. +const gunzipCapped = (bytes: Uint8Array, maxBytes: number): Promise => + new Promise((resolve, reject) => { + const stream = NodeZlib.createGunzip(); + const chunks: Array = []; + let total = 0; + stream.on("data", (chunk: Buffer) => { + total += chunk.byteLength; + if (total > maxBytes) { + stream.destroy(); + reject( + managementError("extract-failed", "Plugin archive expands beyond the size limit.", { + limit: maxBytes, + }), + ); + return; + } + chunks.push(chunk); + }); + stream.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks)))); + stream.on("error", (cause) => reject(cause)); + stream.end(Buffer.from(bytes)); + }); +const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); +const isPluginManagementError = Schema.is(PluginManagementError); + +export const PLUGIN_CAPABILITY_DESCRIPTIONS = { + agents: "Run AI agents", + vcs: "Read version control state", + terminals: "Create and manage terminals", + database: "Use plugin database tables", + "projections.read": "Read projected workspace data", + "environments.read": "Read environment metadata", + secrets: "Store plugin secrets", + http: "Serve plugin HTTP routes", + sourceControl: "Use source control integrations", + textGeneration: "Request text generation", +} satisfies Record; + +const managementError = (code: PluginManagementError["code"], message: string, data?: unknown) => + new PluginManagementError({ + code, + message, + ...(data === undefined ? {} : { data }), + }); + +export class PluginInstaller extends Context.Service< + PluginInstaller, + { + readonly beginInstall: (input: { + readonly sourceId: string; + readonly pluginId: PluginIdType; + readonly version: string; + }) => Effect.Effect; + readonly confirmInstall: ( + stageToken: string, + ) => Effect.Effect<{ readonly plugin: PluginInfo }, PluginManagementError>; + readonly abortInstall: (stageToken: string) => Effect.Effect; + readonly setEnabled: (input: { + readonly pluginId: PluginIdType; + readonly enabled: boolean; + }) => Effect.Effect; + readonly uninstall: (input: { + readonly pluginId: PluginIdType; + readonly removeData: boolean; + }) => Effect.Effect; + readonly beginUpgrade: (input: { + readonly pluginId: PluginIdType; + readonly version: string; + }) => Effect.Effect; + readonly confirmUpgrade: ( + stageToken: string, + ) => Effect.Effect<{ readonly plugin: PluginInfo }, PluginManagementError>; + readonly checkUpdates: Effect.Effect< + { + readonly updates: ReadonlyArray<{ + readonly pluginId: PluginIdType; + readonly currentVersion: string; + readonly latestVersion: string; + }>; + }, + PluginManagementError + >; + } +>()("t3/plugins/PluginInstaller") {} + +interface StageRecord { + readonly operation: "install" | "upgrade"; + readonly stageToken: string; + readonly sourceId: string; + readonly pluginId: PluginIdType; + readonly version: string; + readonly sha256: string; + readonly stagingDir: string; + readonly expiresAtMs: number; + readonly manifest: PluginManifest; +} + +const sha256Hex = (bytes: Uint8Array) => + NodeCrypto.createHash("sha256").update(bytes).digest("hex"); + +const isGzip = (bytes: Uint8Array) => bytes[0] === 0x1f && bytes[1] === 0x8b; + +const isZeroBlock = (block: Uint8Array) => block.every((byte) => byte === 0); + +const cString = (bytes: Uint8Array) => { + const end = bytes.indexOf(0); + const slice = end === -1 ? bytes : bytes.slice(0, end); + return new TextDecoder().decode(slice).trim(); +}; + +const octal = (bytes: Uint8Array) => { + const raw = cString(bytes).split("\u0000").join("").trim(); + return raw.length === 0 ? 0 : Number.parseInt(raw, 8); +}; + +const isAllowedArchivePath = (entryPath: string) => + entryPath === "manifest.json" || + entryPath === "server" || + entryPath.startsWith("server/") || + entryPath === "web" || + entryPath.startsWith("web/") || + entryPath === "assets" || + entryPath.startsWith("assets/"); + +const validateRelativeArchivePath = (entryPath: string) => { + if ( + entryPath.length === 0 || + entryPath.includes("\0") || + entryPath.includes("\\") || + entryPath.startsWith("/") || + entryPath.split("/").some((segment) => segment === "." || segment === "..") + ) { + return managementError("extract-failed", "Plugin archive contains an unsafe path.", { + entryPath, + }); + } + if (!isAllowedArchivePath(entryPath)) { + return managementError("extract-failed", "Plugin archive contains an unsupported path.", { + entryPath, + }); + } + return null; +}; + +const ensureSemver = (value: string) => value.split(/[+-]/u)[0]?.split(".").map(Number) ?? []; + +const compareSemver = (left: string, right: string) => { + const leftParts = ensureSemver(left); + const rightParts = ensureSemver(right); + for (let index = 0; index < 3; index++) { + const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (diff !== 0) return diff; + } + return left.localeCompare(right); +}; + +const installedEntry = (entry: PluginLockfilePlugin | undefined): PluginLockfilePlugin => { + if (!entry) { + throw managementError("plugin-not-found", "Plugin is not installed."); + } + return entry; +}; + +const lockfileError = (cause: unknown) => + managementError( + "lockfile", + cause instanceof Error ? cause.message : "Plugin lockfile update failed.", + { + cause, + }, + ); + +/** + * Two plugin ids collide iff one's DB table prefix (`p__`) + * is a prefix of the other's — the real namespacing invariant the migrator + * enforces. A raw-id startsWith both falsely rejects distinct ids + * ("chat"/"chatbot": `p_chat_` is NOT a prefix of `p_chatbot_`) and misses + * hyphen aliasing ("test"/"test-plugin": `p_test_` IS a prefix of + * `p_test_plugin_`, so they DO collide). + */ +export function pluginTablePrefixesCollide(idA: string, idB: string): boolean { + const prefixA = pluginSqlPrefix(idA); + const prefixB = pluginSqlPrefix(idB); + return prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA); +} + +function assertNoPluginIdCollision( + pluginId: PluginIdType, + installedIds: ReadonlyArray, + allowSameId: boolean, +) { + for (const installedId of installedIds) { + if (installedId === pluginId) { + if (allowSameId) continue; + throw managementError("manifest-invalid", "Plugin is already installed.", { pluginId }); + } + if (pluginTablePrefixesCollide(pluginId, installedId)) { + throw managementError("manifest-invalid", "Plugin id collides with an installed plugin id.", { + pluginId, + installedId, + }); + } + } +} + +const assertHostCompatibility = (version: MarketplaceVersion, manifest: PluginManifest) => { + if (!hostApiSatisfies(version.hostApi, HOST_API_VERSION)) { + throw managementError( + "manifest-invalid", + "Marketplace version is not compatible with this host API.", + { + requested: version.hostApi, + hostApiVersion: HOST_API_VERSION, + }, + ); + } + if (!hostApiSatisfies(manifest.hostApi, HOST_API_VERSION)) { + throw managementError( + "manifest-invalid", + "Plugin manifest is not compatible with this host API.", + { + requested: manifest.hostApi, + hostApiVersion: HOST_API_VERSION, + }, + ); + } + if (version.minAppVersion && compareSemver(packageJson.version, version.minAppVersion) < 0) { + throw managementError("manifest-invalid", "Plugin version requires a newer app version.", { + minAppVersion: version.minAppVersion, + appVersion: packageJson.version, + }); + } + if (manifest.minAppVersion && compareSemver(packageJson.version, manifest.minAppVersion) < 0) { + throw managementError("manifest-invalid", "Plugin manifest requires a newer app version.", { + minAppVersion: manifest.minAppVersion, + appVersion: packageJson.version, + }); + } +}; + +const decompressTarball = (bytes: Uint8Array) => + Effect.tryPromise({ + try: async () => { + if (!isGzip(bytes)) return bytes; + // Cap the streamed output so a bomb is aborted before it exhausts memory. + // Also bound by the ratio relative to the (already capped) input. + const ratioCap = bytes.byteLength * DECOMPRESSION_RATIO_MAX; + const cap = Math.min(EXTRACT_TOTAL_MAX_BYTES, Math.max(ratioCap, 512)); + return await gunzipCapped(bytes, cap); + }, + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("extract-failed", "Failed to decompress plugin archive.", { cause }), + }); + +const extractTar = (input: { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly tarBytes: Uint8Array; + readonly outputDir: string; +}) => + Effect.gen(function* () { + let offset = 0; + let totalSize = 0; + while (offset + 512 <= input.tarBytes.byteLength) { + const header = input.tarBytes.slice(offset, offset + 512); + offset += 512; + if (isZeroBlock(header)) break; + + const name = cString(header.slice(0, 100)); + const prefix = cString(header.slice(345, 500)); + const entryPath = prefix.length > 0 ? `${prefix}/${name}` : name; + const size = octal(header.slice(124, 136)); + const typeFlag = String.fromCharCode(header[156] ?? 0); + if (!Number.isFinite(size) || size < 0) { + return yield* managementError( + "extract-failed", + "Plugin archive contains an invalid size.", + { + entryPath, + }, + ); + } + if (offset + size > input.tarBytes.byteLength) { + return yield* managementError("extract-failed", "Plugin archive is truncated.", { + entryPath, + }); + } + + const pathError = validateRelativeArchivePath(entryPath); + if (pathError) return yield* pathError; + if (typeFlag === "2" || typeFlag === "1") { + return yield* managementError("extract-failed", "Plugin archive may not contain links.", { + entryPath, + }); + } + if (typeFlag !== "\0" && typeFlag !== "0" && typeFlag !== "5") { + return yield* managementError( + "extract-failed", + "Plugin archive contains an unsupported entry.", + { + entryPath, + typeFlag, + }, + ); + } + + if (typeFlag === "5") { + yield* input.fs.makeDirectory(input.path.join(input.outputDir, entryPath), { + recursive: true, + }); + } else { + if (size > EXTRACT_FILE_MAX_BYTES) { + return yield* managementError("extract-failed", "Plugin archive file is too large.", { + entryPath, + limit: EXTRACT_FILE_MAX_BYTES, + actual: size, + }); + } + totalSize += size; + if (totalSize > EXTRACT_TOTAL_MAX_BYTES) { + return yield* managementError( + "extract-failed", + "Plugin archive extracted size is too large.", + { + limit: EXTRACT_TOTAL_MAX_BYTES, + actual: totalSize, + }, + ); + } + const outputPath = input.path.join(input.outputDir, entryPath); + yield* input.fs.makeDirectory(input.path.dirname(outputPath), { recursive: true }); + yield* input.fs.writeFile(outputPath, input.tarBytes.slice(offset, offset + size)); + } + + offset += Math.ceil(size / 512) * 512; + } + }).pipe( + Effect.mapError((cause) => + isPluginManagementError(cause) + ? cause + : managementError("extract-failed", "Failed to extract plugin archive.", { cause }), + ), + ); + +export const make = Effect.fn("PluginInstaller.make")(function* () { + const config = yield* ServerConfig.ServerConfig; + const httpClient = yield* HttpClient.HttpClient; + const clock = yield* Clock.Clock; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const store = yield* PluginLockfileStore; + const marketplace = yield* PluginMarketplace; + const host = yield* PluginHost; + const catalog = yield* PluginCatalog; + const stages = yield* Ref.make(new Map()); + const stagingRoot = path.join(config.pluginsDir, ".staging"); + + const removePath = (target: string) => + fs + .remove(target, { recursive: true, force: true }) + .pipe( + Effect.mapError((cause) => + managementError("filesystem", "Failed to remove plugin path.", { target, cause }), + ), + ); + + const cleanupExpired = Effect.gen(function* () { + const now = yield* clock.currentTimeMillis; + const expired = yield* Ref.modify(stages, (current) => { + const next = new Map(current); + const removed: Array = []; + for (const stage of current.values()) { + if (stage.expiresAtMs <= now) { + next.delete(stage.stageToken); + removed.push(stage); + } + } + return [removed, next]; + }); + yield* Effect.forEach(expired, (stage) => removePath(stage.stagingDir), { + concurrency: 4, + discard: true, + }); + }); + + const getStage = (stageToken: string, operation: StageRecord["operation"]) => + Effect.gen(function* () { + yield* cleanupExpired; + const stage = (yield* Ref.get(stages)).get(stageToken); + if (!stage || stage.operation !== operation) { + return yield* managementError("stage-not-found", "Plugin staging token was not found.", { + stageToken, + }); + } + return stage; + }); + + const dropStage = (stageToken: string) => + Ref.modify(stages, (current) => { + const stage = current.get(stageToken); + const next = new Map(current); + next.delete(stageToken); + return [stage, next] as const; + }); + + const readSources = Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + return lockfile.sources; + }); + + const sourceById = (sourceId: string) => + Effect.gen(function* () { + const source = (yield* readSources).find((candidate) => candidate.id === sourceId); + if (!source) { + return yield* managementError("source-not-found", "Plugin source was not found.", { + sourceId, + }); + } + return source; + }); + + const downloadBytes = (url: string) => { + if (url.startsWith("file:")) { + return fs.readFile(NodeURL.fileURLToPath(url)).pipe( + Effect.mapError((cause) => + managementError("download-failed", "Failed to read plugin tarball file.", { + url, + cause, + }), + ), + ); + } + return httpClient.execute(HttpClientRequest.get(url)).pipe( + Effect.mapError((cause) => + managementError("download-failed", "Failed to download plugin tarball.", { url, cause }), + ), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError((cause) => + managementError("download-failed", "Plugin tarball returned a non-OK response.", { + url, + cause, + }), + ), + Effect.flatMap((response) => + readHttpResponseBytesCapped({ + response, + maxBytes: DOWNLOAD_MAX_BYTES, + tooLarge: (actual) => + managementError("download-failed", "Plugin tarball is too large.", { + url, + limit: DOWNLOAD_MAX_BYTES, + actual, + }), + readFailed: (cause) => + managementError("download-failed", "Failed to read plugin tarball body.", { + url, + cause, + }), + }), + ), + ); + }; + + const readManifest = (stagingDir: string) => + fs.readFileString(pluginManifestPath(stagingDir, path.join)).pipe( + Effect.mapError((cause) => + managementError("manifest-invalid", "Plugin archive is missing manifest.json.", { cause }), + ), + Effect.flatMap((raw) => + decodeManifestJson(raw).pipe( + Effect.mapError((cause) => + managementError("manifest-invalid", "Plugin manifest is invalid.", { cause }), + ), + ), + ), + ); + + const validateManifest = (input: { + readonly operation: StageRecord["operation"]; + readonly requestedPluginId: PluginIdType; + readonly requestedVersion: string; + readonly manifest: PluginManifest; + readonly marketplaceVersion: MarketplaceVersion; + }) => + Effect.gen(function* () { + if (input.manifest.id !== input.requestedPluginId) { + return yield* managementError( + "manifest-invalid", + "Plugin manifest id does not match the request.", + { + expected: input.requestedPluginId, + actual: input.manifest.id, + }, + ); + } + if (input.manifest.version !== input.requestedVersion) { + return yield* managementError( + "manifest-invalid", + "Plugin manifest version does not match the request.", + { + expected: input.requestedVersion, + actual: input.manifest.version, + }, + ); + } + yield* Effect.try({ + try: () => assertHostCompatibility(input.marketplaceVersion, input.manifest), + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("manifest-invalid", "Plugin manifest compatibility check failed.", { + cause, + }), + }); + + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + yield* Effect.try({ + try: () => + assertNoPluginIdCollision( + input.requestedPluginId, + Object.keys(lockfile.plugins), + input.operation === "upgrade", + ), + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("manifest-invalid", "Plugin id collision check failed.", { cause }), + }); + }); + + const stageTarball = (input: { + readonly operation: StageRecord["operation"]; + readonly sourceId: string; + readonly pluginId: PluginIdType; + readonly version: string; + readonly tarballUrl: string; + readonly marketplaceVersion: MarketplaceVersion; + }) => + Effect.gen(function* () { + yield* cleanupExpired; + const downloaded = yield* downloadBytes(input.tarballUrl); + if (downloaded.byteLength > DOWNLOAD_MAX_BYTES) { + return yield* managementError("download-failed", "Plugin tarball is too large.", { + limit: DOWNLOAD_MAX_BYTES, + actual: downloaded.byteLength, + }); + } + const actualSha = sha256Hex(downloaded); + if (actualSha.toLowerCase() !== input.marketplaceVersion.sha256.toLowerCase()) { + return yield* managementError( + "checksum-mismatch", + "Plugin tarball checksum did not match.", + { + expected: input.marketplaceVersion.sha256, + actual: actualSha, + }, + ); + } + + const stageToken = NodeCrypto.randomUUID(); + const stagingDir = path.join(stagingRoot, stageToken); + yield* fs.remove(stagingDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(stagingDir, { recursive: true })), + Effect.mapError((cause) => + managementError("filesystem", "Failed to create plugin staging directory.", { + stagingDir, + cause, + }), + ), + ); + + const tarBytes = yield* decompressTarball(downloaded); + yield* extractTar({ fs, path, tarBytes, outputDir: stagingDir }).pipe( + Effect.catch((error) => removePath(stagingDir).pipe(Effect.andThen(Effect.fail(error)))), + ); + const manifest = yield* readManifest(stagingDir).pipe( + Effect.catch((error) => removePath(stagingDir).pipe(Effect.andThen(Effect.fail(error)))), + ); + yield* validateManifest({ + operation: input.operation, + requestedPluginId: input.pluginId, + requestedVersion: input.version, + manifest, + marketplaceVersion: input.marketplaceVersion, + }).pipe( + Effect.catch((error) => removePath(stagingDir).pipe(Effect.andThen(Effect.fail(error)))), + ); + + const now = yield* clock.currentTimeMillis; + yield* Ref.update(stages, (current) => { + const next = new Map(current); + next.set(stageToken, { + operation: input.operation, + stageToken, + sourceId: input.sourceId, + pluginId: input.pluginId, + version: input.version, + sha256: input.marketplaceVersion.sha256, + stagingDir, + expiresAtMs: now + STAGE_TOKEN_TTL_MS, + manifest, + }); + return next; + }); + + return { + stageToken, + manifest, + capabilityDescriptions: Object.fromEntries( + manifest.capabilities.map((capability) => [ + capability, + PLUGIN_CAPABILITY_DESCRIPTIONS[capability], + ]), + ) as Record, + }; + }); + + const pluginInfo = (pluginId: PluginIdType) => + catalog.list.pipe( + Effect.map((plugins) => plugins.find((plugin) => plugin.id === pluginId)), + Effect.flatMap((plugin) => + plugin + ? Effect.succeed(plugin) + : Effect.fail( + managementError("plugin-not-found", "Installed plugin metadata was not found.", { + pluginId, + }), + ), + ), + ); + + const moveStagingToVersionDir = (stage: StageRecord) => + Effect.gen(function* () { + const destination = pluginVersionDir( + config.pluginsDir, + stage.pluginId, + stage.version, + path.join, + ); + yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); + // Remove any pre-existing version dir (reinstall / interrupted prior + // move) so rename cannot fail with ENOTEMPTY on an occupied destination. + yield* fs.remove(destination, { recursive: true, force: true }); + yield* fs.rename(stage.stagingDir, destination); + }).pipe( + Effect.mapError((cause) => + managementError("filesystem", "Failed to move staged plugin into place.", { + pluginId: stage.pluginId, + version: stage.version, + cause, + }), + ), + ); + + // Drop the stage token and best-effort remove its staging dir. Run on any + // confirm failure so a failed confirm never leaves a dangling record or dir. + const cleanupStage = (stageToken: string) => + dropStage(stageToken).pipe( + Effect.flatMap((stage) => + stage ? removePath(stage.stagingDir).pipe(Effect.ignore) : Effect.void, + ), + ); + + const beginInstall: PluginInstaller["Service"]["beginInstall"] = (input) => + Effect.gen(function* () { + const source = yield* sourceById(input.sourceId); + const found = yield* marketplace.findVersion({ + source, + pluginId: input.pluginId, + version: input.version, + }); + return yield* stageTarball({ + operation: "install", + sourceId: input.sourceId, + pluginId: input.pluginId, + version: input.version, + tarballUrl: found.tarballUrl, + marketplaceVersion: found.version, + }); + }); + + const confirmInstall: PluginInstaller["Service"]["confirmInstall"] = (stageToken) => + Effect.gen(function* () { + const stage = yield* getStage(stageToken, "install"); + yield* moveStagingToVersionDir(stage); + const installedAt = DateTime.formatIso(yield* DateTime.now); + yield* store + .updatePlugin(stage.pluginId, () => + Effect.succeed({ + version: stage.version, + sha256: stage.sha256, + sourceId: stage.sourceId, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt, + lastError: null, + }), + ) + .pipe(Effect.mapError(lockfileError)); + yield* dropStage(stageToken); + yield* host + .activatePlugin(stage.pluginId) + .pipe( + Effect.mapError((cause) => + managementError("activation-failed", "Plugin activation failed.", { cause }), + ), + ); + return { plugin: yield* pluginInfo(stage.pluginId) }; + }).pipe(Effect.tapError(() => cleanupStage(stageToken))); + + const abortInstall: PluginInstaller["Service"]["abortInstall"] = (stageToken) => + Effect.gen(function* () { + const stage = yield* dropStage(stageToken); + if (stage) { + yield* removePath(stage.stagingDir); + } + }); + + const setEnabled: PluginInstaller["Service"]["setEnabled"] = (input) => + Effect.gen(function* () { + yield* store + .updatePlugin(input.pluginId, ({ current }) => + Effect.succeed({ + ...installedEntry(current), + enabled: input.enabled, + state: input.enabled ? "active" : "disabled", + lastError: input.enabled ? null : (current?.lastError ?? null), + activation: input.enabled + ? { activatingSince: null, crashCount: 0 } + : (current?.activation ?? { activatingSince: null, crashCount: 0 }), + }), + ) + .pipe(Effect.mapError(lockfileError)); + if (input.enabled) { + yield* host.activatePlugin(input.pluginId); + } else { + yield* host.deactivatePlugin(input.pluginId); + } + }); + + const uninstall: PluginInstaller["Service"]["uninstall"] = (input) => + Effect.gen(function* () { + if (!input.removeData) { + const markerPath = path.join(config.pluginsDir, input.pluginId, PRESERVE_DATA_MARKER); + yield* fs.makeDirectory(path.dirname(markerPath), { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(markerPath, "")), + Effect.mapError((cause) => + managementError("filesystem", "Failed to record plugin data preservation intent.", { + pluginId: input.pluginId, + cause, + }), + ), + ); + } + yield* store + .updatePlugin(input.pluginId, ({ current }) => + Effect.succeed({ + ...installedEntry(current), + state: "pending-remove", + enabled: false, + }), + ) + .pipe(Effect.mapError(lockfileError)); + }); + + const beginUpgrade: PluginInstaller["Service"]["beginUpgrade"] = (input) => + Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + const current = installedEntry(lockfile.plugins[input.pluginId]); + const source = lockfile.sources.find((candidate) => candidate.id === current.sourceId); + if (!source) { + return yield* managementError( + "source-not-found", + "Installed plugin source was not found.", + { + sourceId: current.sourceId, + }, + ); + } + const found = yield* marketplace.findVersion({ + source, + pluginId: input.pluginId, + version: input.version, + }); + return yield* stageTarball({ + operation: "upgrade", + sourceId: current.sourceId, + pluginId: input.pluginId, + version: input.version, + tarballUrl: found.tarballUrl, + marketplaceVersion: found.version, + }); + }); + + const confirmUpgrade: PluginInstaller["Service"]["confirmUpgrade"] = (stageToken) => + Effect.gen(function* () { + const stage = yield* getStage(stageToken, "upgrade"); + yield* moveStagingToVersionDir(stage); + const stagedAt = DateTime.formatIso(yield* DateTime.now); + yield* store + .updatePlugin(stage.pluginId, ({ current }) => + Effect.succeed({ + ...installedEntry(current), + state: "pending-upgrade", + staged: { + version: stage.version, + sha256: stage.sha256, + stagedAt, + }, + }), + ) + .pipe(Effect.mapError(lockfileError)); + yield* dropStage(stageToken); + return { plugin: yield* pluginInfo(stage.pluginId) }; + }).pipe(Effect.tapError(() => cleanupStage(stageToken))); + + const checkUpdates = Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + const updates = yield* Effect.forEach( + Object.entries(lockfile.plugins), + ([rawPluginId, entry]) => + Effect.gen(function* () { + const pluginId = PluginId.make(rawPluginId); + const source = lockfile.sources.find((candidate) => candidate.id === entry.sourceId); + if (!source) return null; + const index = yield* marketplace + .fetchSource(source) + .pipe(Effect.orElseSucceed(() => null)); + if (index === null) return null; + const marketplaceEntry = index.plugins.find((candidate) => candidate.id === pluginId); + if (!marketplaceEntry) return null; + const latest = marketplaceEntry.versions.toSorted((left, right) => + compareSemver(right.version, left.version), + )[0]; + if (!latest || compareSemver(latest.version, entry.version) <= 0) return null; + return { + pluginId, + currentVersion: entry.version, + latestVersion: latest.version, + }; + }), + { concurrency: 4 }, + ); + return { updates: updates.filter((update) => update !== null) }; + }); + + return PluginInstaller.of({ + beginInstall, + confirmInstall, + abortInstall, + setEnabled, + uninstall, + beginUpgrade, + confirmUpgrade, + checkUpdates, + }); +}); + +export { PRESERVE_DATA_MARKER }; +export const layer = Layer.effect(PluginInstaller, make()); diff --git a/apps/server/src/plugins/PluginLockfileStore.ts b/apps/server/src/plugins/PluginLockfileStore.ts index 4649599aa30..5266eb9c616 100644 --- a/apps/server/src/plugins/PluginLockfileStore.ts +++ b/apps/server/src/plugins/PluginLockfileStore.ts @@ -95,6 +95,15 @@ export class PluginLockfileStore extends Context.Service< PluginLockfile, PluginLockfileReadError | PluginLockfileCorruptError >; + readonly updateSources: ( + fn: ( + sources: ReadonlyArray, + lockfile: PluginLockfile, + ) => Effect.Effect< + ReadonlyArray, + PluginLockfileStoreError + >, + ) => Effect.Effect; readonly updatePlugin: ( id: PluginId, fn: ( @@ -296,6 +305,14 @@ export const make = Effect.fn("PluginLockfileStore.make")(function* () { }), ); + const updateSources: PluginLockfileStore["Service"]["updateSources"] = (fn) => + mutate((lockfile) => + Effect.gen(function* () { + const sources = yield* fn(lockfile.sources, lockfile); + return { ...lockfile, sources: Array.from(sources) }; + }), + ); + const removePlugin: PluginLockfileStore["Service"]["removePlugin"] = (id) => updatePlugin(id, () => Effect.succeed(undefined as PluginLockfilePlugin | undefined)); @@ -318,6 +335,7 @@ export const make = Effect.fn("PluginLockfileStore.make")(function* () { lockfilePath, advisoryLockPath, readLockfile, + updateSources, updatePlugin, removePlugin, transition, diff --git a/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts b/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts new file mode 100644 index 00000000000..0e54f1de93b --- /dev/null +++ b/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts @@ -0,0 +1,107 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import { PluginInstaller } from "./PluginInstaller.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import { PluginManagementRpcHandlers } from "./PluginManagementRpcHandlers.ts"; +import * as PluginManagementRpcHandlersModule from "./PluginManagementRpcHandlers.ts"; +import * as PluginMarketplace from "./PluginMarketplace.ts"; + +const pluginId = PluginId.make("test-plugin"); + +const TestHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + +const InstallerMockLive = Layer.succeed( + PluginInstaller, + PluginInstaller.of({ + beginInstall: () => Effect.die("not used"), + confirmInstall: () => Effect.die("not used"), + abortInstall: () => Effect.void, + setEnabled: () => Effect.void, + uninstall: () => Effect.void, + beginUpgrade: () => Effect.die("not used"), + confirmUpgrade: () => Effect.die("not used"), + checkUpdates: Effect.succeed({ updates: [] }), + }), +); + +const managementTest = it.layer( + PluginManagementRpcHandlersModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayer.layer), + Layer.provideMerge(PluginMarketplace.layer), + Layer.provideMerge(InstallerMockLive), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-management-" })), + Layer.provideMerge(NodeServices.layer), + ), +); + +managementTest("PluginManagementRpcHandlers", (it) => { + it.effect("dedupes added sources by normalized HTTPS URL", () => + Effect.gen(function* () { + const handlers = yield* PluginManagementRpcHandlers; + + const first = yield* handlers.addSource({ + url: "https://example.test/marketplace.json#ignored", + }); + const second = yield* handlers.addSource({ + url: "https://example.test/marketplace.json", + }); + const listed = yield* handlers.listSources; + + assert.equal(first.source.id, second.source.id); + assert.equal(listed.sources.length, 1); + assert.equal(listed.sources[0]?.url, "https://example.test/marketplace.json"); + }), + ); + + it.effect("rejects non-HTTPS sources", () => + Effect.gen(function* () { + const handlers = yield* PluginManagementRpcHandlers; + + const result = yield* Effect.result(handlers.addSource({ url: "http://example.test" })); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "invalid-source"); + }), + ); + + it.effect("prevents removing a source used by an installed plugin", () => + Effect.gen(function* () { + const handlers = yield* PluginManagementRpcHandlers; + const store = yield* PluginLockfileStore; + const source = yield* handlers.addSource({ url: "https://example.test/marketplace.json" }); + yield* store.updatePlugin(pluginId, () => + Effect.succeed({ + version: "1.0.0", + sha256: "sha", + sourceId: source.source.id, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const result = yield* Effect.result(handlers.removeSource({ sourceId: source.source.id })); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "invalid-source"); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginManagementRpcHandlers.ts b/apps/server/src/plugins/PluginManagementRpcHandlers.ts new file mode 100644 index 00000000000..52f7de01ffe --- /dev/null +++ b/apps/server/src/plugins/PluginManagementRpcHandlers.ts @@ -0,0 +1,165 @@ +import { + PluginManagementError, + type PluginCatalogResult, + type PluginCheckUpdatesResult, + type PluginInstallBeginInput, + type PluginInstallConfirmResult, + type PluginInstallStaged, + type PluginSetEnabledInput, + type PluginSource, + type PluginSourcesAddInput, + type PluginSourcesAddResult, + type PluginSourcesListResult, + type PluginSourcesRemoveInput, + type PluginUninstallInput, + type PluginUpgradeBeginInput, + type PluginUpgradeConfirmResult, +} from "@t3tools/contracts/plugin"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { PluginInstaller } from "./PluginInstaller.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { PluginMarketplace, sourceIdForUrl } from "./PluginMarketplace.ts"; + +const managementError = (code: PluginManagementError["code"], message: string, data?: unknown) => + new PluginManagementError({ + code, + message, + ...(data === undefined ? {} : { data }), + }); + +const lockfileError = (cause: unknown) => + managementError( + "lockfile", + cause instanceof Error ? cause.message : "Plugin lockfile update failed.", + { + cause, + }, + ); +const isPluginManagementError = Schema.is(PluginManagementError); +const toManagementError = (cause: unknown) => + isPluginManagementError(cause) ? cause : lockfileError(cause); + +export class PluginManagementRpcHandlers extends Context.Service< + PluginManagementRpcHandlers, + { + readonly listSources: Effect.Effect; + readonly addSource: ( + input: PluginSourcesAddInput, + ) => Effect.Effect; + readonly removeSource: ( + input: PluginSourcesRemoveInput, + ) => Effect.Effect; + readonly catalog: (input: { + readonly sourceId?: string; + }) => Effect.Effect; + readonly beginInstall: ( + input: PluginInstallBeginInput, + ) => Effect.Effect; + readonly confirmInstall: ( + stageToken: string, + ) => Effect.Effect; + readonly abortInstall: (stageToken: string) => Effect.Effect; + readonly setEnabled: ( + input: PluginSetEnabledInput, + ) => Effect.Effect; + readonly uninstall: (input: PluginUninstallInput) => Effect.Effect; + readonly beginUpgrade: ( + input: PluginUpgradeBeginInput, + ) => Effect.Effect; + readonly confirmUpgrade: ( + stageToken: string, + ) => Effect.Effect; + readonly checkUpdates: Effect.Effect; + } +>()("t3/plugins/PluginManagementRpcHandlers") {} + +export const make = Effect.fn("PluginManagementRpcHandlers.make")(function* () { + const store = yield* PluginLockfileStore; + const marketplace = yield* PluginMarketplace; + const installer = yield* PluginInstaller; + + const listSources = store.readLockfile.pipe( + Effect.map((lockfile) => ({ sources: Array.from(lockfile.sources) })), + Effect.mapError(lockfileError), + ); + + const addSource: PluginManagementRpcHandlers["Service"]["addSource"] = (input) => + Effect.gen(function* () { + const normalized = yield* marketplace.normalizeSourceUrl(input.url); + const id = sourceIdForUrl(normalized); + const now = DateTime.formatIso(yield* DateTime.now); + const source = yield* store + .updateSources((sources) => { + const existing = sources.find((candidate) => candidate.url === normalized); + if (existing) return Effect.succeed(sources); + return Effect.succeed([...sources, { id, url: normalized, addedAt: now }]); + }) + .pipe(Effect.mapError(toManagementError)); + const entry = source.sources.find((candidate) => candidate.url === normalized); + if (!entry) { + return yield* managementError("invalid-source", "Failed to add plugin source.", { + url: normalized, + }); + } + return { source: entry }; + }); + + const removeSource: PluginManagementRpcHandlers["Service"]["removeSource"] = (input) => + Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + if (!lockfile.sources.some((source) => source.id === input.sourceId)) { + return yield* managementError("source-not-found", "Plugin source was not found.", { + sourceId: input.sourceId, + }); + } + const usedBy = Object.entries(lockfile.plugins).find( + ([, plugin]) => plugin.sourceId === input.sourceId, + )?.[0]; + if (usedBy) { + return yield* managementError( + "invalid-source", + "Plugin source is still used by an installed plugin.", + { + sourceId: input.sourceId, + pluginId: usedBy, + }, + ); + } + yield* store + .updateSources((sources) => + Effect.succeed(sources.filter((source) => source.id !== input.sourceId)), + ) + .pipe(Effect.asVoid, Effect.mapError(toManagementError)); + }); + + const catalog: PluginManagementRpcHandlers["Service"]["catalog"] = (input) => + Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + return yield* marketplace.catalog( + lockfile.sources as ReadonlyArray, + input.sourceId, + ); + }); + + return PluginManagementRpcHandlers.of({ + listSources, + addSource, + removeSource, + catalog, + beginInstall: installer.beginInstall, + confirmInstall: installer.confirmInstall, + abortInstall: installer.abortInstall, + setEnabled: installer.setEnabled, + uninstall: installer.uninstall, + beginUpgrade: installer.beginUpgrade, + confirmUpgrade: installer.confirmUpgrade, + checkUpdates: installer.checkUpdates, + }); +}); + +export const layer = Layer.effect(PluginManagementRpcHandlers, make()); diff --git a/apps/server/src/plugins/PluginMarketplace.test.ts b/apps/server/src/plugins/PluginMarketplace.test.ts new file mode 100644 index 00000000000..b023427e26f --- /dev/null +++ b/apps/server/src/plugins/PluginMarketplace.test.ts @@ -0,0 +1,174 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PluginId, type PluginSource } from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as NodeURL from "node:url"; + +import { + MarketplaceIndex, + PluginMarketplace, + resolveMarketplaceUrl, + sourceIdForUrl, + layer as PluginMarketplaceLayer, +} from "./PluginMarketplace.ts"; + +const encodeMarketplaceJson = Schema.encodeSync(Schema.fromJsonString(MarketplaceIndex)); + +const validMarketplace = { + plugins: [ + { + id: PluginId.make("test-plugin"), + name: "Test Plugin", + description: "Adds tests.", + capabilities: ["agents" as const], + versions: [ + { + version: "1.0.0", + tarball: "https://example.test/test-plugin-1.0.0.tgz", + sha256: "a".repeat(64), + hostApi: "^1.0.0", + publishedAt: "2026-07-03T00:00:00.000Z", + }, + ], + }, + ], +}; + +const TestHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(validMarketplace))), + ), +); + +const marketplaceTest = it.layer( + PluginMarketplaceLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(TestHttpClientLive), + ), +); + +const withPluginDev = (effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => process.env.T3_PLUGIN_DEV), + () => + Effect.sync(() => { + process.env.T3_PLUGIN_DEV = "1"; + }).pipe(Effect.andThen(effect)), + (previous) => + Effect.sync(() => { + if (previous === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous; + } + }), + ); + +marketplaceTest("PluginMarketplace", (it) => { + it.effect("resolves HTTPS and owner/repo sources and rejects unsafe protocols", () => + Effect.sync(() => { + assert.equal( + resolveMarketplaceUrl("https://example.test/marketplace.json#ignored"), + "https://example.test/marketplace.json", + ); + assert.equal( + resolveMarketplaceUrl("owner/repo"), + "https://raw.githubusercontent.com/owner/repo/HEAD/marketplace.json", + ); + assert.throws(() => resolveMarketplaceUrl("http://example.test/marketplace.json")); + assert.throws(() => resolveMarketplaceUrl("file:///tmp/marketplace.json")); + }), + ); + + it.effect("allows file sources only in plugin dev mode", () => + withPluginDev( + Effect.sync(() => { + assert.equal( + resolveMarketplaceUrl("file:///tmp/marketplace.json"), + "file:///tmp/marketplace.json", + ); + }), + ), + ); + + it.effect("decodes marketplace json from a source", () => + withPluginDev( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const marketplace = yield* PluginMarketplace; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-marketplace-" }); + const filePath = path.join(dir, "marketplace.json"); + yield* fs.writeFileString(filePath, encodeMarketplaceJson(validMarketplace)); + const url = NodeURL.pathToFileURL(filePath).toString(); + const source: PluginSource = { + id: sourceIdForUrl(url), + url, + addedAt: "2026-07-03T00:00:00.000Z", + }; + + const index = yield* marketplace.fetchSource(source); + + assert.equal(index.plugins[0]?.id, PluginId.make("test-plugin")); + }), + ), + ); + + it.effect("isolates bad source errors in aggregate catalog calls", () => + withPluginDev( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const marketplace = yield* PluginMarketplace; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-marketplace-" }); + const goodPath = path.join(dir, "good.json"); + const badPath = path.join(dir, "bad.json"); + yield* fs.writeFileString(goodPath, encodeMarketplaceJson(validMarketplace)); + yield* fs.writeFileString(badPath, "{not-json"); + const goodUrl = NodeURL.pathToFileURL(goodPath).toString(); + const badUrl = NodeURL.pathToFileURL(badPath).toString(); + + const result = yield* marketplace.catalog([ + { id: "good", url: goodUrl, addedAt: "2026-07-03T00:00:00.000Z" }, + { id: "bad", url: badUrl, addedAt: "2026-07-03T00:00:00.000Z" }, + ]); + + assert.equal(result.entries.length, 1); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0]?.sourceId, "bad"); + }), + ), + ); + + it.effect("rejects marketplace responses over the byte cap", () => + withPluginDev( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const marketplace = yield* PluginMarketplace; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-marketplace-" }); + const filePath = path.join(dir, "huge.json"); + yield* fs.writeFileString(filePath, "x".repeat(2 * 1024 * 1024 + 1)); + const url = NodeURL.pathToFileURL(filePath).toString(); + const result = yield* Effect.result( + marketplace.fetchSource({ + id: "huge", + url, + addedAt: "2026-07-03T00:00:00.000Z", + }), + ); + + assert.isTrue(Result.isFailure(result)); + }), + ), + ); +}); diff --git a/apps/server/src/plugins/PluginMarketplace.ts b/apps/server/src/plugins/PluginMarketplace.ts new file mode 100644 index 00000000000..b1c7f1f880d --- /dev/null +++ b/apps/server/src/plugins/PluginMarketplace.ts @@ -0,0 +1,305 @@ +import { + MarketplaceEntry, + PluginManagementError, + type MarketplaceVersion, + type PluginCatalogResult, + type PluginId, + type PluginSource, +} from "@t3tools/contracts/plugin"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeURL from "node:url"; + +import { readHttpResponseBytesCapped } from "./readHttpResponseBytesCapped.ts"; + +const MARKETPLACE_RESPONSE_MAX_BYTES = 2 * 1024 * 1024; +const CATALOG_CACHE_TTL_MS = 30_000; +const OWNER_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; + +export const MarketplaceIndex = Schema.Struct({ + plugins: Schema.Array(MarketplaceEntry), +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type MarketplaceIndex = typeof MarketplaceIndex.Type; + +const decodeMarketplaceIndexJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(MarketplaceIndex), +); +const isPluginManagementError = Schema.is(PluginManagementError); + +const managementError = (code: PluginManagementError["code"], message: string, data?: unknown) => + new PluginManagementError({ + code, + message, + ...(data === undefined ? {} : { data }), + }); + +export function sourceIdForUrl(url: string): string { + return `src-${NodeCrypto.createHash("sha256").update(url).digest("hex").slice(0, 16)}`; +} + +function canonicalHttpsUrl(input: string): string | null { + try { + const url = new URL(input); + if (url.protocol !== "https:") return null; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +export function resolveMarketplaceUrl(input: string): string { + const trimmed = input.trim(); + if (OWNER_REPO_PATTERN.test(trimmed)) { + return `https://raw.githubusercontent.com/${trimmed}/HEAD/marketplace.json`; + } + + const https = canonicalHttpsUrl(trimmed); + if (https !== null) return https; + + const url = new URL(trimmed); + if (url.protocol === "file:" && process.env.T3_PLUGIN_DEV === "1") { + return url.toString(); + } + throw managementError( + "invalid-source", + "Plugin sources must be HTTPS URLs or owner/repo shorthand.", + { url: input }, + ); +} + +export function resolveTarballUrl(input: { + readonly tarball: string; + readonly marketplaceUrl: string; +}): string { + const url = new URL(input.tarball, input.marketplaceUrl); + if (url.protocol === "https:") { + url.hash = ""; + return url.toString(); + } + if (url.protocol === "file:" && process.env.T3_PLUGIN_DEV === "1") { + return url.toString(); + } + throw managementError("invalid-source", "Plugin tarballs must resolve to HTTPS URLs.", { + tarball: input.tarball, + }); +} + +export class PluginMarketplace extends Context.Service< + PluginMarketplace, + { + readonly normalizeSourceUrl: (url: string) => Effect.Effect; + readonly fetchSource: ( + source: PluginSource, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; + readonly catalog: ( + sources: ReadonlyArray, + sourceId?: string, + ) => Effect.Effect; + readonly findVersion: (input: { + readonly source: PluginSource; + readonly pluginId: PluginId; + readonly version: string; + }) => Effect.Effect< + { + readonly entry: MarketplaceEntry; + readonly version: MarketplaceVersion; + readonly marketplaceUrl: string; + readonly tarballUrl: string; + }, + PluginManagementError + >; + } +>()("t3/plugins/PluginMarketplace") {} + +interface CachedIndex { + readonly expiresAtMs: number; + readonly index: MarketplaceIndex; +} + +export const make = Effect.fn("PluginMarketplace.make")(function* () { + const httpClient = yield* HttpClient.HttpClient; + const clock = yield* Clock.Clock; + const fs = yield* FileSystem.FileSystem; + const cache = yield* Ref.make(new Map()); + + const normalizeSourceUrl = (url: string) => + Effect.try({ + try: () => resolveMarketplaceUrl(url), + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("invalid-source", "Plugin source URL is invalid.", { cause }), + }); + + const readFileUrl = (url: string) => + fs.readFile(NodeURL.fileURLToPath(url)).pipe( + Effect.mapError((cause) => + managementError("catalog-fetch-failed", "Failed to read plugin marketplace file.", { + url, + cause, + }), + ), + ); + + const readHttpUrl = (url: string) => + httpClient.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson)).pipe( + Effect.mapError((cause) => + managementError("catalog-fetch-failed", "Failed to fetch plugin marketplace.", { + url, + cause, + }), + ), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError((cause) => + managementError("catalog-fetch-failed", "Plugin marketplace returned a non-OK response.", { + url, + cause, + }), + ), + Effect.flatMap((response) => + readHttpResponseBytesCapped({ + response, + maxBytes: MARKETPLACE_RESPONSE_MAX_BYTES, + tooLarge: (actual) => + managementError("catalog-fetch-failed", "Plugin marketplace is too large.", { + url, + limit: MARKETPLACE_RESPONSE_MAX_BYTES, + actual, + }), + readFailed: (cause) => + managementError("catalog-fetch-failed", "Failed to read plugin marketplace body.", { + url, + cause, + }), + }), + ), + ); + + const readBytes = (url: string) => + url.startsWith("file:") ? readFileUrl(url) : readHttpUrl(url); + + const fetchSource: PluginMarketplace["Service"]["fetchSource"] = (source, options) => + Effect.gen(function* () { + const marketplaceUrl = yield* normalizeSourceUrl(source.url); + const now = yield* clock.currentTimeMillis; + if (options?.refresh !== true) { + const cached = (yield* Ref.get(cache)).get(marketplaceUrl); + if (cached && cached.expiresAtMs > now) return cached.index; + } + + const bytes = yield* readBytes(marketplaceUrl); + if (bytes.byteLength > MARKETPLACE_RESPONSE_MAX_BYTES) { + return yield* managementError("catalog-fetch-failed", "Plugin marketplace is too large.", { + sourceId: source.id, + limit: MARKETPLACE_RESPONSE_MAX_BYTES, + actual: bytes.byteLength, + }); + } + + const index = yield* decodeMarketplaceIndexJson(new TextDecoder().decode(bytes)).pipe( + Effect.mapError((cause) => + managementError("catalog-fetch-failed", "Plugin marketplace JSON is invalid.", { + sourceId: source.id, + cause, + }), + ), + ); + yield* Ref.update(cache, (current) => { + const next = new Map(current); + next.set(marketplaceUrl, { index, expiresAtMs: now + CATALOG_CACHE_TTL_MS }); + return next; + }); + return index; + }); + + const catalog: PluginMarketplace["Service"]["catalog"] = (sources, sourceId) => + Effect.gen(function* () { + const selected = + sourceId === undefined ? sources : sources.filter((source) => source.id === sourceId); + if (sourceId !== undefined && selected.length === 0) { + return yield* managementError("source-not-found", "Plugin source was not found.", { + sourceId, + }); + } + const results = yield* Effect.forEach( + selected, + (source) => + fetchSource(source).pipe( + Effect.match({ + onFailure: (error) => ({ + source, + error, + index: null, + }), + onSuccess: (index) => ({ + source, + error: null, + index, + }), + }), + ), + { concurrency: 4 }, + ); + return { + entries: results.flatMap((result) => + result.index === null ? [] : Array.from(result.index.plugins), + ), + errors: results.flatMap((result) => + result.error === null + ? [] + : [ + { + sourceId: result.source.id, + url: result.source.url, + message: result.error.message, + }, + ], + ), + }; + }); + + const findVersion: PluginMarketplace["Service"]["findVersion"] = (input) => + Effect.gen(function* () { + const marketplaceUrl = yield* normalizeSourceUrl(input.source.url); + const index = yield* fetchSource(input.source, { refresh: true }); + const entry = index.plugins.find((candidate) => candidate.id === input.pluginId); + if (entry === undefined) { + return yield* managementError("plugin-not-found", "Plugin was not found in source.", { + pluginId: input.pluginId, + sourceId: input.source.id, + }); + } + const version = entry.versions.find((candidate) => candidate.version === input.version); + if (version === undefined) { + return yield* managementError("version-not-found", "Plugin version was not found.", { + pluginId: input.pluginId, + version: input.version, + sourceId: input.source.id, + }); + } + return { + entry, + version, + marketplaceUrl, + tarballUrl: resolveTarballUrl({ tarball: version.tarball, marketplaceUrl }), + }; + }); + + return PluginMarketplace.of({ + normalizeSourceUrl, + fetchSource, + catalog, + findVersion, + }); +}); + +export const layer = Layer.effect(PluginMarketplace, make()); diff --git a/apps/server/src/plugins/PluginMigrator.ts b/apps/server/src/plugins/PluginMigrator.ts index 5ef03ccbe5e..9dbd8db9fde 100644 --- a/apps/server/src/plugins/PluginMigrator.ts +++ b/apps/server/src/plugins/PluginMigrator.ts @@ -80,7 +80,9 @@ export class PluginMigrator extends Context.Service< } >()("t3/plugins/PluginMigrator") {} -const pluginSqlPrefix = (pluginId: string) => `p_${pluginId.replaceAll("-", "_")}_`; +// The DB namespace a plugin's migrations are confined to. Exported so the +// installer's id-collision guard checks the SAME prefix the gate enforces. +export const pluginSqlPrefix = (pluginId: string) => `p_${pluginId.replaceAll("-", "_")}_`; const sqliteMasterSnapshot = (sql: SqlClient.SqlClient) => sql` diff --git a/apps/server/src/plugins/readHttpResponseBytesCapped.ts b/apps/server/src/plugins/readHttpResponseBytesCapped.ts new file mode 100644 index 00000000000..55e1d0e4fd5 --- /dev/null +++ b/apps/server/src/plugins/readHttpResponseBytesCapped.ts @@ -0,0 +1,37 @@ +import { PluginManagementError } from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type { HttpClientResponse } from "effect/unstable/http"; + +const isPluginManagementError = Schema.is(PluginManagementError); + +export const readHttpResponseBytesCapped = (input: { + readonly response: HttpClientResponse.HttpClientResponse; + readonly maxBytes: number; + readonly tooLarge: (observedBytes: number) => PluginManagementError; + readonly readFailed: (cause: unknown) => PluginManagementError; +}) => + input.response.stream.pipe( + Stream.runFoldEffect( + () => ({ chunks: [] as Array, total: 0 }), + (acc, chunk) => { + const total = acc.total + chunk.byteLength; + if (total > input.maxBytes) { + return Effect.fail(input.tooLarge(total)); + } + acc.chunks.push(chunk); + return Effect.succeed({ chunks: acc.chunks, total }); + }, + ), + Effect.map(({ chunks, total }) => { + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + }), + Effect.mapError((cause) => (isPluginManagementError(cause) ? cause : input.readFailed(cause))), + ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 60ee4b4a9af..aadf11cb2cc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -118,6 +118,7 @@ import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; +import * as PluginManagementRpcHandlers from "./plugins/PluginManagementRpcHandlers.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as Data from "effect/Data"; @@ -756,6 +757,27 @@ const buildAppUnderTest = (options?: { }), ), ), + Layer.provide( + Layer.succeed( + PluginManagementRpcHandlers.PluginManagementRpcHandlers, + PluginManagementRpcHandlers.PluginManagementRpcHandlers.of({ + listSources: Effect.succeed({ sources: [] }), + addSource: () => Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + removeSource: () => Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + catalog: () => Effect.succeed({ entries: [], errors: [] }), + beginInstall: () => Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + confirmInstall: () => + Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + abortInstall: () => Effect.void, + setEnabled: () => Effect.void, + uninstall: () => Effect.void, + beginUpgrade: () => Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + confirmUpgrade: () => + Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + checkUpdates: Effect.succeed({ updates: [] }), + }), + ), + ), Layer.provideMerge(PluginHttpRegistry.layer), Layer.provide(PluginLockfileStore.layer), Layer.provide( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d38d73ded58..69d720ef7fd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -37,7 +37,10 @@ import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import { pluginHttpRouteLayer } from "./plugins/PluginHttpRoutes.ts"; import { pluginWebRouteLayer } from "./plugins/PluginWebRoutes.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; +import * as PluginInstaller from "./plugins/PluginInstaller.ts"; import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; +import * as PluginManagementRpcHandlers from "./plugins/PluginManagementRpcHandlers.ts"; +import * as PluginMarketplace from "./plugins/PluginMarketplace.ts"; import * as PluginMigrator from "./plugins/PluginMigrator.ts"; import * as PluginModuleLoader from "./plugins/PluginModuleLoader.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; @@ -335,10 +338,25 @@ const PluginCatalogLayerLive = PluginCatalog.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayerLive), Layer.provideMerge(PluginRuntimeRegistryLayerLive), ); +const PluginMarketplaceLayerLive = PluginMarketplace.layer; +const PluginInstallerLayerLive = PluginInstaller.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginMarketplaceLayerLive), + Layer.provideMerge(PluginHostLayerLive), + Layer.provideMerge(PluginCatalogLayerLive), +); +const PluginManagementRpcHandlersLayerLive = PluginManagementRpcHandlers.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginMarketplaceLayerLive), + Layer.provideMerge(PluginInstallerLayerLive), +); const PluginLayerLive = Layer.mergeAll( PluginHostLayerLive, PluginRpcDispatcherLayerLive, PluginCatalogLayerLive, + PluginMarketplaceLayerLive, + PluginInstallerLayerLive, + PluginManagementRpcHandlersLayerLive, PluginHttpRegistryLayerLive, PluginLockfileStoreLayerLive, ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 352d8eb6bf9..9b154eb0f0c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -18,6 +18,7 @@ import { AuthTerminalOperateScope, AuthAccessReadScope, AuthAccessStreamError, + AuthPluginsManageScope, type AuthAccessStreamEvent, type AuthEnvironmentScope, AuthSessionId, @@ -114,6 +115,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import { PluginCatalog } from "./plugins/PluginCatalog.ts"; +import { PluginManagementRpcHandlers } from "./plugins/PluginManagementRpcHandlers.ts"; import { PluginRpcDispatcher } from "./plugins/PluginRpcDispatcher.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -352,6 +354,18 @@ const RPC_REQUIRED_SCOPE = new Map([ // performs the real per-plugin plugin::read|operate authorization. [PLUGINS_WS_METHODS.call, AuthOrchestrationReadScope], [PLUGINS_WS_METHODS.subscribe, AuthOrchestrationReadScope], + [PLUGINS_WS_METHODS.sourcesList, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.sourcesAdd, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.sourcesRemove, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.catalog, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installBegin, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installConfirm, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installAbort, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.setEnabled, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.uninstall, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.upgradeBegin, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.upgradeConfirm, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.checkUpdates, AuthPluginsManageScope], ]); function toAuthAccessStreamEvent( @@ -445,6 +459,7 @@ const makeWsRpcLayer = ( const relayClient = yield* RelayClient.RelayClient; const pluginCatalog = yield* PluginCatalog; const pluginRpcDispatcher = yield* PluginRpcDispatcher; + const pluginManagement = yield* PluginManagementRpcHandlers; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1215,6 +1230,76 @@ const makeWsRpcLayer = ( "plugin.method": input.method, }, ), + [PLUGINS_WS_METHODS.sourcesList]: (_input) => + observeRpcEffect(PLUGINS_WS_METHODS.sourcesList, pluginManagement.listSources, { + "rpc.aggregate": "plugins", + }), + [PLUGINS_WS_METHODS.sourcesAdd]: (input) => + observeRpcEffect(PLUGINS_WS_METHODS.sourcesAdd, pluginManagement.addSource(input), { + "rpc.aggregate": "plugins", + }), + [PLUGINS_WS_METHODS.sourcesRemove]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.sourcesRemove, + pluginManagement.removeSource(input).pipe(Effect.as({})), + { + "rpc.aggregate": "plugins", + }, + ), + [PLUGINS_WS_METHODS.catalog]: (input) => + observeRpcEffect(PLUGINS_WS_METHODS.catalog, pluginManagement.catalog(input), { + "rpc.aggregate": "plugins", + }), + [PLUGINS_WS_METHODS.installBegin]: (input) => + observeRpcEffect(PLUGINS_WS_METHODS.installBegin, pluginManagement.beginInstall(input), { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + }), + [PLUGINS_WS_METHODS.installConfirm]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.installConfirm, + pluginManagement.confirmInstall(input.stageToken), + { "rpc.aggregate": "plugins" }, + ), + [PLUGINS_WS_METHODS.installAbort]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.installAbort, + pluginManagement.abortInstall(input.stageToken).pipe(Effect.as({})), + { "rpc.aggregate": "plugins" }, + ), + [PLUGINS_WS_METHODS.setEnabled]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.setEnabled, + pluginManagement.setEnabled(input).pipe(Effect.as({})), + { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + }, + ), + [PLUGINS_WS_METHODS.uninstall]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.uninstall, + pluginManagement.uninstall(input).pipe(Effect.as({})), + { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + }, + ), + [PLUGINS_WS_METHODS.upgradeBegin]: (input) => + observeRpcEffect(PLUGINS_WS_METHODS.upgradeBegin, pluginManagement.beginUpgrade(input), { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + }), + [PLUGINS_WS_METHODS.upgradeConfirm]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.upgradeConfirm, + pluginManagement.confirmUpgrade(input.stageToken), + { "rpc.aggregate": "plugins" }, + ), + [PLUGINS_WS_METHODS.checkUpdates]: (_input) => + observeRpcEffect(PLUGINS_WS_METHODS.checkUpdates, pluginManagement.checkUpdates, { + "rpc.aggregate": "plugins", + }), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 75131ee8dd3..d4edfc4a463 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -29,10 +29,17 @@ import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { EnvironmentRpcRequestObserver, + addPluginSource, + beginPluginInstall, callPlugin, + checkPluginUpdates, + confirmPluginInstall, + getPluginCatalog, listPlugins, + listPluginSources, request, runStream, + setPluginEnabled, subscribe, subscribePlugin, } from "./client.ts"; @@ -137,6 +144,111 @@ describe("environment RPC", () => { }), ); + it.effect("calls plugin management helpers with typed payloads", () => + Effect.gen(function* () { + const pluginId = PluginId.make("test-plugin"); + const observedInputs: Array = []; + const client = { + [PLUGINS_WS_METHODS.sourcesList]: () => Effect.succeed({ sources: [] }), + [PLUGINS_WS_METHODS.sourcesAdd]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ + source: { + id: "src-test", + url: "https://example.test/marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }, + }); + }, + [PLUGINS_WS_METHODS.catalog]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ entries: [], errors: [] }); + }, + [PLUGINS_WS_METHODS.installBegin]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ + stageToken: "stage-token", + manifest: { + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + hostApi: "^1.0.0", + capabilities: [], + entries: { web: "web/index.js" }, + }, + capabilityDescriptions: {}, + }); + }, + [PLUGINS_WS_METHODS.installConfirm]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ + plugin: { + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + state: "active", + capabilities: [], + hasWeb: true, + lastError: null, + }, + }); + }, + [PLUGINS_WS_METHODS.setEnabled]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({}); + }, + [PLUGINS_WS_METHODS.checkUpdates]: () => Effect.succeed({ updates: [] }), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const provide = Effect.provideService( + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ); + + expect(yield* listPluginSources().pipe(provide)).toEqual({ sources: [] }); + expect( + yield* addPluginSource({ url: "https://example.test/marketplace.json" }).pipe(provide), + ).toEqual({ + source: { + id: "src-test", + url: "https://example.test/marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }, + }); + expect(yield* getPluginCatalog({ sourceId: "src-test" }).pipe(provide)).toEqual({ + entries: [], + errors: [], + }); + expect( + yield* beginPluginInstall({ sourceId: "src-test", pluginId, version: "1.0.0" }).pipe( + provide, + ), + ).toMatchObject({ stageToken: "stage-token" }); + expect(yield* confirmPluginInstall({ stageToken: "stage-token" }).pipe(provide)).toEqual({ + plugin: { + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + state: "active", + capabilities: [], + hasWeb: true, + lastError: null, + }, + }); + yield* setPluginEnabled({ pluginId, enabled: false }).pipe(provide); + expect(yield* checkPluginUpdates().pipe(provide)).toEqual({ updates: [] }); + + expect(observedInputs).toEqual([ + { url: "https://example.test/marketplace.json" }, + { sourceId: "src-test" }, + { sourceId: "src-test", pluginId, version: "1.0.0" }, + { stageToken: "stage-token" }, + { pluginId, enabled: false }, + ]); + }), + ); + it.effect("calls plugin methods with optional payloads", () => Effect.gen(function* () { const pluginId = PluginId.make("test-plugin"); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c676ddeab7d..652fc09a563 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -2,7 +2,16 @@ import { ORCHESTRATION_WS_METHODS, PLUGINS_WS_METHODS, WS_METHODS, + type PluginCatalogInput, + type PluginInstallBeginInput, + type PluginInstallConfirmInput, type PluginId, + type PluginSetEnabledInput, + type PluginSourcesAddInput, + type PluginSourcesRemoveInput, + type PluginUninstallInput, + type PluginUpgradeBeginInput, + type PluginUpgradeConfirmInput, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -251,6 +260,74 @@ export const listPlugins = Effect.fn("EnvironmentRpc.listPlugins")(function* () return yield* request(PLUGINS_WS_METHODS.list, {}); }); +export const listPluginSources = Effect.fn("EnvironmentRpc.listPluginSources")(function* () { + return yield* request(PLUGINS_WS_METHODS.sourcesList, {}); +}); + +export const addPluginSource = Effect.fn("EnvironmentRpc.addPluginSource")(function* ( + input: PluginSourcesAddInput, +) { + return yield* request(PLUGINS_WS_METHODS.sourcesAdd, input); +}); + +export const removePluginSource = Effect.fn("EnvironmentRpc.removePluginSource")(function* ( + input: PluginSourcesRemoveInput, +) { + return yield* request(PLUGINS_WS_METHODS.sourcesRemove, input); +}); + +export const getPluginCatalog = Effect.fn("EnvironmentRpc.getPluginCatalog")(function* ( + input: PluginCatalogInput = {}, +) { + return yield* request(PLUGINS_WS_METHODS.catalog, input); +}); + +export const beginPluginInstall = Effect.fn("EnvironmentRpc.beginPluginInstall")(function* ( + input: PluginInstallBeginInput, +) { + return yield* request(PLUGINS_WS_METHODS.installBegin, input); +}); + +export const confirmPluginInstall = Effect.fn("EnvironmentRpc.confirmPluginInstall")(function* ( + input: PluginInstallConfirmInput, +) { + return yield* request(PLUGINS_WS_METHODS.installConfirm, input); +}); + +export const abortPluginInstall = Effect.fn("EnvironmentRpc.abortPluginInstall")(function* ( + input: PluginInstallConfirmInput, +) { + return yield* request(PLUGINS_WS_METHODS.installAbort, input); +}); + +export const setPluginEnabled = Effect.fn("EnvironmentRpc.setPluginEnabled")(function* ( + input: PluginSetEnabledInput, +) { + return yield* request(PLUGINS_WS_METHODS.setEnabled, input); +}); + +export const uninstallPlugin = Effect.fn("EnvironmentRpc.uninstallPlugin")(function* ( + input: PluginUninstallInput, +) { + return yield* request(PLUGINS_WS_METHODS.uninstall, input); +}); + +export const beginPluginUpgrade = Effect.fn("EnvironmentRpc.beginPluginUpgrade")(function* ( + input: PluginUpgradeBeginInput, +) { + return yield* request(PLUGINS_WS_METHODS.upgradeBegin, input); +}); + +export const confirmPluginUpgrade = Effect.fn("EnvironmentRpc.confirmPluginUpgrade")(function* ( + input: PluginUpgradeConfirmInput, +) { + return yield* request(PLUGINS_WS_METHODS.upgradeConfirm, input); +}); + +export const checkPluginUpdates = Effect.fn("EnvironmentRpc.checkPluginUpdates")(function* () { + return yield* request(PLUGINS_WS_METHODS.checkUpdates, {}); +}); + export const callPlugin = Effect.fn("EnvironmentRpc.callPlugin")(function* ( pluginId: PluginId, method: string, diff --git a/packages/contracts/src/plugin.test.ts b/packages/contracts/src/plugin.test.ts index 889c5cbf226..45bd75f8d41 100644 --- a/packages/contracts/src/plugin.test.ts +++ b/packages/contracts/src/plugin.test.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { HOST_API_VERSION, PluginLockfile, PluginManifest, hostApiSatisfies } from "./plugin.ts"; +import { + HOST_API_VERSION, + MarketplaceEntry, + PluginInstallStaged, + PluginLockfile, + PluginManifest, + hostApiSatisfies, +} from "./plugin.ts"; const decodeManifest = Schema.decodeUnknownSync(PluginManifest); const decodeLockfile = Schema.decodeUnknownSync(PluginLockfile); const encodeLockfile = Schema.encodeSync(PluginLockfile); +const decodeMarketplaceEntry = Schema.decodeUnknownSync(MarketplaceEntry); +const decodeInstallStaged = Schema.decodeUnknownSync(PluginInstallStaged); const minimalManifest = { id: "test-plugin", @@ -123,3 +132,44 @@ describe("PluginLockfile", () => { expect(encodeLockfile(decoded)).toEqual(decoded); }); }); + +describe("MarketplaceEntry", () => { + it("decodes marketplace plugin versions", () => { + const decoded = decodeMarketplaceEntry({ + id: "test-plugin", + name: "Test Plugin", + description: "Adds test plugin behavior.", + capabilities: ["agents"], + versions: [ + { + version: "1.0.0", + tarball: "https://example.test/plugin.tgz", + sha256: "a".repeat(64), + hostApi: "^1.0.0", + minAppVersion: "0.0.1", + publishedAt: "2026-07-03T00:00:00.000Z", + }, + ], + }); + + expect(decoded.id).toBe("test-plugin"); + expect(decoded.versions[0]?.sha256).toBe("a".repeat(64)); + }); +}); + +describe("PluginInstallStaged", () => { + it("decodes staged install metadata with capability descriptions", () => { + const decoded = decodeInstallStaged({ + stageToken: "token", + manifest: { + ...minimalManifest, + capabilities: ["agents"], + }, + capabilityDescriptions: { + agents: "Run AI agents", + }, + }); + + expect(decoded.capabilityDescriptions.agents).toBe("Run AI agents"); + }); +}); diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts index 8f531894341..4de903526e7 100644 --- a/packages/contracts/src/plugin.ts +++ b/packages/contracts/src/plugin.ts @@ -32,6 +32,7 @@ export type PluginCapability = typeof PluginCapability.Type; const SemverString = TrimmedNonEmptyString.check(Schema.isPattern(SEMVER_PATTERN)); const HostApiRange = TrimmedNonEmptyString.check(Schema.isPattern(HOST_API_RANGE_PATTERN)); const OptionalUrl = Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(2048))); +const Sha256Hex = TrimmedNonEmptyString.check(Schema.isPattern(/^[a-f0-9]{64}$/i)); const RelativeEntryPath = TrimmedNonEmptyString.check( Schema.makeFilter((entryPath) => { @@ -143,6 +144,159 @@ export const PluginListResult = Schema.Struct({ }); export type PluginListResult = typeof PluginListResult.Type; +export const PluginSource = Schema.Struct({ + id: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + addedAt: IsoDateTime, +}); +export type PluginSource = typeof PluginSource.Type; + +export const MarketplaceVersion = Schema.Struct({ + version: SemverString, + tarball: TrimmedNonEmptyString, + sha256: Sha256Hex, + hostApi: HostApiRange, + minAppVersion: Schema.optionalKey(SemverString), + publishedAt: IsoDateTime, +}); +export type MarketplaceVersion = typeof MarketplaceVersion.Type; + +export const MarketplaceEntry = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + description: TrimmedString.check(Schema.isMaxLength(500)), + author: Schema.optionalKey(PluginAuthor), + capabilities: Schema.Array(PluginCapability), + versions: Schema.Array(MarketplaceVersion), +}); +export type MarketplaceEntry = typeof MarketplaceEntry.Type; + +export const PluginInstallStaged = Schema.Struct({ + stageToken: TrimmedNonEmptyString, + manifest: PluginManifest, + capabilityDescriptions: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), +}); +export type PluginInstallStaged = typeof PluginInstallStaged.Type; + +export const PluginSourceError = Schema.Struct({ + sourceId: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, +}); +export type PluginSourceError = typeof PluginSourceError.Type; + +export const PluginSourcesListResult = Schema.Struct({ + sources: Schema.Array(PluginSource), +}); +export type PluginSourcesListResult = typeof PluginSourcesListResult.Type; + +export const PluginSourcesAddInput = Schema.Struct({ + url: TrimmedNonEmptyString, +}); +export type PluginSourcesAddInput = typeof PluginSourcesAddInput.Type; + +export const PluginSourcesAddResult = Schema.Struct({ + source: PluginSource, +}); +export type PluginSourcesAddResult = typeof PluginSourcesAddResult.Type; + +export const PluginSourcesRemoveInput = Schema.Struct({ + sourceId: TrimmedNonEmptyString, +}); +export type PluginSourcesRemoveInput = typeof PluginSourcesRemoveInput.Type; + +export const PluginCatalogInput = Schema.Struct({ + sourceId: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type PluginCatalogInput = typeof PluginCatalogInput.Type; + +export const PluginCatalogResult = Schema.Struct({ + entries: Schema.Array(MarketplaceEntry), + errors: Schema.Array(PluginSourceError), +}); +export type PluginCatalogResult = typeof PluginCatalogResult.Type; + +export const PluginInstallBeginInput = Schema.Struct({ + sourceId: TrimmedNonEmptyString, + pluginId: PluginId, + version: SemverString, +}); +export type PluginInstallBeginInput = typeof PluginInstallBeginInput.Type; + +export const PluginInstallConfirmInput = Schema.Struct({ + stageToken: TrimmedNonEmptyString, +}); +export type PluginInstallConfirmInput = typeof PluginInstallConfirmInput.Type; + +export const PluginInstallConfirmResult = Schema.Struct({ + plugin: PluginInfo, +}); +export type PluginInstallConfirmResult = typeof PluginInstallConfirmResult.Type; + +export const PluginInstallAbortInput = PluginInstallConfirmInput; +export type PluginInstallAbortInput = typeof PluginInstallAbortInput.Type; + +export const PluginSetEnabledInput = Schema.Struct({ + pluginId: PluginId, + enabled: Schema.Boolean, +}); +export type PluginSetEnabledInput = typeof PluginSetEnabledInput.Type; + +export const PluginUninstallInput = Schema.Struct({ + pluginId: PluginId, + removeData: Schema.Boolean, +}); +export type PluginUninstallInput = typeof PluginUninstallInput.Type; + +export const PluginUpgradeBeginInput = Schema.Struct({ + pluginId: PluginId, + version: SemverString, +}); +export type PluginUpgradeBeginInput = typeof PluginUpgradeBeginInput.Type; + +export const PluginUpgradeConfirmInput = PluginInstallConfirmInput; +export type PluginUpgradeConfirmInput = typeof PluginUpgradeConfirmInput.Type; + +export const PluginUpgradeConfirmResult = Schema.Struct({ + plugin: PluginInfo, +}); +export type PluginUpgradeConfirmResult = typeof PluginUpgradeConfirmResult.Type; + +export const PluginUpdateInfo = Schema.Struct({ + pluginId: PluginId, + currentVersion: SemverString, + latestVersion: SemverString, +}); +export type PluginUpdateInfo = typeof PluginUpdateInfo.Type; + +export const PluginCheckUpdatesResult = Schema.Struct({ + updates: Schema.Array(PluginUpdateInfo), +}); +export type PluginCheckUpdatesResult = typeof PluginCheckUpdatesResult.Type; + +export class PluginManagementError extends Schema.TaggedErrorClass()( + "PluginManagementError", + { + code: Schema.Literals([ + "invalid-source", + "source-not-found", + "catalog-fetch-failed", + "plugin-not-found", + "version-not-found", + "download-failed", + "checksum-mismatch", + "extract-failed", + "manifest-invalid", + "stage-not-found", + "filesystem", + "lockfile", + "activation-failed", + ]), + message: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) {} + export const PluginMethodInput = Schema.Struct({ pluginId: PluginId, method: TrimmedNonEmptyString, @@ -154,6 +308,18 @@ export const PLUGINS_WS_METHODS = { list: "plugins.list", call: "plugins.call", subscribe: "plugins.subscribe", + sourcesList: "plugins.sources.list", + sourcesAdd: "plugins.sources.add", + sourcesRemove: "plugins.sources.remove", + catalog: "plugins.catalog", + installBegin: "plugins.install.begin", + installConfirm: "plugins.install.confirm", + installAbort: "plugins.install.abort", + setEnabled: "plugins.setEnabled", + uninstall: "plugins.uninstall", + upgradeBegin: "plugins.upgrade.begin", + upgradeConfirm: "plugins.upgrade.confirm", + checkUpdates: "plugins.checkUpdates", } as const; const LockfileSource = Schema.Struct({ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 03d22aaff9b..e4d81e93ae4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -145,9 +145,26 @@ import { import { VcsError } from "./vcs.ts"; import { PLUGINS_WS_METHODS, + PluginCatalogInput, + PluginCatalogResult, + PluginCheckUpdatesResult, + PluginInstallBeginInput, + PluginInstallConfirmInput, + PluginInstallConfirmResult, + PluginInstallStaged, PluginListResult, + PluginManagementError, PluginMethodInput, PluginRpcError, + PluginSetEnabledInput, + PluginSourcesAddInput, + PluginSourcesAddResult, + PluginSourcesListResult, + PluginSourcesRemoveInput, + PluginUninstallInput, + PluginUpgradeBeginInput, + PluginUpgradeConfirmInput, + PluginUpgradeConfirmResult, } from "./plugin.ts"; export const WS_METHODS = { @@ -228,6 +245,18 @@ export const WS_METHODS = { pluginsList: PLUGINS_WS_METHODS.list, pluginsCall: PLUGINS_WS_METHODS.call, pluginsSubscribe: PLUGINS_WS_METHODS.subscribe, + pluginsSourcesList: PLUGINS_WS_METHODS.sourcesList, + pluginsSourcesAdd: PLUGINS_WS_METHODS.sourcesAdd, + pluginsSourcesRemove: PLUGINS_WS_METHODS.sourcesRemove, + pluginsCatalog: PLUGINS_WS_METHODS.catalog, + pluginsInstallBegin: PLUGINS_WS_METHODS.installBegin, + pluginsInstallConfirm: PLUGINS_WS_METHODS.installConfirm, + pluginsInstallAbort: PLUGINS_WS_METHODS.installAbort, + pluginsSetEnabled: PLUGINS_WS_METHODS.setEnabled, + pluginsUninstall: PLUGINS_WS_METHODS.uninstall, + pluginsUpgradeBegin: PLUGINS_WS_METHODS.upgradeBegin, + pluginsUpgradeConfirm: PLUGINS_WS_METHODS.upgradeConfirm, + pluginsCheckUpdates: PLUGINS_WS_METHODS.checkUpdates, // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", @@ -711,6 +740,78 @@ export const WsPluginsSubscribeRpc = Rpc.make(PLUGINS_WS_METHODS.subscribe, { stream: true, }); +export const WsPluginsSourcesListRpc = Rpc.make(PLUGINS_WS_METHODS.sourcesList, { + payload: Schema.Struct({}), + success: PluginSourcesListResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsSourcesAddRpc = Rpc.make(PLUGINS_WS_METHODS.sourcesAdd, { + payload: PluginSourcesAddInput, + success: PluginSourcesAddResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsSourcesRemoveRpc = Rpc.make(PLUGINS_WS_METHODS.sourcesRemove, { + payload: PluginSourcesRemoveInput, + success: Schema.Struct({}), + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsCatalogRpc = Rpc.make(PLUGINS_WS_METHODS.catalog, { + payload: PluginCatalogInput, + success: PluginCatalogResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsInstallBeginRpc = Rpc.make(PLUGINS_WS_METHODS.installBegin, { + payload: PluginInstallBeginInput, + success: PluginInstallStaged, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsInstallConfirmRpc = Rpc.make(PLUGINS_WS_METHODS.installConfirm, { + payload: PluginInstallConfirmInput, + success: PluginInstallConfirmResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsInstallAbortRpc = Rpc.make(PLUGINS_WS_METHODS.installAbort, { + payload: PluginInstallConfirmInput, + success: Schema.Struct({}), + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsSetEnabledRpc = Rpc.make(PLUGINS_WS_METHODS.setEnabled, { + payload: PluginSetEnabledInput, + success: Schema.Struct({}), + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsUninstallRpc = Rpc.make(PLUGINS_WS_METHODS.uninstall, { + payload: PluginUninstallInput, + success: Schema.Struct({}), + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsUpgradeBeginRpc = Rpc.make(PLUGINS_WS_METHODS.upgradeBegin, { + payload: PluginUpgradeBeginInput, + success: PluginInstallStaged, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsUpgradeConfirmRpc = Rpc.make(PLUGINS_WS_METHODS.upgradeConfirm, { + payload: PluginUpgradeConfirmInput, + success: PluginUpgradeConfirmResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsCheckUpdatesRpc = Rpc.make(PLUGINS_WS_METHODS.checkUpdates, { + payload: Schema.Struct({}), + success: PluginCheckUpdatesResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, @@ -776,6 +877,18 @@ export const WsRpcGroup = RpcGroup.make( WsPluginsListRpc, WsPluginsCallRpc, WsPluginsSubscribeRpc, + WsPluginsSourcesListRpc, + WsPluginsSourcesAddRpc, + WsPluginsSourcesRemoveRpc, + WsPluginsCatalogRpc, + WsPluginsInstallBeginRpc, + WsPluginsInstallConfirmRpc, + WsPluginsInstallAbortRpc, + WsPluginsSetEnabledRpc, + WsPluginsUninstallRpc, + WsPluginsUpgradeBeginRpc, + WsPluginsUpgradeConfirmRpc, + WsPluginsCheckUpdatesRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, From 215b7d64664db888fe6590e5987ce8d5423e9e46 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 09:54:33 -0400 Subject: [PATCH 14/75] Add Plugins marketplace UI, fixture plugin, and authoring docs Completes the plugin system: a user-facing marketplace, an in-repo fixture that exercises the whole stack end to end, and an authoring guide. - Plugins settings page: add/remove sources, browse catalogs, install with a capability-consent dialog (shows the host-owned capability descriptions before confirm; cancel aborts the staged token), and an installed list with enable/disable, uninstall (with remove-data), and check-for-updates/upgrade; pending states prompt a relaunch - Management-command atoms over the existing plugins:manage RPCs; the installed list stays driven by pluginListAtom (live refresh) - fixtures/hello-board: a full-stack fixture plugin (database migration + RPC on the server, a route + sidebar section on the web) with an esbuild build producing a tarball + sha256 + marketplace.json - Server integration test: add file:// source -> catalog -> install -> activate -> RPC round-trip -> asserts the p_hello_board_notes table, with every non-declared capability mocked to fail - docs/plugins.md authoring guide Reviewed by Claude + Grok (SHIP, no blockers): consent flow is abort-safe, the test symlink harness is isolated from the production loader, and the component-rendering fiber fix is preserved. Implemented by GPT-5.5 via codex exec (assembly-line slice 2c-2). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .../HelloBoardFixture.integration.test.ts | 413 +++++++ .../settings/SettingsSidebarNav.test.ts | 2 + .../settings/SettingsSidebarNav.tsx | 2 + .../plugins/PluginsSettings.logic.test.tsx | 153 +++ .../settings/plugins/PluginsSettings.logic.ts | 188 +++ .../settings/plugins/PluginsSettings.tsx | 1024 +++++++++++++++++ apps/web/src/plugins/PluginUiHost.tsx | 20 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.plugins.tsx | 11 + apps/web/src/state/plugins.ts | 123 +- docs/plugins.md | 157 +++ fixtures/hello-board/.gitignore | 2 + fixtures/hello-board/manifest.json | 15 + fixtures/hello-board/package.json | 21 + fixtures/hello-board/scripts/build.mjs | 164 +++ fixtures/hello-board/server/index.ts | 96 ++ fixtures/hello-board/tsconfig.json | 24 + fixtures/hello-board/web/index.tsx | 179 +++ pnpm-workspace.yaml | 1 + 19 files changed, 2609 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/plugins/HelloBoardFixture.integration.test.ts create mode 100644 apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx create mode 100644 apps/web/src/components/settings/plugins/PluginsSettings.logic.ts create mode 100644 apps/web/src/components/settings/plugins/PluginsSettings.tsx create mode 100644 apps/web/src/routes/settings.plugins.tsx create mode 100644 docs/plugins.md create mode 100644 fixtures/hello-board/.gitignore create mode 100644 fixtures/hello-board/manifest.json create mode 100644 fixtures/hello-board/package.json create mode 100644 fixtures/hello-board/scripts/build.mjs create mode 100644 fixtures/hello-board/server/index.ts create mode 100644 fixtures/hello-board/tsconfig.json create mode 100644 fixtures/hello-board/web/index.tsx diff --git a/apps/server/src/plugins/HelloBoardFixture.integration.test.ts b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts new file mode 100644 index 00000000000..e6cf0374155 --- /dev/null +++ b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts @@ -0,0 +1,413 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + AuthStandardClientScopes, + PluginId, + type AuthScope, +} from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { runMigrations } from "../persistence/Migrations.ts"; +import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; +import * as ProjectionThreadMessages from "../persistence/Services/ProjectionThreadMessages.ts"; +import * as ProjectionTurns from "../persistence/Services/ProjectionTurns.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; +import * as PluginCatalogModule from "./PluginCatalog.ts"; +import * as PluginHostModule from "./PluginHost.ts"; +import * as PluginHttpRegistry from "./PluginHttpRegistry.ts"; +import * as PluginInstallerModule from "./PluginInstaller.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import * as PluginManagementRpcHandlersModule from "./PluginManagementRpcHandlers.ts"; +import * as PluginMarketplaceModule from "./PluginMarketplace.ts"; +import * as PluginMigrator from "./PluginMigrator.ts"; +import * as PluginModuleLoaderLayer from "./PluginModuleLoader.ts"; +import * as PluginRpcDispatcherModule from "./PluginRpcDispatcher.ts"; +import * as PluginRuntimeRegistryLayer from "./PluginRuntimeRegistry.ts"; + +const pluginId = PluginId.make("hello-board"); +const fixtureRoot = decodeURIComponent( + new URL("../../../../fixtures/hello-board", import.meta.url).pathname, +); + +class HelloBoardFixtureBuildError extends Data.TaggedError("HelloBoardFixtureBuildError")<{ + readonly stdout: string; + readonly stderr: string; +}> {} + +const unexpectedCapabilityUse = () => + Effect.die(new Error("unexpected capability use in hello-board fixture test")); + +const TestHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + +const PluginRuntimeRegistryLayerLive = PluginRuntimeRegistryLayer.layer; +const PluginHttpRegistryLayerLive = PluginHttpRegistry.layer; +const PluginLockfileStoreLayerLive = PluginLockfileStoreLayer.layer; +const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: unexpectedCapabilityUse, + set: unexpectedCapabilityUse, + create: unexpectedCapabilityUse, + getOrCreateRandom: unexpectedCapabilityUse, + remove: unexpectedCapabilityUse, + }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: unexpectedCapabilityUse(), + getDescriptor: unexpectedCapabilityUse(), + }), + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: unexpectedCapabilityUse, + streamDomainEvents: Stream.empty, + }), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getCommandReadModel: unexpectedCapabilityUse, + getSnapshot: unexpectedCapabilityUse, + getShellSnapshot: unexpectedCapabilityUse, + getArchivedShellSnapshot: unexpectedCapabilityUse, + getSnapshotSequence: unexpectedCapabilityUse, + getCounts: unexpectedCapabilityUse, + getActiveProjectByWorkspaceRoot: unexpectedCapabilityUse, + getProjectShellById: unexpectedCapabilityUse, + getFirstActiveThreadIdByProjectId: unexpectedCapabilityUse, + getThreadOwnerById: unexpectedCapabilityUse, + getThreadCheckpointContext: unexpectedCapabilityUse, + getFullThreadDiffContext: unexpectedCapabilityUse, + getThreadShellById: unexpectedCapabilityUse, + getThreadDetailById: unexpectedCapabilityUse, + }), + Layer.mock(ProjectionTurns.ProjectionTurnRepository)({ + upsertByTurnId: unexpectedCapabilityUse, + replacePendingTurnStart: unexpectedCapabilityUse, + getPendingTurnStartByThreadId: unexpectedCapabilityUse, + deletePendingTurnStartByThreadId: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + getByTurnId: unexpectedCapabilityUse, + clearCheckpointTurnConflict: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + Layer.mock(ProjectionThreadMessages.ProjectionThreadMessageRepository)({ + upsert: unexpectedCapabilityUse, + getByMessageId: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + Layer.mock(ProjectionThreadActivities.ProjectionThreadActivityRepository)({ + upsert: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: unexpectedCapabilityUse, + listInstances: unexpectedCapabilityUse(), + listUnavailable: unexpectedCapabilityUse(), + streamChanges: Stream.empty, + subscribeChanges: unexpectedCapabilityUse(), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: unexpectedCapabilityUse, + status: unexpectedCapabilityUse, + statusDetails: unexpectedCapabilityUse, + statusDetailsLocal: unexpectedCapabilityUse, + statusDetailsRemote: unexpectedCapabilityUse, + prepareCommitContext: unexpectedCapabilityUse, + commit: unexpectedCapabilityUse, + pushCurrentBranch: unexpectedCapabilityUse, + readRangeContext: unexpectedCapabilityUse, + getReviewDiffPreview: unexpectedCapabilityUse, + readConfigValue: unexpectedCapabilityUse, + listRefs: unexpectedCapabilityUse, + pullCurrentBranch: unexpectedCapabilityUse, + createWorktree: unexpectedCapabilityUse, + fetchPullRequestBranch: unexpectedCapabilityUse, + ensureRemote: unexpectedCapabilityUse, + resolvePrimaryRemoteName: unexpectedCapabilityUse, + fetchRemote: unexpectedCapabilityUse, + resolveRemoteTrackingCommit: unexpectedCapabilityUse, + fetchRemoteBranch: unexpectedCapabilityUse, + fetchRemoteTrackingBranch: unexpectedCapabilityUse, + setBranchUpstream: unexpectedCapabilityUse, + removeWorktree: unexpectedCapabilityUse, + renameBranch: unexpectedCapabilityUse, + createRef: unexpectedCapabilityUse, + switchRef: unexpectedCapabilityUse, + initRepo: unexpectedCapabilityUse, + listLocalBranchNames: unexpectedCapabilityUse, + }), + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: unexpectedCapabilityUse, + captureCheckpoint: unexpectedCapabilityUse, + hasCheckpointRef: unexpectedCapabilityUse, + restoreCheckpoint: unexpectedCapabilityUse, + diffCheckpoints: unexpectedCapabilityUse, + deleteCheckpointRefs: unexpectedCapabilityUse, + }), + Layer.mock(TextGeneration.TextGeneration)({ + generateCommitMessage: unexpectedCapabilityUse, + generatePrContent: unexpectedCapabilityUse, + generateBranchName: unexpectedCapabilityUse, + generateThreadTitle: unexpectedCapabilityUse, + }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + get: unexpectedCapabilityUse, + resolveHandle: unexpectedCapabilityUse, + resolve: unexpectedCapabilityUse, + discover: unexpectedCapabilityUse(), + }), + Layer.mock(GitHubCli.GitHubCli)({ + execute: unexpectedCapabilityUse, + listOpenPullRequests: unexpectedCapabilityUse, + getPullRequest: unexpectedCapabilityUse, + getRepositoryCloneUrls: unexpectedCapabilityUse, + createRepository: unexpectedCapabilityUse, + createPullRequest: unexpectedCapabilityUse, + getDefaultBranch: unexpectedCapabilityUse, + checkoutPullRequest: unexpectedCapabilityUse, + }), + Layer.mock(TerminalManager.TerminalManager)({ + open: unexpectedCapabilityUse, + attachStream: unexpectedCapabilityUse, + write: unexpectedCapabilityUse, + resize: unexpectedCapabilityUse, + clear: unexpectedCapabilityUse, + restart: unexpectedCapabilityUse, + close: unexpectedCapabilityUse, + subscribe: unexpectedCapabilityUse, + subscribeMetadata: unexpectedCapabilityUse, + }), +); + +const PluginHostLayerLive = PluginHostModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginModuleLoaderLayer.layer), + Layer.provideMerge(PluginMigrator.layer), + Layer.provideMerge(PluginRuntimeRegistryLayerLive), + Layer.provideMerge(PluginHttpRegistryLayerLive), + Layer.provideMerge(ServerLifecycleEvents.layer), + Layer.provideMerge(PluginHostCapabilityDepsLayerLive), +); +const PluginRpcDispatcherLayerLive = PluginRpcDispatcherModule.layer.pipe( + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginCatalogLayerLive = PluginCatalogModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginMarketplaceLayerLive = PluginMarketplaceModule.layer; +const PluginInstallerLayerLive = PluginInstallerModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginMarketplaceLayerLive), + Layer.provideMerge(PluginHostLayerLive), + Layer.provideMerge(PluginCatalogLayerLive), +); +const PluginManagementRpcHandlersLayerLive = PluginManagementRpcHandlersModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginMarketplaceLayerLive), + Layer.provideMerge(PluginInstallerLayerLive), +); +const PluginLayerLive = Layer.mergeAll( + PluginHostLayerLive, + PluginRpcDispatcherLayerLive, + PluginCatalogLayerLive, + PluginMarketplaceLayerLive, + PluginInstallerLayerLive, + PluginManagementRpcHandlersLayerLive, + PluginHttpRegistryLayerLive, + PluginLockfileStoreLayerLive, +); + +const testLayer = PluginLayerLive.pipe( + Layer.provideMerge(NodeSqliteClient.layerMemory()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-hello-board-fixture-" }), + ), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(NodeServices.layer), +); + +const layer = it.layer(testLayer); + +const session = (scopes: ReadonlyArray) => ({ scopes }); + +const collectText = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +function buildFixture(outDir: string) { + return Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make( + "pnpm", + ["--dir", fixtureRoot, "run", "build", "--", "--out-dir", outDir], + { cwd: fixtureRoot }, + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [collectText(child.stdout), collectText(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) { + return yield* new HelloBoardFixtureBuildError({ stdout, stderr }); + } + }).pipe(Effect.scoped); +} + +function linkHostPluginExternals(pluginsDir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nodeModules = path.join(pluginsDir, "node_modules"); + yield* fs.makeDirectory(path.join(nodeModules, "@t3tools"), { recursive: true }); + const links = [ + { + from: path.resolve(import.meta.dirname, "../../../../packages/plugin-sdk"), + to: path.join(nodeModules, "@t3tools/plugin-sdk"), + }, + { + from: path.resolve(import.meta.dirname, "../../node_modules/effect"), + to: path.join(nodeModules, "effect"), + }, + ]; + for (const link of links) { + yield* fs.remove(link.to, { force: true, recursive: true }); + yield* fs.symlink(link.from, link.to); + } + }); +} + +const withPluginDev = (effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => ({ + pluginDev: process.env.T3_PLUGIN_DEV, + healthyDelay: process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS, + })), + () => + Effect.sync(() => { + process.env.T3_PLUGIN_DEV = "1"; + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + }).pipe(Effect.andThen(effect)), + (previous) => + Effect.sync(() => { + if (previous.pluginDev === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous.pluginDev; + } + if (previous.healthyDelay === undefined) { + delete process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + } else { + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = previous.healthyDelay; + } + }), + ); + +layer("hello-board fixture plugin", (it) => { + it.effect("installs, activates, runs migrations, and round-trips plugin RPC", () => + withPluginDev( + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const handlers = yield* PluginManagementRpcHandlersModule.PluginManagementRpcHandlers; + const catalog = yield* PluginCatalogModule.PluginCatalog; + const dispatcher = yield* PluginRpcDispatcherModule.PluginRpcDispatcher; + const outDir = yield* fs.makeTempDirectoryScoped({ prefix: "hello-board-fixture-" }); + const config = yield* ServerConfig.ServerConfig; + + yield* buildFixture(outDir); + yield* linkHostPluginExternals(config.pluginsDir); + yield* runMigrations({ toMigrationInclusive: 34 }); + + const marketplaceUrl = new URL(`file://${path.join(outDir, "marketplace.json")}`).href; + const source = yield* handlers.addSource({ url: marketplaceUrl }); + const catalogResult = yield* handlers.catalog({ sourceId: source.source.id }); + assert.equal(catalogResult.entries[0]?.id, pluginId); + + const staged = yield* handlers.beginInstall({ + sourceId: source.source.id, + pluginId, + version: "1.0.0", + }); + assert.equal(staged.manifest.id, pluginId); + assert.property(staged.capabilityDescriptions, "database"); + + const confirmed = yield* handlers.confirmInstall(staged.stageToken); + assert.equal(confirmed.plugin.id, pluginId); + + const installed = yield* catalog.list; + assert.deepInclude( + installed.map((plugin) => ({ + id: plugin.id, + state: plugin.state, + hasWeb: plugin.hasWeb, + capabilities: plugin.capabilities, + lastError: plugin.lastError, + })), + { + id: pluginId, + state: "active", + hasWeb: true, + capabilities: ["database"], + lastError: null, + }, + ); + + const added = (yield* dispatcher.call( + pluginId, + "addNote", + { body: "hello from fixture" }, + session(AuthStandardClientScopes), + )) as { readonly body?: unknown }; + assert.equal(added.body, "hello from fixture"); + + const notes = (yield* dispatcher.call( + pluginId, + "listNotes", + {}, + session(AuthStandardClientScopes), + )) as ReadonlyArray<{ readonly body?: unknown }>; + assert.equal(notes[0]?.body, "hello from fixture"); + + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'p_hello_board_notes' + `; + assert.deepEqual(tables, [{ name: "p_hello_board_notes" }]); + }), + ), + ), + ); +}); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.test.ts b/apps/web/src/components/settings/SettingsSidebarNav.test.ts index 5ef5a1f207e..9b608756806 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.test.ts +++ b/apps/web/src/components/settings/SettingsSidebarNav.test.ts @@ -10,6 +10,7 @@ describe("SettingsSidebarNav plugin entries", () => { "/settings/general", "/settings/keybindings", "/settings/providers", + "/settings/plugins", "/settings/source-control", "/settings/connections", "/settings/archived", @@ -33,6 +34,7 @@ describe("SettingsSidebarNav plugin entries", () => { "/settings/general", "/settings/keybindings", "/settings/providers", + "/settings/plugins", "/settings/source-control", "/settings/connections", "/settings/archived", diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 357fb16bf6f..a630f972780 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -29,6 +29,7 @@ export type CoreSettingsSectionPath = | "/settings/general" | "/settings/keybindings" | "/settings/providers" + | "/settings/plugins" | "/settings/source-control" | "/settings/connections" | "/settings/archived"; @@ -42,6 +43,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "General", to: "/settings/general", icon: Settings2Icon }, { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, + { label: "Plugins", to: "/settings/plugins", icon: PuzzleIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx new file mode 100644 index 00000000000..18cec0f453b --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx @@ -0,0 +1,153 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { + PluginId, + type PluginInstallStaged, + type PluginInfo, + type PluginSourcesAddResult, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { InstalledPluginsSection } from "./PluginsSettings"; +import { + ALL_PLUGIN_SOURCES_VALUE, + abortPluginInstallConsentFlow, + addPluginSourceFlow, + beginPluginInstallConsentFlow, + confirmPluginInstallConsentFlow, + effectiveInstallSourceId, + pluginRequiresRelaunch, + removePluginSourceFlow, +} from "./PluginsSettings.logic"; + +const pluginId = PluginId.make("hello-board"); + +const plugin = (overrides: Partial = {}): PluginInfo => ({ + id: pluginId, + name: "Hello Board", + version: "1.0.0", + state: "active", + capabilities: ["database"], + hasWeb: true, + lastError: null, + ...overrides, +}); + +function commandFailure
(message: string): AtomCommandResult { + return AsyncResult.failure(Cause.fail(new Error(message))); +} + +describe("Plugins settings logic", () => { + it("detects relaunch states and selected install source", () => { + expect(pluginRequiresRelaunch(plugin({ state: "pending-remove" }))).toBe(true); + expect(pluginRequiresRelaunch(plugin({ state: "pending-upgrade" }))).toBe(true); + expect(pluginRequiresRelaunch(plugin({ state: "disabled-by-host" }))).toBe(true); + expect(pluginRequiresRelaunch(plugin({ state: "active" }))).toBe(false); + + expect(effectiveInstallSourceId(ALL_PLUGIN_SOURCES_VALUE, [{ id: "src-one" }])).toBe( + "src-one", + ); + expect( + effectiveInstallSourceId(ALL_PLUGIN_SOURCES_VALUE, [{ id: "src-one" }, { id: "src-two" }]), + ).toBeNull(); + expect(effectiveInstallSourceId("src-two", [{ id: "src-one" }])).toBe("src-two"); + }); + + it("surfaces source add and remove server errors from stubbed commands", async () => { + const addSource = vi.fn(async () => + commandFailure("Plugin sources must be HTTPS URLs."), + ); + const removeSource = vi.fn(async () => + commandFailure<{}>("Source is used by an installed plugin."), + ); + + await expect(addPluginSourceFlow({ addSource }, " http://invalid.test ")).resolves.toEqual({ + ok: false, + error: "Plugin sources must be HTTPS URLs.", + }); + expect(addSource).toHaveBeenCalledWith({ url: "http://invalid.test" }); + + await expect(removePluginSourceFlow({ removeSource }, "src-used")).resolves.toEqual({ + ok: false, + error: "Source is used by an installed plugin.", + }); + expect(removeSource).toHaveBeenCalledWith({ sourceId: "src-used" }); + }); + + it("runs install consent begin, confirm, and abort through stubbed commands", async () => { + const staged: PluginInstallStaged = { + stageToken: "stage-1", + manifest: { + id: pluginId, + name: "Hello Board", + version: "1.0.0", + hostApi: "^1.0.0", + capabilities: ["database"], + entries: { server: "server/index.js", web: "web/index.js" }, + }, + capabilityDescriptions: { + database: "Read and write plugin tables in the local database.", + }, + }; + const beginInstall = vi.fn(async () => AsyncResult.success(staged)); + const confirmInstall = vi.fn(async () => AsyncResult.success({ plugin: plugin() })); + const abortInstall = vi.fn(async () => AsyncResult.success({})); + + const begin = await beginPluginInstallConsentFlow( + { beginInstall }, + { sourceId: "src-local", pluginId, version: "1.0.0" }, + ); + expect(begin).toEqual({ ok: true, value: staged }); + expect(beginInstall).toHaveBeenCalledWith({ + sourceId: "src-local", + pluginId, + version: "1.0.0", + }); + + await expect( + confirmPluginInstallConsentFlow({ confirmInstall }, { stageToken: "stage-1" }), + ).resolves.toEqual({ ok: true, value: { plugin: plugin() } }); + expect(confirmInstall).toHaveBeenCalledWith({ stageToken: "stage-1" }); + + await expect( + abortPluginInstallConsentFlow({ abortInstall }, { stageToken: "stage-1" }), + ).resolves.toEqual({ ok: true, value: {} }); + expect(abortInstall).toHaveBeenCalledWith({ stageToken: "stage-1" }); + }); +}); + +describe("InstalledPluginsSection", () => { + it("renders installed plugins with failed and pending state details", () => { + const html = renderToStaticMarkup( + {}} + onCheckUpdates={() => {}} + onBeginUpgrade={() => {}} + onRequestUninstall={() => {}} + />, + ); + + expect(html).toContain("Hello Board"); + expect(html).toContain("Failed"); + expect(html).toContain("activation boom"); + expect(html).toContain("Pending removal"); + expect(html).toContain("Relaunch to apply"); + expect(html).toContain("Database"); + }); +}); diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts b/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts new file mode 100644 index 00000000000..71eb29070c4 --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts @@ -0,0 +1,188 @@ +import type { + MarketplaceVersion, + PluginInstallBeginInput, + PluginInstallConfirmInput, + PluginInstallConfirmResult, + PluginInstallStaged, + PluginInfo, + PluginSourcesAddInput, + PluginSourcesAddResult, + PluginSourcesRemoveInput, + PluginState, +} from "@t3tools/contracts"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult } from "effect/unstable/reactivity"; + +export const ALL_PLUGIN_SOURCES_VALUE = "__all__"; + +const RELAUNCH_STATES = new Set([ + "pending-remove", + "pending-upgrade", + "disabled", + "disabled-by-host", +]); + +export function pluginRequiresRelaunch(plugin: Pick): boolean { + return RELAUNCH_STATES.has(plugin.state); +} + +export function pluginStateLabel(state: PluginState): string { + switch (state) { + case "active": + return "Active"; + case "pending-remove": + return "Pending removal"; + case "pending-upgrade": + return "Pending upgrade"; + case "failed": + return "Failed"; + case "disabled": + return "Disabled"; + case "disabled-by-host": + return "Disabled by host"; + } +} + +export function pluginStateBadgeVariant( + state: PluginState, +): "success" | "warning" | "error" | "secondary" | "outline" { + switch (state) { + case "active": + return "success"; + case "failed": + return "error"; + case "pending-remove": + case "pending-upgrade": + case "disabled-by-host": + return "warning"; + case "disabled": + return "secondary"; + } +} + +export function latestMarketplaceVersion( + versions: ReadonlyArray, +): MarketplaceVersion | null { + return versions[0] ?? null; +} + +export function effectiveInstallSourceId( + selectedSourceId: string, + sources: ReadonlyArray<{ readonly id: string }>, +): string | null { + if (selectedSourceId !== ALL_PLUGIN_SOURCES_VALUE) { + return selectedSourceId; + } + return sources.length === 1 ? sources[0]?.id ?? null : null; +} + +export function humanErrorMessage(error: unknown, fallback = "The operation failed."): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim().length > 0 + ) { + return error.message; + } + return fallback; +} + +export function commandFailureMessage( + result: AtomCommandResult, + fallback = "The operation failed.", +): string | null { + if (AsyncResult.isSuccess(result)) { + return null; + } + return humanErrorMessage(squashAtomCommandFailure(result), fallback); +} + +export type PluginFlowResult = + | { readonly ok: true; readonly value: A } + | { readonly ok: false; readonly error: string }; + +function commandFlowResult( + result: AtomCommandResult, + fallback: string, +): PluginFlowResult { + const failure = commandFailureMessage(result, fallback); + if (failure !== null) { + return { ok: false, error: failure }; + } + if (AsyncResult.isSuccess(result)) { + return { ok: true, value: result.value }; + } + return { ok: false, error: fallback }; +} + +export async function addPluginSourceFlow( + commands: { + readonly addSource: ( + input: PluginSourcesAddInput, + ) => Promise>; + }, + url: string, +): Promise> { + return commandFlowResult( + await commands.addSource({ url: url.trim() }), + "Could not add plugin source.", + ); +} + +export async function removePluginSourceFlow( + commands: { + readonly removeSource: ( + input: PluginSourcesRemoveInput, + ) => Promise>; + }, + sourceId: string, +): Promise> { + return commandFlowResult( + await commands.removeSource({ sourceId }), + "Could not remove plugin source.", + ); +} + +export async function beginPluginInstallConsentFlow( + commands: { + readonly beginInstall: ( + input: PluginInstallBeginInput, + ) => Promise>; + }, + input: PluginInstallBeginInput, +): Promise> { + return commandFlowResult(await commands.beginInstall(input), "Could not stage plugin install."); +} + +export async function confirmPluginInstallConsentFlow( + commands: { + readonly confirmInstall: ( + input: PluginInstallConfirmInput, + ) => Promise>; + }, + input: PluginInstallConfirmInput, +): Promise> { + return commandFlowResult(await commands.confirmInstall(input), "Could not install plugin."); +} + +export async function abortPluginInstallConsentFlow( + commands: { + readonly abortInstall: ( + input: PluginInstallConfirmInput, + ) => Promise>; + }, + input: PluginInstallConfirmInput, +): Promise> { + return commandFlowResult(await commands.abortInstall(input), "Could not cancel plugin install."); +} diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.tsx new file mode 100644 index 00000000000..342bb2b1629 --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginsSettings.tsx @@ -0,0 +1,1024 @@ +import { + AlertTriangleIcon, + BoxIcon, + DatabaseIcon, + DownloadIcon, + PlugIcon, + RefreshCwIcon, + RotateCcwIcon, + ShieldCheckIcon, + Trash2Icon, +} from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; +import type { + MarketplaceEntry, + MarketplaceVersion, + PluginCatalogInput, + PluginCatalogResult, + PluginCheckUpdatesResult, + PluginId, + PluginInfo, + PluginInstallBeginInput, + PluginInstallConfirmInput, + PluginInstallConfirmResult, + PluginInstallStaged, + PluginSetEnabledInput, + PluginSource, + PluginSourcesAddInput, + PluginSourcesAddResult, + PluginSourcesListResult, + PluginSourcesRemoveInput, + PluginUninstallInput, + PluginUpdateInfo, + PluginUpgradeBeginInput, + PluginUpgradeConfirmInput, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; + +import { + abortPluginInstallCommand, + addPluginSourceCommand, + beginPluginInstallCommand, + beginPluginUpgradeCommand, + checkPluginUpdatesCommand, + confirmPluginInstallCommand, + confirmPluginUpgradeCommand, + getPluginCatalogCommand, + listPluginSourcesCommand, + pluginListAtom, + removePluginSourceCommand, + setPluginEnabledCommand, + uninstallPluginCommand, +} from "~/state/plugins"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { Alert, AlertDescription, AlertTitle } from "../../ui/alert"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../../ui/alert-dialog"; +import { Badge } from "../../ui/badge"; +import { Button } from "../../ui/button"; +import { Checkbox } from "../../ui/checkbox"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../../ui/dialog"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../../ui/empty"; +import { Input } from "../../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../../ui/select"; +import { Spinner } from "../../ui/spinner"; +import { Switch } from "../../ui/switch"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "../settingsLayout"; +import { + ALL_PLUGIN_SOURCES_VALUE, + abortPluginInstallConsentFlow, + addPluginSourceFlow, + beginPluginInstallConsentFlow, + commandFailureMessage, + confirmPluginInstallConsentFlow, + effectiveInstallSourceId, + latestMarketplaceVersion, + pluginRequiresRelaunch, + pluginStateBadgeVariant, + pluginStateLabel, + removePluginSourceFlow, +} from "./PluginsSettings.logic"; + +type InstallIntent = "install" | "upgrade"; + +interface StagedPluginAction { + readonly intent: InstallIntent; + readonly staged: PluginInstallStaged; + readonly entryName: string; +} + +interface UninstallTarget { + readonly plugin: PluginInfo; + readonly removeData: boolean; +} + +interface PluginSettingsCommands { + readonly listSources: ( + input: void, + ) => Promise>; + readonly addSource: ( + input: PluginSourcesAddInput, + ) => Promise>; + readonly removeSource: ( + input: PluginSourcesRemoveInput, + ) => Promise>; + readonly catalog: ( + input: PluginCatalogInput | void, + ) => Promise>; + readonly beginInstall: ( + input: PluginInstallBeginInput, + ) => Promise>; + readonly confirmInstall: ( + input: PluginInstallConfirmInput, + ) => Promise>; + readonly abortInstall: ( + input: PluginInstallConfirmInput, + ) => Promise>; + readonly setEnabled: ( + input: PluginSetEnabledInput, + ) => Promise>; + readonly uninstall: ( + input: PluginUninstallInput, + ) => Promise>; + readonly beginUpgrade: ( + input: PluginUpgradeBeginInput, + ) => Promise>; + readonly confirmUpgrade: ( + input: PluginUpgradeConfirmInput, + ) => Promise>; + readonly checkUpdates: ( + input: void, + ) => Promise>; +} + +function usePluginSettingsCommands(): PluginSettingsCommands { + const listSources = useAtomCommand(listPluginSourcesCommand, { reportFailure: false }); + const addSource = useAtomCommand(addPluginSourceCommand, { reportFailure: false }); + const removeSource = useAtomCommand(removePluginSourceCommand, { reportFailure: false }); + const catalog = useAtomCommand(getPluginCatalogCommand, { reportFailure: false }); + const beginInstall = useAtomCommand(beginPluginInstallCommand, { reportFailure: false }); + const confirmInstall = useAtomCommand(confirmPluginInstallCommand, { reportFailure: false }); + const abortInstall = useAtomCommand(abortPluginInstallCommand, { reportFailure: false }); + const setEnabled = useAtomCommand(setPluginEnabledCommand, { reportFailure: false }); + const uninstall = useAtomCommand(uninstallPluginCommand, { reportFailure: false }); + const beginUpgrade = useAtomCommand(beginPluginUpgradeCommand, { reportFailure: false }); + const confirmUpgrade = useAtomCommand(confirmPluginUpgradeCommand, { reportFailure: false }); + const checkUpdates = useAtomCommand(checkPluginUpdatesCommand, { reportFailure: false }); + + return useMemo( + () => ({ + listSources, + addSource, + removeSource, + catalog, + beginInstall, + confirmInstall, + abortInstall, + setEnabled, + uninstall, + beginUpgrade, + confirmUpgrade, + checkUpdates, + }), + [ + abortInstall, + addSource, + beginInstall, + beginUpgrade, + catalog, + checkUpdates, + confirmInstall, + confirmUpgrade, + listSources, + removeSource, + setEnabled, + uninstall, + ], + ); +} + +function updateMapFromResult(result: PluginCheckUpdatesResult): Map { + return new Map(result.updates.map((update) => [update.pluginId, update])); +} + +function capabilityLabel(capability: string): string { + switch (capability) { + case "agents": + return "Agents"; + case "vcs": + return "VCS"; + case "terminals": + return "Terminals"; + case "database": + return "Database"; + case "projections.read": + return "Projections read"; + case "environments.read": + return "Environments read"; + case "secrets": + return "Secrets"; + case "http": + return "HTTP"; + case "sourceControl": + return "Source control"; + case "textGeneration": + return "Text generation"; + default: + return capability; + } +} + +function CapabilityBadges({ capabilities }: { readonly capabilities: ReadonlyArray }) { + if (capabilities.length === 0) { + return No declared capabilities; + } + + return ( +
+ {capabilities.map((capability) => ( + + {capabilityLabel(capability)} + + ))} +
+ ); +} + +function SectionError({ message }: { readonly message: string | null }) { + if (!message) return null; + return ( + + + Plugin operation failed + {message} + + ); +} + +function RelaunchBanner({ state }: { readonly state: PluginInfo["state"] }) { + return ( + + + Relaunch to apply + + This plugin is {pluginStateLabel(state).toLowerCase()}; restart the app to finish applying + the change. + + + ); +} + +function InstalledPluginRow({ + plugin, + update, + busy, + onToggleEnabled, + onCheckUpdates, + onBeginUpgrade, + onRequestUninstall, +}: { + readonly plugin: PluginInfo; + readonly update: PluginUpdateInfo | undefined; + readonly busy: boolean; + readonly onToggleEnabled: (plugin: PluginInfo, enabled: boolean) => void; + readonly onCheckUpdates: () => void; + readonly onBeginUpgrade: (plugin: PluginInfo, version: string) => void; + readonly onRequestUninstall: (plugin: PluginInfo) => void; +}) { + const checked = plugin.state === "active" || plugin.state === "pending-upgrade"; + const canToggle = plugin.state === "active" || plugin.state === "disabled"; + const stateBadge = ( + + {pluginStateLabel(plugin.state)} + + ); + + return ( + + {plugin.name} + {stateBadge} + + } + description={`${plugin.id} · ${plugin.version}`} + status={} + control={ +
+ onToggleEnabled(plugin, enabled)} + /> + + {update ? ( + + ) : null} + +
+ } + > + {plugin.lastError ? ( + + + Activation failed + {plugin.lastError} + + ) : null} + {pluginRequiresRelaunch(plugin) ? : null} +
+ ); +} + +export function InstalledPluginsSection({ + plugins, + updates, + busy, + error, + onToggleEnabled, + onCheckUpdates, + onBeginUpgrade, + onRequestUninstall, +}: { + readonly plugins: ReadonlyArray; + readonly updates: ReadonlyMap; + readonly busy: boolean; + readonly error: string | null; + readonly onToggleEnabled: (plugin: PluginInfo, enabled: boolean) => void; + readonly onCheckUpdates: () => void; + readonly onBeginUpgrade: (plugin: PluginInfo, version: string) => void; + readonly onRequestUninstall: (plugin: PluginInfo) => void; +}) { + return ( + } + headerAction={ + + } + > +
+ +
+ {plugins.length === 0 ? ( + + + + + + No plugins installed + Installed plugins will appear here. + + + ) : ( + plugins.map((plugin) => ( + + )) + )} +
+ ); +} + +function SourcesSection({ + sources, + selectedSourceId, + addUrl, + busy, + error, + onAddUrlChange, + onSelectedSourceChange, + onAddSource, + onRemoveSource, +}: { + readonly sources: ReadonlyArray; + readonly selectedSourceId: string; + readonly addUrl: string; + readonly busy: boolean; + readonly error: string | null; + readonly onAddUrlChange: (value: string) => void; + readonly onSelectedSourceChange: (value: string) => void; + readonly onAddSource: () => void; + readonly onRemoveSource: (sourceId: string) => void; +}) { + const submit = (event: FormEvent) => { + event.preventDefault(); + onAddSource(); + }; + + return ( + }> +
+ +
+ onAddUrlChange(event.currentTarget.value)} + /> + +
+
+ + {sources.length === 0 ? ( +

+ No marketplace sources have been added for this environment. +

+ ) : ( +
+ {sources.map((source) => ( +
+
+

{source.url}

+

{source.id}

+
+ +
+ ))} +
+ )} +
+
+
+ ); +} + +function CatalogEntryRow({ + entry, + version, + sourceReady, + busy, + onInstall, +}: { + readonly entry: MarketplaceEntry; + readonly version: MarketplaceVersion | null; + readonly sourceReady: boolean; + readonly busy: boolean; + readonly onInstall: (entry: MarketplaceEntry, version: MarketplaceVersion) => void; +}) { + return ( + + {entry.name} + {version ? ( + + {version.version} + + ) : null} + + } + description={entry.description || entry.id} + status={ +
+ } + control={ + + } + /> + ); +} + +function BrowseSection({ + catalogEntries, + catalogErrors, + sourceReady, + busy, + error, + onRefreshCatalog, + onInstall, +}: { + readonly catalogEntries: ReadonlyArray; + readonly catalogErrors: ReadonlyArray; + readonly sourceReady: boolean; + readonly busy: boolean; + readonly error: string | null; + readonly onRefreshCatalog: () => void; + readonly onInstall: (entry: MarketplaceEntry, version: MarketplaceVersion) => void; +}) { + return ( + } + headerAction={ + + } + > +
+ + {!sourceReady ? ( + + + Select one source to install + + All sources can be browsed together, but installing requires a concrete source. + + + ) : null} + {catalogErrors.map((message) => ( + + + Source could not be loaded + {message} + + ))} +
+ {catalogEntries.length === 0 ? ( + + + + + + No catalog entries + Add a source or refresh the selected source. + + + ) : ( + catalogEntries.map((entry) => ( + + )) + )} +
+ ); +} + +function ConsentDialog({ + stagedAction, + busy, + error, + onConfirm, + onCancel, +}: { + readonly stagedAction: StagedPluginAction | null; + readonly busy: boolean; + readonly error: string | null; + readonly onConfirm: () => void; + readonly onCancel: () => void; +}) { + const capabilityDescriptions = stagedAction + ? Object.entries(stagedAction.staged.capabilityDescriptions) + : []; + const actionLabel = stagedAction?.intent === "upgrade" ? "Upgrade" : "Install"; + + return ( + !open && onCancel()}> + + + {actionLabel} {stagedAction?.entryName ?? "plugin"} + + Review the capabilities this plugin requests before continuing. + + + + + {capabilityDescriptions.length === 0 ? ( +

This plugin does not request host capabilities.

+ ) : ( +
+ {capabilityDescriptions.map(([capability, description]) => ( +
+

{capabilityLabel(capability)}

+

{description}

+
+ ))} +
+ )} +
+ + }> + Cancel + + + +
+
+ ); +} + +function UninstallDialog({ + target, + busy, + onRemoveDataChange, + onConfirm, + onCancel, +}: { + readonly target: UninstallTarget | null; + readonly busy: boolean; + readonly onRemoveDataChange: (removeData: boolean) => void; + readonly onConfirm: () => void; + readonly onCancel: () => void; +}) { + return ( + !open && onCancel()}> + + + Uninstall {target?.plugin.name ?? "plugin"}? + + The plugin will be removed on the next app restart. + + +
+ +
+ + }> + Cancel + + + +
+
+ ); +} + +export function PluginsSettingsPanel() { + const installedPlugins = useAtomValue(pluginListAtom); + const commands = usePluginSettingsCommands(); + const [sources, setSources] = useState>([]); + const [selectedSourceId, setSelectedSourceId] = useState(ALL_PLUGIN_SOURCES_VALUE); + const [addUrl, setAddUrl] = useState(""); + const [catalogEntries, setCatalogEntries] = useState>([]); + const [catalogErrors, setCatalogErrors] = useState>([]); + const [updates, setUpdates] = useState>(() => new Map()); + const [stagedAction, setStagedAction] = useState(null); + const [uninstallTarget, setUninstallTarget] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [installedError, setInstalledError] = useState(null); + const [sourcesError, setSourcesError] = useState(null); + const [catalogError, setCatalogError] = useState(null); + const [consentError, setConsentError] = useState(null); + + const installSourceId = useMemo( + () => effectiveInstallSourceId(selectedSourceId, sources), + [selectedSourceId, sources], + ); + + const refreshSources = useCallback(async () => { + setBusyKey("sources"); + const result = await commands.listSources(undefined); + setBusyKey(null); + const failure = commandFailureMessage(result, "Could not load plugin sources."); + if (failure) { + setSourcesError(failure); + return; + } + if (AsyncResult.isSuccess(result)) { + setSources(result.value.sources); + setSourcesError(null); + if ( + selectedSourceId !== ALL_PLUGIN_SOURCES_VALUE && + !result.value.sources.some((source) => source.id === selectedSourceId) + ) { + setSelectedSourceId(ALL_PLUGIN_SOURCES_VALUE); + } + } + }, [commands, selectedSourceId]); + + const refreshCatalog = useCallback(async () => { + setBusyKey("catalog"); + const result = await commands.catalog( + selectedSourceId === ALL_PLUGIN_SOURCES_VALUE ? undefined : { sourceId: selectedSourceId }, + ); + setBusyKey(null); + const failure = commandFailureMessage(result, "Could not load the plugin catalog."); + if (failure) { + setCatalogError(failure); + return; + } + if (AsyncResult.isSuccess(result)) { + setCatalogEntries(result.value.entries); + setCatalogErrors(result.value.errors.map((error) => `${error.url}: ${error.message}`)); + setCatalogError(null); + } + }, [commands, selectedSourceId]); + + const checkUpdates = useCallback(async () => { + setBusyKey("updates"); + const result = await commands.checkUpdates(undefined); + setBusyKey(null); + const failure = commandFailureMessage(result, "Could not check plugin updates."); + if (failure) { + setInstalledError(failure); + return; + } + if (AsyncResult.isSuccess(result)) { + setUpdates(updateMapFromResult(result.value)); + setInstalledError(null); + } + }, [commands]); + + useEffect(() => { + void refreshSources(); + }, [refreshSources]); + + useEffect(() => { + void refreshCatalog(); + }, [refreshCatalog]); + + const addSource = useCallback(async () => { + const url = addUrl.trim(); + if (!url) return; + setBusyKey("sources"); + const result = await addPluginSourceFlow(commands, url); + setBusyKey(null); + if (!result.ok) { + setSourcesError(result.error); + return; + } + setAddUrl(""); + setSelectedSourceId(result.value.source.id); + setSourcesError(null); + await refreshSources(); + }, [addUrl, commands, refreshSources]); + + const removeSource = useCallback( + async (sourceId: string) => { + setBusyKey("sources"); + const result = await removePluginSourceFlow(commands, sourceId); + setBusyKey(null); + if (!result.ok) { + setSourcesError(result.error); + return; + } + setSourcesError(null); + await refreshSources(); + await refreshCatalog(); + }, + [commands, refreshCatalog, refreshSources], + ); + + const toggleEnabled = useCallback( + async (plugin: PluginInfo, enabled: boolean) => { + setBusyKey(plugin.id); + const result = await commands.setEnabled({ pluginId: plugin.id, enabled }); + setBusyKey(null); + const failure = commandFailureMessage(result, "Could not update plugin enabled state."); + setInstalledError(failure); + }, + [commands], + ); + + const beginInstall = useCallback( + async (entry: MarketplaceEntry, version: MarketplaceVersion) => { + if (!installSourceId) { + setCatalogError("Choose a concrete source before installing this plugin."); + return; + } + const input: PluginInstallBeginInput = { + sourceId: installSourceId, + pluginId: entry.id, + version: version.version, + }; + setBusyKey(entry.id); + const result = await beginPluginInstallConsentFlow(commands, input); + setBusyKey(null); + if (!result.ok) { + setCatalogError(result.error); + return; + } + setCatalogError(null); + setConsentError(null); + setStagedAction({ intent: "install", staged: result.value, entryName: entry.name }); + }, + [commands, installSourceId], + ); + + const beginUpgrade = useCallback( + async (plugin: PluginInfo, version: string) => { + setBusyKey(plugin.id); + const result = await commands.beginUpgrade({ pluginId: plugin.id, version }); + setBusyKey(null); + const failure = commandFailureMessage(result, "Could not stage plugin upgrade."); + if (failure) { + setInstalledError(failure); + return; + } + if (AsyncResult.isSuccess(result)) { + setInstalledError(null); + setConsentError(null); + setStagedAction({ intent: "upgrade", staged: result.value, entryName: plugin.name }); + } + }, + [commands], + ); + + const cancelStaged = useCallback(async () => { + const staged = stagedAction; + setStagedAction(null); + setConsentError(null); + if (!staged) return; + if (staged.intent === "install") { + await abortPluginInstallConsentFlow(commands, { stageToken: staged.staged.stageToken }); + return; + } + await commands.abortInstall({ stageToken: staged.staged.stageToken }); + }, [commands, stagedAction]); + + const confirmStaged = useCallback(async () => { + const staged = stagedAction; + if (!staged) return; + setBusyKey("consent"); + if (staged.intent === "install") { + const result = await confirmPluginInstallConsentFlow(commands, { + stageToken: staged.staged.stageToken, + }); + setBusyKey(null); + if (!result.ok) { + setConsentError(result.error); + return; + } + setStagedAction(null); + setConsentError(null); + void checkUpdates(); + return; + } + + const result = await commands.confirmUpgrade({ stageToken: staged.staged.stageToken }); + setBusyKey(null); + const failure = commandFailureMessage(result, "Could not upgrade plugin."); + if (failure) { + setConsentError(failure); + return; + } + setStagedAction(null); + setConsentError(null); + void checkUpdates(); + }, [checkUpdates, commands, stagedAction]); + + const confirmUninstall = useCallback(async () => { + const target = uninstallTarget; + if (!target) return; + setBusyKey(target.plugin.id); + const result = await commands.uninstall({ + pluginId: target.plugin.id, + removeData: target.removeData, + }); + setBusyKey(null); + const failure = commandFailureMessage(result, "Could not uninstall plugin."); + if (failure) { + setInstalledError(failure); + return; + } + setInstalledError(null); + setUninstallTarget(null); + }, [commands, uninstallTarget]); + + const isBusy = busyKey !== null; + + return ( + + void toggleEnabled(plugin, enabled)} + onCheckUpdates={() => void checkUpdates()} + onBeginUpgrade={(plugin, version) => void beginUpgrade(plugin, version)} + onRequestUninstall={(plugin) => setUninstallTarget({ plugin, removeData: false })} + /> + + void addSource()} + onRemoveSource={(sourceId) => void removeSource(sourceId)} + /> + + void refreshCatalog()} + onInstall={(entry, version) => void beginInstall(entry, version)} + /> + + void confirmStaged()} + onCancel={() => void cancelStaged()} + /> + + + setUninstallTarget((current) => (current ? { ...current, removeData } : current)) + } + onConfirm={() => void confirmUninstall()} + onCancel={() => setUninstallTarget(null)} + /> + + ); +} diff --git a/apps/web/src/plugins/PluginUiHost.tsx b/apps/web/src/plugins/PluginUiHost.tsx index 352629f3365..4ba8ada2192 100644 --- a/apps/web/src/plugins/PluginUiHost.tsx +++ b/apps/web/src/plugins/PluginUiHost.tsx @@ -1,9 +1,11 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import type { PluginCommandRegistration, - PluginRouteRegistration, - PluginSettingsPageRegistration, + PluginComponent, + PluginRouteComponentProps, + PluginSettingsComponentProps, PluginSidebarSectionRegistration, + PluginSidebarSectionRenderProps, PluginUiContext, PluginWebDefinition, PluginWebRpc, @@ -15,16 +17,24 @@ import { Component, useEffect, useRef, type ErrorInfo, type ReactNode } from "re import { pluginListAtom, pluginRpc } from "../state/plugins"; import { whenPluginHostReady } from "./hostSingletons"; -export interface RegisteredPluginRoute extends PluginRouteRegistration { +export interface RegisteredPluginRoute { readonly pluginId: PluginId; + readonly path: string; + readonly component: PluginComponent; } -export interface RegisteredPluginSidebarSection extends PluginSidebarSectionRegistration { +export interface RegisteredPluginSidebarSection { readonly pluginId: PluginId; + readonly id: string; + readonly title: string; + readonly render: (props: PluginSidebarSectionRenderProps) => unknown; } -export interface RegisteredPluginSettingsPage extends PluginSettingsPageRegistration { +export interface RegisteredPluginSettingsPage { readonly pluginId: PluginId; + readonly id: string; + readonly title: string; + readonly component: PluginComponent; } export interface RegisteredPluginCommand extends PluginCommandRegistration { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f5c54de403a..46cfe75a427 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsPluginsRouteImport } from './routes/settings.plugins' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' @@ -54,6 +55,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -112,6 +118,7 @@ export interface FileRoutesByFullPath { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/plugins': typeof SettingsPluginsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -127,6 +134,7 @@ export interface FileRoutesByTo { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/plugins': typeof SettingsPluginsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -145,6 +153,7 @@ export interface FileRoutesById { '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/plugins': typeof SettingsPluginsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -164,6 +173,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -179,6 +189,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/' @@ -196,6 +207,7 @@ export interface FileRouteTypes { | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -254,6 +266,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/plugins': { + id: '/settings/plugins' + path: '/plugins' + fullPath: '/settings/plugins' + preLoaderRoute: typeof SettingsPluginsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -343,6 +362,7 @@ interface SettingsRouteChildren { SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsPluginsRoute: typeof SettingsPluginsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -354,6 +374,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsPluginsRoute: SettingsPluginsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/settings.plugins.tsx b/apps/web/src/routes/settings.plugins.tsx new file mode 100644 index 00000000000..b727d0fda74 --- /dev/null +++ b/apps/web/src/routes/settings.plugins.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { PluginsSettingsPanel } from "../components/settings/plugins/PluginsSettings"; + +function SettingsPluginsRoute() { + return ; +} + +export const Route = createFileRoute("/settings/plugins")({ + component: SettingsPluginsRoute, +}); diff --git a/apps/web/src/state/plugins.ts b/apps/web/src/state/plugins.ts index fcd14231f47..388db48096c 100644 --- a/apps/web/src/state/plugins.ts +++ b/apps/web/src/state/plugins.ts @@ -1,11 +1,37 @@ import { + type PluginCatalogInput, type PluginId, type PluginInfo, + type PluginInstallBeginInput, + type PluginInstallConfirmInput, + type PluginSetEnabledInput, + type PluginSourcesAddInput, + type PluginSourcesRemoveInput, + type PluginUninstallInput, + type PluginUpgradeBeginInput, + type PluginUpgradeConfirmInput, type ServerLifecycleStreamEvent, WS_METHODS, } from "@t3tools/contracts"; -import { callPlugin, listPlugins, subscribePlugin } from "@t3tools/client-runtime/rpc"; import { + abortPluginInstall, + addPluginSource, + beginPluginInstall, + beginPluginUpgrade, + callPlugin, + checkPluginUpdates, + confirmPluginInstall, + confirmPluginUpgrade, + getPluginCatalog, + listPluginSources, + listPlugins, + removePluginSource, + setPluginEnabled, + subscribePlugin, + uninstallPlugin, +} from "@t3tools/client-runtime/rpc"; +import { + createRuntimeCommand, createEnvironmentRpcSubscriptionAtomFamily, executeAtomQuery, runInEnvironment, @@ -16,7 +42,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Cause from "effect/Cause"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -25,6 +51,27 @@ import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; const EMPTY_PLUGIN_LIST: ReadonlyArray = Object.freeze([]); +export class PluginManagementConnectionError extends Error { + override readonly name = "PluginManagementConnectionError"; + + constructor() { + super("Plugin management is unavailable before the primary environment is connected."); + } +} + +function runPrimaryPluginManagement( + registry: AtomRegistry.AtomRegistry, + effect: Effect.Effect, +) { + return Effect.gen(function* () { + const environmentId = registry.get(primaryEnvironmentIdAtom); + if (environmentId === null) { + return yield* Effect.fail(new PluginManagementConnectionError()); + } + return yield* runInEnvironment(environmentId as never, effect); + }); +} + export function makePluginListStream( lifecycleEvents: Stream.Stream, loadPlugins: Effect.Effect, E, R>, @@ -135,4 +182,76 @@ export function pluginRpc(pluginId: PluginId, dependencies: PluginRpcDependencie }; } +export const listPluginSourcesCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:sources:list", + execute: (_input: void, registry) => + runPrimaryPluginManagement(registry, listPluginSources()), +}); + +export const addPluginSourceCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:sources:add", + execute: (input: PluginSourcesAddInput, registry) => + runPrimaryPluginManagement(registry, addPluginSource(input)), +}); + +export const removePluginSourceCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:sources:remove", + execute: (input: PluginSourcesRemoveInput, registry) => + runPrimaryPluginManagement(registry, removePluginSource(input)), +}); + +export const getPluginCatalogCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:catalog", + execute: (input: PluginCatalogInput | void, registry) => + runPrimaryPluginManagement(registry, getPluginCatalog(input ?? {})), +}); + +export const beginPluginInstallCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:install:begin", + execute: (input: PluginInstallBeginInput, registry) => + runPrimaryPluginManagement(registry, beginPluginInstall(input)), +}); + +export const confirmPluginInstallCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:install:confirm", + execute: (input: PluginInstallConfirmInput, registry) => + runPrimaryPluginManagement(registry, confirmPluginInstall(input)), +}); + +export const abortPluginInstallCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:install:abort", + execute: (input: PluginInstallConfirmInput, registry) => + runPrimaryPluginManagement(registry, abortPluginInstall(input)), +}); + +export const setPluginEnabledCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:set-enabled", + execute: (input: PluginSetEnabledInput, registry) => + runPrimaryPluginManagement(registry, setPluginEnabled(input)), +}); + +export const uninstallPluginCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:uninstall", + execute: (input: PluginUninstallInput, registry) => + runPrimaryPluginManagement(registry, uninstallPlugin(input)), +}); + +export const beginPluginUpgradeCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:upgrade:begin", + execute: (input: PluginUpgradeBeginInput, registry) => + runPrimaryPluginManagement(registry, beginPluginUpgrade(input)), +}); + +export const confirmPluginUpgradeCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:upgrade:confirm", + execute: (input: PluginUpgradeConfirmInput, registry) => + runPrimaryPluginManagement(registry, confirmPluginUpgrade(input)), +}); + +export const checkPluginUpdatesCommand = createRuntimeCommand(connectionAtomRuntime, { + label: "web-plugins:updates:check", + execute: (_input: void, registry) => + runPrimaryPluginManagement(registry, checkPluginUpdates()), +}); + export { WS_METHODS }; diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000000..cf95ab5a277 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,157 @@ +# Plugins + +T3 plugins are full-trust local extensions packaged as a tarball with a `manifest.json` plus +optional server and web entry bundles. Users add marketplace sources, review requested +capabilities, and install plugins through Settings -> Plugins. + +## Manifest + +```json +{ + "id": "hello-board", + "name": "Hello Board", + "version": "1.0.0", + "description": "Stores local notes.", + "author": { "name": "T3 Tools", "url": "https://example.com" }, + "homepage": "https://example.com/hello-board", + "license": "MIT", + "hostApi": "^1.0.0", + "minAppVersion": "0.0.28", + "capabilities": ["database"], + "entries": { + "server": "server/index.js", + "web": "web/index.js" + } +} +``` + +- `id` must match `[a-z][a-z0-9-]{1,40}`. +- `version` is strict semver. +- `hostApi` currently targets the SDK host API version `1.0.0` and accepts `^`, `~`, or exact + ranges. +- `entries` must include at least one of `server` or `web`; paths are relative and may not escape + the plugin directory. +- Web-only plugins may not declare server capabilities. + +## Server Entry + +Server plugins default-export `definePlugin({ register })` from `@t3tools/plugin-sdk`. + +```ts +import { definePlugin } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; + +export default definePlugin({ + register: (hostApi) => + Effect.gen(function* () { + const database = yield* hostApi.database; + return { + rpc: [ + { + method: "listNotes", + scope: "read", + handler: () => database.execute("SELECT * FROM p_hello_board_notes"), + }, + ], + }; + }), +}); +``` + +Registrations may provide `migrations`, `rpc`, `streams`, `http`, `services`, and `recover`. +RPC and stream methods declare `scope: "read" | "operate"`; the host enforces plugin auth scopes +before dispatch. + +## Web Entry + +Web plugins default-export `defineWebPlugin({ register })` from `@t3tools/plugin-sdk-web`. +The web context can register routes, sidebar sections, settings pages, and commands. + +```ts +import { Button, defineWebPlugin } from "@t3tools/plugin-sdk-web"; + +export default defineWebPlugin({ + register: (ctx) => { + ctx.registerRoute({ + path: "notes", + component: () => , + }); + }, +}); +``` + +Web bundles must treat these as runtime externals: `react`, `react-dom`, `@effect/atom-react`, +`effect`, and `@t3tools/plugin-sdk-web`. Import `effect` from the bare barrel only in web bundles: +`import { Effect } from "effect"`. Do not import web-side effect subpaths such as +`effect/Effect`; browser import maps only enumerate the bare specifier. See +`packages/plugin-sdk-web/README.md`. + +Tailwind utilities are emitted by scanning the host app, not separately-built plugins. Prefer +SDK-exported host UI components, host CSS variables, or plugin-local compiled CSS. + +## Capabilities + +- `agents`: create and operate plugin-owned agent threads. +- `vcs`: run trusted VCS operations on absolute repository or worktree paths. +- `terminals`: create and control plugin-owned terminal sessions. +- `database`: run trusted SQL through the shared database client. +- `projections.read`: read thread, turn, message, activity, and shell projections. +- `environments.read`: read environment descriptors and projected environment state. +- `secrets`: store plugin-prefixed secrets. +- `http`: register plugin HTTP routes under `/hooks/plugins/`. +- `sourceControl`: use configured source-control providers. +- `textGeneration`: call host text-generation helpers. + +Capabilities are full-trust grants. Consent text is shown during install, but plugins execute +locally with the capabilities they declare. + +## Database + +Plugin tables must be namespaced as `p__*`. The migration +gate enforces this namespace for tables, indexes, triggers, and views, and rejects migrations that +drop or alter objects outside the plugin namespace, create temp objects, or attach other databases. +Runtime SQL is not sandboxed; only migrations are gated. + +## Packaging + +A marketplace source is a JSON file: + +```json +{ + "plugins": [ + { + "id": "hello-board", + "name": "Hello Board", + "description": "Stores local notes.", + "author": { "name": "T3 Tools" }, + "capabilities": ["database"], + "versions": [ + { + "version": "1.0.0", + "tarball": "https://example.com/hello-board-1.0.0.tgz", + "sha256": "<64 hex chars>", + "hostApi": "^1.0.0", + "publishedAt": "2026-07-03T00:00:00.000Z" + } + ] + } + ] +} +``` + +The tarball must include `manifest.json` and the entry files referenced by the manifest. The host +downloads the tarball, verifies `sha256`, extracts it into the plugin store, validates the +manifest, runs migrations, and activates the plugin. + +For local development only, `T3_PLUGIN_DEV=1` enables `file://` marketplace sources and tarballs. +The in-repo fixture at `fixtures/hello-board` builds a local marketplace with: + +```sh +pnpm --dir fixtures/hello-board run build +``` + +## Host API Versioning + +The SDK exports `HOST_API_VERSION`, currently `1.0.0`. If an installed plugin's `hostApi` range is +not satisfied, the host marks it `disabled-by-host` and skips activation until a compatible version +is installed. diff --git a/fixtures/hello-board/.gitignore b/fixtures/hello-board/.gitignore new file mode 100644 index 00000000000..5f97303c145 --- /dev/null +++ b/fixtures/hello-board/.gitignore @@ -0,0 +1,2 @@ +dist/ +.tmp-test-build/ diff --git a/fixtures/hello-board/manifest.json b/fixtures/hello-board/manifest.json new file mode 100644 index 00000000000..43705c15956 --- /dev/null +++ b/fixtures/hello-board/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "hello-board", + "name": "Hello Board", + "version": "1.0.0", + "description": "Fixture plugin that stores and displays local notes.", + "author": { + "name": "T3 Tools" + }, + "hostApi": "^1.0.0", + "capabilities": ["database"], + "entries": { + "server": "server/index.js", + "web": "web/index.js" + } +} diff --git a/fixtures/hello-board/package.json b/fixtures/hello-board/package.json new file mode 100644 index 00000000000..c78c474ac68 --- /dev/null +++ b/fixtures/hello-board/package.json @@ -0,0 +1,21 @@ +{ + "name": "@t3tools/fixture-hello-board", + "private": true, + "type": "module", + "scripts": { + "build": "node scripts/build.mjs", + "typecheck": "tsgo --noEmit", + "test": "pnpm run build -- --out-dir .tmp-test-build" + }, + "dependencies": { + "@effect/atom-react": "catalog:", + "@t3tools/plugin-sdk": "workspace:*", + "@t3tools/plugin-sdk-web": "workspace:*", + "effect": "catalog:", + "react": "19.2.6" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/react": "~19.2.14" + } +} diff --git a/fixtures/hello-board/scripts/build.mjs b/fixtures/hello-board/scripts/build.mjs new file mode 100644 index 00000000000..71f77be09d5 --- /dev/null +++ b/fixtures/hello-board/scripts/build.mjs @@ -0,0 +1,164 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { gzipSync } from "node:zlib"; +import { spawnSync } from "node:child_process"; + +const fixtureRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(fixtureRoot, "../.."); + +function outDirFromArgs(argv) { + const direct = argv.find((arg) => arg.startsWith("--out-dir=")); + if (direct) return resolve(fixtureRoot, direct.slice("--out-dir=".length)); + const index = argv.indexOf("--out-dir"); + if (index >= 0 && argv[index + 1]) return resolve(fixtureRoot, argv[index + 1]); + return join(fixtureRoot, "dist"); +} + +const outDir = outDirFromArgs(process.argv.slice(2)); +const packageDir = join(outDir, "package"); +const manifest = JSON.parse(readFileSync(join(fixtureRoot, "manifest.json"), "utf8")); +const tarballName = `${manifest.id}-${manifest.version}.tgz`; +const tarballPath = join(outDir, tarballName); +const shaPath = `${tarballPath}.sha256`; +const marketplacePath = join(outDir, "marketplace.json"); + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: repoRoot, + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`); + } +} + +function bundle(input, output, platform, externals) { + run("pnpm", [ + "exec", + "esbuild", + input, + "--bundle", + "--format=esm", + `--platform=${platform}`, + "--target=es2022", + ...externals.flatMap((external) => [`--external:${external}`]), + `--outfile=${output}`, + ]); +} + +function writeString(buffer, offset, length, value) { + buffer.write(value, offset, length, "utf8"); +} + +function writeOctal(buffer, offset, length, value) { + writeString(buffer, offset, length, value.toString(8).padStart(length - 1, "0")); +} + +function tarChecksum(header) { + let sum = 0; + for (const byte of header) sum += byte; + return sum; +} + +function tarEntry(name, body) { + if (Buffer.byteLength(name) > 100) { + throw new Error(`Tar entry name is too long: ${name}`); + } + const header = Buffer.alloc(512); + writeString(header, 0, 100, name); + writeOctal(header, 100, 8, 0o644); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, body.byteLength); + writeOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + writeString(header, 156, 1, "0"); + writeString(header, 257, 6, "ustar"); + writeString(header, 263, 2, "00"); + writeOctal(header, 148, 8, tarChecksum(header)); + + const paddingLength = Math.ceil(body.byteLength / 512) * 512 - body.byteLength; + return Buffer.concat([header, body, Buffer.alloc(paddingLength)]); +} + +function tar(entries) { + return Buffer.concat([ + ...entries.map((entry) => tarEntry(entry.name, entry.body)), + Buffer.alloc(1024), + ]); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(join(packageDir, "server"), { recursive: true }); +mkdirSync(join(packageDir, "web"), { recursive: true }); + +writeFileSync(join(packageDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + +bundle( + join(fixtureRoot, "server/index.ts"), + join(packageDir, "server/index.js"), + "node", + ["@t3tools/plugin-sdk", "effect", "effect/*"], +); +bundle( + join(fixtureRoot, "web/index.tsx"), + join(packageDir, "web/index.js"), + "browser", + [ + "@effect/atom-react", + "@t3tools/plugin-sdk-web", + "effect", + "react", + "react/*", + "react-dom", + "react-dom/*", + ], +); + +const archive = gzipSync( + tar([ + { name: "manifest.json", body: readFileSync(join(packageDir, "manifest.json")) }, + { name: "server/index.js", body: readFileSync(join(packageDir, "server/index.js")) }, + { name: "web/index.js", body: readFileSync(join(packageDir, "web/index.js")) }, + ]), + { mtime: 0 }, +); +writeFileSync(tarballPath, archive); + +const sha256 = createHash("sha256").update(archive).digest("hex"); +writeFileSync(shaPath, `${sha256} ${tarballName}\n`); +writeFileSync( + marketplacePath, + `${JSON.stringify( + { + plugins: [ + { + id: manifest.id, + name: manifest.name, + description: manifest.description, + author: manifest.author, + capabilities: manifest.capabilities, + versions: [ + { + version: manifest.version, + tarball: pathToFileURL(tarballPath).href, + sha256, + hostApi: manifest.hostApi, + publishedAt: "2026-07-03T00:00:00.000Z", + }, + ], + }, + ], + }, + null, + 2, + )}\n`, +); + +console.log(`tarball=${tarballPath}`); +console.log(`sha256=${sha256}`); +console.log(`sha256File=${shaPath}`); +console.log(`marketplace=${marketplacePath}`); diff --git a/fixtures/hello-board/server/index.ts b/fixtures/hello-board/server/index.ts new file mode 100644 index 00000000000..bdb1d6fa23e --- /dev/null +++ b/fixtures/hello-board/server/index.ts @@ -0,0 +1,96 @@ +import { definePlugin } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +class HelloBoardPluginError extends Error { + readonly _tag = "HelloBoardPluginError"; +} + +function toPluginError(error: unknown): HelloBoardPluginError { + if (error instanceof Error) { + return new HelloBoardPluginError(error.message, { cause: error }); + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) { + return new HelloBoardPluginError(error.message, { cause: error }); + } + return new HelloBoardPluginError("hello-board plugin operation failed", { cause: error }); +} + +function noteBodyFromPayload(payload: unknown): Effect.Effect { + if ( + typeof payload === "object" && + payload !== null && + "body" in payload && + typeof payload.body === "string" + ) { + const body = payload.body.trim(); + if (body.length > 0 && body.length <= 500) { + return Effect.succeed(body); + } + } + + return Effect.fail( + new HelloBoardPluginError("body must be a non-empty string no longer than 500 characters"), + ); +} + +export default definePlugin({ + register: (hostApi) => + Effect.gen(function* () { + const database = yield* Effect.mapError(hostApi.database, toPluginError); + + return { + migrations: [ + { + version: 1, + name: "Create hello board notes", + up: Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE p_hello_board_notes ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + body TEXT NOT NULL CHECK (length(body) > 0 AND length(body) <= 500), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) + `; + }).pipe(Effect.mapError(toPluginError)), + }, + ], + rpc: [ + { + method: "listNotes", + scope: "read" as const, + handler: () => + database.execute(` + SELECT id, body, created_at AS createdAt + FROM p_hello_board_notes + ORDER BY created_at DESC, id DESC + LIMIT 50 + `), + }, + { + method: "addNote", + scope: "operate" as const, + handler: (payload) => + Effect.gen(function* () { + const body = yield* noteBodyFromPayload(payload); + const rows = yield* database.execute( + ` + INSERT INTO p_hello_board_notes (body) + VALUES (?) + RETURNING id, body, created_at AS createdAt + `, + [body], + ); + return rows[0] ?? { body }; + }), + }, + ], + }; + }), +}); diff --git a/fixtures/hello-board/tsconfig.json b/fixtures/hello-board/tsconfig.json new file mode 100644 index 00000000000..d9dea21edbf --- /dev/null +++ b/fixtures/hello-board/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node"], + "module": "Preserve", + "moduleResolution": "Bundler", + "erasableSyntaxOnly": false, + "verbatimModuleSyntax": false, + "paths": { + "~/*": ["../../apps/web/src/*"] + }, + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "globalConsole": "off" + } + } + ] + }, + "include": ["server", "web", "../../apps/web/src/*.d.ts"] +} diff --git a/fixtures/hello-board/web/index.tsx b/fixtures/hello-board/web/index.tsx new file mode 100644 index 00000000000..6fdafc61690 --- /dev/null +++ b/fixtures/hello-board/web/index.tsx @@ -0,0 +1,179 @@ +import { Button, defineWebPlugin, Input, type PluginWebRpc } from "@t3tools/plugin-sdk-web"; +import type { CSSProperties } from "react"; +import { useCallback, useEffect, useState } from "react"; + +interface Note { + readonly id: string; + readonly body: string; + readonly createdAt: string; +} + +function isNote(value: unknown): value is Note { + return ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + "body" in value && + typeof value.body === "string" && + "createdAt" in value && + typeof value.createdAt === "string" + ); +} + +function parseNotes(value: unknown): ReadonlyArray { + return Array.isArray(value) ? value.filter(isNote) : []; +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Plugin RPC failed."; +} + +const shellStyle = { + minHeight: "100%", + padding: "24px", + color: "var(--foreground)", + background: "var(--background)", +} satisfies CSSProperties; + +const panelStyle = { + display: "flex", + maxWidth: "640px", + flexDirection: "column", + gap: "16px", +} satisfies CSSProperties; + +const noteStyle = { + border: "1px solid var(--border)", + borderRadius: "8px", + padding: "12px", + background: "var(--card)", +} satisfies CSSProperties; + +function HelloBoardNotes({ rpc }: { readonly rpc: PluginWebRpc }) { + const [notes, setNotes] = useState>([]); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const loadNotes = useCallback(async () => { + setLoading(true); + try { + setNotes(parseNotes(await rpc.call("listNotes"))); + setError(null); + } catch (cause) { + setError(errorMessage(cause)); + } finally { + setLoading(false); + } + }, [rpc]); + + useEffect(() => { + void loadNotes(); + }, [loadNotes]); + + const addNote = useCallback(async () => { + const trimmed = body.trim(); + if (!trimmed) return; + setLoading(true); + try { + await rpc.call("addNote", { body: trimmed }); + setBody(""); + setNotes(parseNotes(await rpc.call("listNotes"))); + setError(null); + } catch (cause) { + setError(errorMessage(cause)); + } finally { + setLoading(false); + } + }, [body, rpc]); + + return ( +
+
+
+

Hello Board

+

+ Fixture plugin notes stored in the local plugin database table. +

+
+
{ + event.preventDefault(); + void addNote(); + }} + > + setBody(event.currentTarget.value)} + /> + +
+ {error ? ( +
+ {error} +
+ ) : null} +
+ {notes.length === 0 ? ( +

+ No notes yet. +

+ ) : ( + notes.map((note) => ( +
+

{note.body}

+ +
+ )) + )} +
+
+
+ ); +} + +export default defineWebPlugin({ + register: (ctx) => { + ctx.registerRoute({ + path: "notes", + component: () => , + }); + ctx.registerSidebarSection({ + id: "hello-board", + title: "Hello Board", + render: ({ routeBasePath }) => ( + + Notes + + ), + }); + }, +}); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 25e6fd2889e..9ff68983803 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - apps/* + - fixtures/* - infra/* - oxlint-plugin-t3code - packages/* From b7f029e1d57f9811a96e63083a9ee9f80a503686 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Fri, 3 Jul 2026 13:32:05 -0400 Subject: [PATCH 15/75] Add filesystem + httpClient plugin capabilities and palette wiring Extends the plugin capability set so a plugin can read/write its project workspace and make outbound HTTPS requests - the two gaps found when assessing the workflows-plugin conversion. Also surfaces plugin commands in the host command palette. - filesystem: workspace-scoped, realpath-contained to project workspace roots (read live) + worktrees the plugin creates via vcs (tracked in a per-plugin PluginWorkspaceGrants holder; granted after createWorktree, revoked after removeWorktree). Enforcement: checks strictly precede mutation, writes go through O_NOFOLLOW to a target built from the validated real parent (O_EXCL|O_NOFOLLOW for exclusive create), remove is no-follow, rename is same-root/no-overwrite, and plugin-facing error messages carry only the supplied root/relativePath. Full surface: read/write/capped-read/exists/stat/list/recursive-list/mkdir/remove/ rename/listRoots + an SDK writeFileAtomic helper. - httpClient: HTTPS-only outbound via the lifted OutboundUrlValidator (dns.lookup all-addresses, full special-use + metadata blocks, IPv4- mapped/NAT64 forms) with the connection pinned to a validated address (custom Node lookup) to close DNS rebinding; redirects surfaced not followed; response + request-body + timeout caps; header control-char rejection. http:// only to loopback under T3_PLUGIN_DEV. - Command palette contributes a plugins group from the plugin command registry; a throwing command is contained; zero-plugin palette unchanged. - Manifest gating + consent descriptions; fixture declares and exercises both capabilities in the server integration test. Reviewed by Claude + Grok (SHIP, no MUSTs; the 4 spec security MUSTs verified implemented). Applied Grok SHOULDs: request-body size cap and header CRLF sanitization. Implemented by GPT-5.5 via codex exec (assembly-line, workflows-plugin sub-project 0). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .../HelloBoardFixture.integration.test.ts | 99 +- .../src/plugins/OutboundUrlValidator.test.ts | 114 +++ .../src/plugins/OutboundUrlValidator.ts | 234 +++++ apps/server/src/plugins/PluginHost.test.ts | 130 ++- apps/server/src/plugins/PluginHost.ts | 31 + .../src/plugins/PluginInstaller.test.ts | 26 + apps/server/src/plugins/PluginInstaller.ts | 2 + .../src/plugins/PluginWorkspaceGrants.ts | 32 + .../capabilities/FilesystemCapability.test.ts | 293 ++++++ .../capabilities/FilesystemCapability.ts | 896 ++++++++++++++++++ .../capabilities/HttpClientCapability.test.ts | 211 +++++ .../capabilities/HttpClientCapability.ts | 310 ++++++ .../capabilities/VcsCapability.test.ts | 6 +- .../src/plugins/capabilities/VcsCapability.ts | 11 +- .../plugins/readHttpResponseBytesCapped.ts | 30 +- apps/server/src/server.ts | 4 + .../components/CommandPalette.logic.test.ts | 72 ++ .../src/components/CommandPalette.logic.ts | 11 + apps/web/src/components/CommandPalette.tsx | 23 +- .../plugins/PluginsSettings.logic.test.tsx | 4 +- .../settings/plugins/PluginsSettings.logic.ts | 2 +- .../settings/plugins/PluginsSettings.tsx | 27 +- apps/web/src/plugins/PluginUiHost.test.tsx | 9 + apps/web/src/plugins/PluginUiHost.tsx | 4 +- apps/web/src/state/plugins.ts | 6 +- fixtures/hello-board/manifest.json | 2 +- fixtures/hello-board/scripts/build.mjs | 82 +- fixtures/hello-board/server/index.ts | 40 +- packages/contracts/src/plugin.test.ts | 9 +- packages/contracts/src/plugin.ts | 2 + packages/plugin-sdk/src/index.test.ts | 49 +- packages/plugin-sdk/src/index.ts | 195 +++- 32 files changed, 2862 insertions(+), 104 deletions(-) create mode 100644 apps/server/src/plugins/OutboundUrlValidator.test.ts create mode 100644 apps/server/src/plugins/OutboundUrlValidator.ts create mode 100644 apps/server/src/plugins/PluginWorkspaceGrants.ts create mode 100644 apps/server/src/plugins/capabilities/FilesystemCapability.test.ts create mode 100644 apps/server/src/plugins/capabilities/FilesystemCapability.ts create mode 100644 apps/server/src/plugins/capabilities/HttpClientCapability.test.ts create mode 100644 apps/server/src/plugins/capabilities/HttpClientCapability.ts diff --git a/apps/server/src/plugins/HelloBoardFixture.integration.test.ts b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts index e6cf0374155..e3737da59ee 100644 --- a/apps/server/src/plugins/HelloBoardFixture.integration.test.ts +++ b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts @@ -1,10 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { - AuthStandardClientScopes, - PluginId, - type AuthScope, -} from "@t3tools/contracts"; +import { AuthStandardClientScopes, PluginId, ProjectId, type AuthScope } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,7 +8,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -34,6 +30,8 @@ import * as TerminalManager from "../terminal/Manager.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { OutboundUrlError, OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import * as PluginCatalogModule from "./PluginCatalog.ts"; import * as PluginHostModule from "./PluginHost.ts"; import * as PluginHttpRegistry from "./PluginHttpRegistry.ts"; @@ -47,6 +45,7 @@ import * as PluginRpcDispatcherModule from "./PluginRpcDispatcher.ts"; import * as PluginRuntimeRegistryLayer from "./PluginRuntimeRegistry.ts"; const pluginId = PluginId.make("hello-board"); +const WORKSPACE_ROOT_ENV = "T3_HELLO_BOARD_WORKSPACE_ROOT"; const fixtureRoot = decodeURIComponent( new URL("../../../../fixtures/hello-board", import.meta.url).pathname, ); @@ -65,6 +64,21 @@ const TestHttpClientLive = Layer.succeed( Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), ), ); +const TestOutboundLookupLive = Layer.succeed(OutboundUrlLookup, (host: string) => + host === "fixture.test" + ? Effect.succeed([{ address: "140.82.112.3", family: 4 as const }]) + : Effect.fail(new OutboundUrlError({ reason: `unexpected lookup ${host}` })), +); +const TestPluginHttpClientTransportLive = Layer.succeed( + PluginHttpClientTransportService, + (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.make(request.method as "GET")(request.url.toString()), + new Response("hello http", { status: 200 }), + ), + ), +); const PluginRuntimeRegistryLayerLive = PluginRuntimeRegistryLayer.layer; const PluginHttpRegistryLayerLive = PluginHttpRegistry.layer; @@ -89,7 +103,24 @@ const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getCommandReadModel: unexpectedCapabilityUse, getSnapshot: unexpectedCapabilityUse, - getShellSnapshot: unexpectedCapabilityUse, + getShellSnapshot: () => + Effect.sync(() => ({ + snapshotSequence: 1, + updatedAt: "2026-07-03T00:00:00.000Z", + projects: [ + { + id: ProjectId.make("hello-board-project"), + title: "Hello Board Project", + workspaceRoot: process.env[WORKSPACE_ROOT_ENV] ?? process.cwd(), + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-03T00:00:00.000Z", + updatedAt: "2026-07-03T00:00:00.000Z", + }, + ], + threads: [], + })), getArchivedShellSnapshot: unexpectedCapabilityUse, getSnapshotSequence: unexpectedCapabilityUse, getCounts: unexpectedCapabilityUse, @@ -201,6 +232,8 @@ const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( subscribe: unexpectedCapabilityUse, subscribeMetadata: unexpectedCapabilityUse, }), + TestOutboundLookupLive, + TestPluginHttpClientTransportLive, ); const PluginHostLayerLive = PluginHostModule.layer.pipe( @@ -244,9 +277,7 @@ const PluginLayerLive = Layer.mergeAll( const testLayer = PluginLayerLive.pipe( Layer.provideMerge(NodeSqliteClient.layerMemory()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-hello-board-fixture-" }), - ), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-hello-board-fixture-" })), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(TestClock.layer()), Layer.provideMerge(NodeServices.layer), @@ -269,14 +300,16 @@ function buildFixture(outDir: string) { return Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn( - ChildProcess.make( - "pnpm", - ["--dir", fixtureRoot, "run", "build", "--", "--out-dir", outDir], - { cwd: fixtureRoot }, - ), + ChildProcess.make("pnpm", ["--dir", fixtureRoot, "run", "build", "--", "--out-dir", outDir], { + cwd: fixtureRoot, + }), ); const [stdout, stderr, exitCode] = yield* Effect.all( - [collectText(child.stdout), collectText(child.stderr), child.exitCode.pipe(Effect.map(Number))], + [ + collectText(child.stdout), + collectText(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], { concurrency: "unbounded" }, ); if (exitCode !== 0) { @@ -313,6 +346,7 @@ const withPluginDev = (effect: Effect.Effect) => Effect.sync(() => ({ pluginDev: process.env.T3_PLUGIN_DEV, healthyDelay: process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS, + workspaceRoot: process.env[WORKSPACE_ROOT_ENV], })), () => Effect.sync(() => { @@ -331,6 +365,11 @@ const withPluginDev = (effect: Effect.Effect) => } else { process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = previous.healthyDelay; } + if (previous.workspaceRoot === undefined) { + delete process.env[WORKSPACE_ROOT_ENV]; + } else { + process.env[WORKSPACE_ROOT_ENV] = previous.workspaceRoot; + } }), ); @@ -346,7 +385,11 @@ layer("hello-board fixture plugin", (it) => { const catalog = yield* PluginCatalogModule.PluginCatalog; const dispatcher = yield* PluginRpcDispatcherModule.PluginRpcDispatcher; const outDir = yield* fs.makeTempDirectoryScoped({ prefix: "hello-board-fixture-" }); + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "hello-board-workspace-", + }); const config = yield* ServerConfig.ServerConfig; + process.env[WORKSPACE_ROOT_ENV] = workspaceRoot; yield* buildFixture(outDir); yield* linkHostPluginExternals(config.pluginsDir); @@ -364,6 +407,8 @@ layer("hello-board fixture plugin", (it) => { }); assert.equal(staged.manifest.id, pluginId); assert.property(staged.capabilityDescriptions, "database"); + assert.property(staged.capabilityDescriptions, "filesystem"); + assert.property(staged.capabilityDescriptions, "httpClient"); const confirmed = yield* handlers.confirmInstall(staged.stageToken); assert.equal(confirmed.plugin.id, pluginId); @@ -381,7 +426,7 @@ layer("hello-board fixture plugin", (it) => { id: pluginId, state: "active", hasWeb: true, - capabilities: ["database"], + capabilities: ["database", "filesystem", "httpClient"], lastError: null, }, ); @@ -402,6 +447,26 @@ layer("hello-board fixture plugin", (it) => { )) as ReadonlyArray<{ readonly body?: unknown }>; assert.equal(notes[0]?.body, "hello from fixture"); + const capabilityResult = (yield* dispatcher.call( + pluginId, + "exerciseCapabilities", + {}, + session(AuthStandardClientScopes), + )) as { + readonly file?: unknown; + readonly status?: unknown; + readonly body?: unknown; + }; + assert.deepEqual(capabilityResult, { + file: "hello filesystem", + status: 200, + body: "hello http", + }); + assert.equal( + yield* fs.readFileString(path.join(workspaceRoot, ".hello-board", "capability.txt")), + "hello filesystem", + ); + const tables = yield* sql<{ readonly name: string }>` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'p_hello_board_notes' `; diff --git a/apps/server/src/plugins/OutboundUrlValidator.test.ts b/apps/server/src/plugins/OutboundUrlValidator.test.ts new file mode 100644 index 00000000000..a82bb11bb3f --- /dev/null +++ b/apps/server/src/plugins/OutboundUrlValidator.test.ts @@ -0,0 +1,114 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { OutboundUrlValidator } from "./OutboundUrlValidator.ts"; + +const validateWith = (url: string, addrs: ReadonlyArray) => + Effect.exit(OutboundUrlValidator.validate(url, { lookup: () => Effect.succeed(addrs) })); + +describe("OutboundUrlValidator", () => { + it.effect("accepts a public https host", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("https://hooks.slack.com/services/x", ["140.82.112.3"]))._tag, + "Success", + ); + }), + ); + + it.effect("rejects non-https by default", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("http://hooks.slack.com/x", ["140.82.112.3"]))._tag, + "Failure", + ); + }), + ); + + it.effect("blocks private, loopback, link-local, metadata, ULA, and CGNAT addresses", () => + Effect.gen(function* () { + for (const addr of [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "100.127.255.255", + "::1", + "fe80::1", + "fc00::1", + "fdff::1", + ]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Failure", addr); + } + }), + ); + + it.effect("blocks special-use IPv4 ranges but accepts neighboring public ranges", () => + Effect.gen(function* () { + for (const addr of [ + "0.0.0.0", + "192.0.0.1", + "192.0.2.5", + "192.88.99.1", + "198.18.0.1", + "198.19.255.255", + "198.51.100.5", + "203.0.113.5", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255", + ]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Failure", addr); + } + for (const addr of ["100.63.255.255", "100.128.0.1", "172.32.0.1", "198.20.0.1"]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Success", addr); + } + }), + ); + + it.effect( + "blocks IPv4-mapped IPv6, NAT64, 6to4, decimal IPv4, octal IPv4, and mixed answers", + () => + Effect.gen(function* () { + for (const [url, addr] of [ + ["https://x.test/y", "::ffff:10.0.0.1"], + ["https://x.test/y", "::ffff:7f00:1"], + ["https://x.test/y", "0:0:0:0:0:ffff:7f00:1"], + ["https://x.test/y", "::7f00:1"], + ["https://x.test/y", "64:ff9b::7f00:1"], + ["https://x.test/y", "2002:7f00:1::"], + ["https://2130706433/y", "127.0.0.1"], + ["https://0177.0.0.1/y", "127.0.0.1"], + ] as const) { + assert.equal((yield* validateWith(url, [addr]))._tag, "Failure", `${url} -> ${addr}`); + } + assert.equal( + (yield* validateWith("https://x.test/y", ["140.82.112.3", "10.0.0.1"]))._tag, + "Failure", + ); + }), + ); + + it.effect("allows http only for loopback when explicitly enabled for plugin development", () => + Effect.gen(function* () { + const allowed = yield* Effect.exit( + OutboundUrlValidator.validate("http://localhost:5173/x", { + lookup: () => Effect.succeed(["127.0.0.1"]), + allowHttpLoopback: true, + }), + ); + const rejected = yield* Effect.exit( + OutboundUrlValidator.validate("http://example.test/x", { + lookup: () => Effect.succeed(["140.82.112.3"]), + allowHttpLoopback: true, + }), + ); + + assert.equal(allowed._tag, "Success"); + assert.equal(rejected._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/plugins/OutboundUrlValidator.ts b/apps/server/src/plugins/OutboundUrlValidator.ts new file mode 100644 index 00000000000..010e76f4beb --- /dev/null +++ b/apps/server/src/plugins/OutboundUrlValidator.ts @@ -0,0 +1,234 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeDns from "node:dns"; + +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export class OutboundUrlError extends Data.TaggedError("OutboundUrlError")<{ + readonly reason: string; +}> {} + +export interface ResolvedAddress { + readonly address: string; + readonly family: 4 | 6; +} + +export interface UrlValidatorDeps { + readonly lookup: ( + host: string, + ) => Effect.Effect, OutboundUrlError | Error>; + readonly allowHttpLoopback?: boolean | undefined; +} + +export interface ResolvedOutboundUrl { + readonly url: URL; + readonly addresses: ReadonlyArray; +} + +const normalizeResolvedAddress = (entry: string | ResolvedAddress): ResolvedAddress => { + if (typeof entry !== "string") return entry; + return { address: entry, family: entry.includes(":") ? 6 : 4 }; +}; + +export const defaultLookup = ( + host: string, +): Effect.Effect, OutboundUrlError> => + Effect.tryPromise({ + try: async () => { + const records = await NodeDns.promises.lookup(host, { all: true }); + return records.map((record) => ({ + address: record.address, + family: record.family === 6 ? 6 : 4, + })); + }, + catch: (error) => { + const code = (error as { code?: unknown })?.code; + const suffix = typeof code === "string" ? ` (${code})` : ""; + return new OutboundUrlError({ reason: `DNS resolution failed for ${host}${suffix}` }); + }, + }); + +export class OutboundUrlLookup extends Context.Service< + OutboundUrlLookup, + UrlValidatorDeps["lookup"] +>()("t3/plugins/OutboundUrlValidator/OutboundUrlLookup") {} + +export const OutboundUrlLookupLive = Layer.succeed(OutboundUrlLookup, defaultLookup); + +// INVARIANT: only call this on canonical dotted decimal from WHATWG URL or DNS. +const ipv4Bytes = (ip: string): ReadonlyArray | null => { + if (!ip.includes(".") || ip.includes(":")) return null; + const parts = ip.split(".").map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return null; + } + return parts; +}; + +const isDisallowedV4 = (bytes: ReadonlyArray): boolean => { + const first = bytes[0] ?? -1; + const second = bytes[1] ?? -1; + const third = bytes[2] ?? -1; + if (first === 0) return true; + if (first === 10) return true; + if (first === 127) return true; + if (first === 100 && second >= 64 && second <= 127) return true; + if (first === 169 && second === 254) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 192 && second === 0 && third === 0) return true; + if (first === 192 && second === 0 && third === 2) return true; + if (first === 192 && second === 88 && third === 99) return true; + if (first === 192 && second === 168) return true; + if (first === 198 && (second === 18 || second === 19)) return true; + if (first === 198 && second === 51 && third === 100) return true; + if (first === 203 && second === 0 && third === 113) return true; + if (first >= 224) return true; + return false; +}; + +const ipv6Bytes = (raw: string): ReadonlyArray | null => { + let ip = raw.toLowerCase().replace(/^\[|\]$/g, ""); + const zone = ip.indexOf("%"); + if (zone !== -1) ip = ip.slice(0, zone); + if (!ip.includes(":")) return null; + + const lastColon = ip.lastIndexOf(":"); + const tail = ip.slice(lastColon + 1); + if (tail.includes(".")) { + const v4 = ipv4Bytes(tail); + if (!v4) return null; + const hi = (((v4[0] ?? 0) << 8) | (v4[1] ?? 0)).toString(16); + const lo = (((v4[2] ?? 0) << 8) | (v4[3] ?? 0)).toString(16); + ip = `${ip.slice(0, lastColon + 1)}${hi}:${lo}`; + } + + const halves = ip.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tailParts = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : null; + let hextets: ReadonlyArray; + if (tailParts === null) { + hextets = head; + } else { + const fill = 8 - head.length - tailParts.length; + if (fill < 0) return null; + hextets = [...head, ...Array(fill).fill("0"), ...tailParts]; + } + if (hextets.length !== 8) return null; + + const bytes: number[] = []; + for (const hextet of hextets) { + if (!/^[0-9a-f]{1,4}$/.test(hextet)) return null; + const value = Number.parseInt(hextet, 16); + bytes.push((value >> 8) & 0xff, value & 0xff); + } + return bytes; +}; + +const embeddedV4 = (bytes: ReadonlyArray): ReadonlyArray | null => { + const zerosThrough = (length: number): boolean => + bytes.slice(0, length).every((byte) => byte === 0); + if (zerosThrough(10) && bytes[10] === 0xff && bytes[11] === 0xff) { + return bytes.slice(12, 16); + } + if ( + bytes[0] === 0x00 && + bytes[1] === 0x64 && + bytes[2] === 0xff && + bytes[3] === 0x9b && + bytes.slice(4, 12).every((byte) => byte === 0) + ) { + return bytes.slice(12, 16); + } + if (zerosThrough(12)) return bytes.slice(12, 16); + if (bytes[0] === 0x20 && bytes[1] === 0x02) return bytes.slice(2, 6); + return null; +}; + +const isPrivateV6 = (raw: string): boolean => { + const bytes = ipv6Bytes(raw); + if (!bytes) return true; + if (bytes.slice(0, 15).every((byte) => byte === 0) && (bytes[15] === 0 || bytes[15] === 1)) { + return true; + } + const first = bytes[0] ?? 0; + const second = bytes[1] ?? 0; + if (first === 0xfe && (second & 0xc0) === 0x80) return true; + if ((first & 0xfe) === 0xfc) return true; + if (first === 0xff) return true; + const v4 = embeddedV4(bytes); + if (v4) return isDisallowedV4(v4); + return false; +}; + +const isBlocked = (ip: string): boolean => { + const v4 = ipv4Bytes(ip); + if (v4) return isDisallowedV4(v4); + return isPrivateV6(ip); +}; + +const isLoopback = (ip: string): boolean => { + const v4 = ipv4Bytes(ip); + if (v4) return v4[0] === 127; + const bytes = ipv6Bytes(ip); + return !!bytes && bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1; +}; + +const mapLookupError = (host: string, error: unknown) => + error instanceof OutboundUrlError + ? error + : new OutboundUrlError({ reason: `DNS resolution failed for ${host}` }); + +export const OutboundUrlValidator = { + resolve: ( + rawUrl: string, + deps: UrlValidatorDeps = { lookup: defaultLookup }, + ): Effect.Effect => + Effect.gen(function* () { + let parsed: URL; + // @effect-diagnostics-next-line tryCatchInEffectGen:off -- WHATWG URL parsing is a synchronous guard converted to an Effect failure. + try { + parsed = new URL(rawUrl); + } catch { + return yield* new OutboundUrlError({ reason: "Malformed URL" }); + } + + const isHttps = parsed.protocol === "https:"; + const allowHttpLoopback = deps.allowHttpLoopback === true && parsed.protocol === "http:"; + if (!isHttps && !allowHttpLoopback) { + return yield* new OutboundUrlError({ reason: "Only https:// targets are allowed" }); + } + + const addresses = yield* deps.lookup(parsed.hostname).pipe( + Effect.map((records) => records.map(normalizeResolvedAddress)), + Effect.mapError((error) => mapLookupError(parsed.hostname, error)), + ); + if (addresses.length === 0) { + return yield* new OutboundUrlError({ reason: "Host did not resolve" }); + } + + for (const address of addresses) { + if (isHttps) { + if (isBlocked(address.address)) { + return yield* new OutboundUrlError({ + reason: `Resolved to a disallowed address (${address.address})`, + }); + } + } else if (!isLoopback(address.address)) { + return yield* new OutboundUrlError({ + reason: `HTTP development target resolved outside loopback (${address.address})`, + }); + } + } + + return { url: parsed, addresses }; + }), + + validate: (rawUrl: string, deps?: UrlValidatorDeps): Effect.Effect => + OutboundUrlValidator.resolve(rawUrl, deps).pipe(Effect.map((result) => result.url)), +}; diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index ad25a71c581..4536b1fc7ce 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -36,6 +36,8 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import * as TerminalManager from "../terminal/Manager.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import * as PluginHostModule from "./PluginHost.ts"; import * as PluginHttpRegistry from "./PluginHttpRegistry.ts"; import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; @@ -202,17 +204,25 @@ const testLayerBase = PluginHostModule.layer.pipe( }), ), Layer.provideMerge( - Layer.mock(TerminalManager.TerminalManager)({ - open: unexpectedCapabilityUse, - attachStream: unexpectedCapabilityUse, - write: unexpectedCapabilityUse, - resize: unexpectedCapabilityUse, - clear: unexpectedCapabilityUse, - restart: unexpectedCapabilityUse, - close: unexpectedCapabilityUse, - subscribe: unexpectedCapabilityUse, - subscribeMetadata: unexpectedCapabilityUse, - }), + Layer.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({ + open: unexpectedCapabilityUse, + attachStream: unexpectedCapabilityUse, + write: unexpectedCapabilityUse, + resize: unexpectedCapabilityUse, + clear: unexpectedCapabilityUse, + restart: unexpectedCapabilityUse, + close: unexpectedCapabilityUse, + subscribe: unexpectedCapabilityUse, + subscribeMetadata: unexpectedCapabilityUse, + }), + Layer.succeed(OutboundUrlLookup, () => + Effect.die(new Error("unexpected outbound lookup in host test")), + ), + Layer.succeed(PluginHttpClientTransportService, () => + Effect.die(new Error("unexpected http client transport in host test")), + ), + ), ), ); @@ -235,6 +245,16 @@ const decodeCapabilityMarker = Schema.decodeEffect( }), ), ); +const decodeNewCapabilityMarker = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + filesystemAvailable: Schema.Boolean, + filesystemUnavailable: Schema.Boolean, + httpClientAvailable: Schema.Boolean, + httpClientUnavailable: Schema.Boolean, + }), + ), +); const makeLockEntry = (overrides: Partial = {}): PluginLockfilePlugin => ({ version: "1.0.0", @@ -352,6 +372,35 @@ export default { }; `; +const newCapabilityGateEntrySource = () => ` +import { createRequire } from "node:module"; +const require = createRequire(${JSON.stringify(NodeURL.pathToFileURL(import.meta.url).href)}); +const Effect = require("effect/Effect"); +const NodeFs = require("node:fs"); + +const available = (effect) => Effect.exit(effect).pipe(Effect.map((exit) => exit._tag === "Success")); +const unavailable = (effect) => + Effect.exit(effect).pipe( + Effect.map((exit) => exit._tag === "Failure" && String(exit.cause).includes("PluginCapabilityUnavailable")), + ); + +export default { + register(hostApi) { + return Effect.gen(function* () { + const marker = { + filesystemAvailable: yield* available(hostApi.filesystem), + filesystemUnavailable: yield* unavailable(hostApi.filesystem), + httpClientAvailable: yield* available(hostApi.httpClient), + httpClientUnavailable: yield* unavailable(hostApi.httpClient), + }; + NodeFs.mkdirSync(hostApi.config.dataDir, { recursive: true }); + NodeFs.writeFileSync(hostApi.config.dataDir + "/new-capabilities.json", JSON.stringify(marker)); + return {}; + }); + }, +}; +`; + layer("PluginModuleLoader", (it) => { it.effect("loads a definePlugin-shaped default export from inside the plugin dir", () => Effect.gen(function* () { @@ -537,6 +586,65 @@ layer("PluginHost", (it) => { }), ); + it.effect("gates filesystem and httpClient independently by manifest declaration", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* PluginHostModule.PluginHost; + + const cases = [ + { + pluginId: PluginId.make("filesystem-only"), + capabilities: ["filesystem"] as const, + expected: { + filesystemAvailable: true, + filesystemUnavailable: false, + httpClientAvailable: false, + httpClientUnavailable: true, + }, + }, + { + pluginId: PluginId.make("http-client-only"), + capabilities: ["httpClient"] as const, + expected: { + filesystemAvailable: false, + filesystemUnavailable: true, + httpClientAvailable: true, + httpClientUnavailable: false, + }, + }, + { + pluginId: PluginId.make("neither-new-cap"), + capabilities: [] as const, + expected: { + filesystemAvailable: false, + filesystemUnavailable: true, + httpClientAvailable: false, + httpClientUnavailable: true, + }, + }, + ]; + + for (const testCase of cases) { + yield* installPlugin({ + pluginId: testCase.pluginId, + capabilities: testCase.capabilities, + entrySource: newCapabilityGateEntrySource(), + }); + } + + yield* host.start; + yield* Effect.yieldNow; + + for (const testCase of cases) { + const dataDir = pluginDataDir(config.pluginsDir, testCase.pluginId, path.join); + const marker = yield* fs.readFileString(path.join(dataDir, "new-capabilities.json")); + assert.deepEqual(yield* decodeNewCapabilityMarker(marker), testCase.expected); + } + }), + ); + it.effect("does not load anything when T3_NO_PLUGINS is set", () => Effect.gen(function* () { const pluginId = PluginId.make("disabled-env"); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 57884b3956c..38451c8d801 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -51,13 +51,19 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { makeAgentsCapability } from "./capabilities/AgentsCapability.ts"; import { makeDatabaseCapability } from "./capabilities/DatabaseCapability.ts"; import { makeEnvironmentsReadCapability } from "./capabilities/EnvironmentsReadCapability.ts"; +import { makeFilesystemCapability } from "./capabilities/FilesystemCapability.ts"; import { makeHttpCapability } from "./capabilities/HttpCapability.ts"; +import { + makeHttpClientCapability, + PluginHttpClientTransportService, +} from "./capabilities/HttpClientCapability.ts"; import { makeProjectionsReadCapability } from "./capabilities/ProjectionsReadCapability.ts"; import { makeSecretsCapability } from "./capabilities/SecretsCapability.ts"; import { makeSourceControlCapability } from "./capabilities/SourceControlCapability.ts"; import { makeTerminalsCapability } from "./capabilities/TerminalsCapability.ts"; import { makeTextGenerationCapability } from "./capabilities/TextGenerationCapability.ts"; import { makeVcsCapability } from "./capabilities/VcsCapability.ts"; +import { OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; import { PluginHttpRegistry } from "./PluginHttpRegistry.ts"; import { PluginMigrator } from "./PluginMigrator.ts"; @@ -65,6 +71,7 @@ import { PluginModuleLoader } from "./PluginModuleLoader.ts"; import { makePluginLogger } from "./PluginLogger.ts"; import { pluginDataDir, pluginManifestPath, pluginVersionDir } from "./PluginPaths.ts"; import { PluginRuntimeRegistry } from "./PluginRuntimeRegistry.ts"; +import { makePluginWorkspaceGrants, type PluginWorkspaceGrants } from "./PluginWorkspaceGrants.ts"; const APP_VERSION = packageJson.version; const PRESERVE_DATA_MARKER = ".preserve-data-on-remove"; @@ -158,6 +165,7 @@ const makeHostApi = (input: { readonly capabilities: ReadonlyArray; readonly dataDir: string; readonly logger: PluginLogger; + readonly grants: PluginWorkspaceGrants; readonly deps: { readonly sql: SqlClient.SqlClient; readonly secretStore: ServerSecretStore.ServerSecretStore["Service"]; @@ -177,6 +185,8 @@ const makeHostApi = (input: { readonly sourceControlRegistry: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; readonly github: GitHubCli.GitHubCli["Service"]; readonly terminals: TerminalManager.TerminalManager["Service"]; + readonly outboundLookup: OutboundUrlLookup["Service"]; + readonly httpClientTransport: PluginHttpClientTransportService["Service"]; }; }): { readonly api: PluginHostApi; readonly teardown: ReadonlyArray> } => { const capabilities = new Set(input.capabilities); @@ -216,6 +226,7 @@ const makeHostApi = (input: { makeVcsCapability({ git: input.deps.git, checkpoints: input.deps.checkpointStore, + grants: input.grants, }), ), terminals: available("terminals", terminalsBundle.capability), @@ -247,6 +258,20 @@ const makeHostApi = (input: { }), ), http: available("http", makeHttpCapability(input.pluginId)), + filesystem: available( + "filesystem", + makeFilesystemCapability({ + snapshots: input.deps.snapshots, + grants: input.grants, + }), + ), + httpClient: available( + "httpClient", + makeHttpClientCapability({ + lookup: input.deps.outboundLookup, + transport: input.deps.httpClientTransport, + }), + ), sourceControl: available( "sourceControl", makeSourceControlCapability({ @@ -345,6 +370,8 @@ export const make = Effect.fn("PluginHost.make")(function* () { const sourceControlRegistry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const github = yield* GitHubCli.GitHubCli; const terminals = yield* TerminalManager.TerminalManager; + const outboundLookup = yield* OutboundUrlLookup; + const httpClientTransport = yield* PluginHttpClientTransportService; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const publishPluginStateChanged = (pluginId: PluginId, state: PluginState) => @@ -424,11 +451,13 @@ export const make = Effect.fn("PluginHost.make")(function* () { const readiness = yield* Deferred.make(); const logger = makePluginLogger(pluginId); const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const grants = yield* makePluginWorkspaceGrants; const { api: hostApi, teardown: hostApiTeardown } = makeHostApi({ pluginId, capabilities: manifest.capabilities, dataDir, logger, + grants, deps: { sql, secretStore, @@ -448,6 +477,8 @@ export const make = Effect.fn("PluginHost.make")(function* () { sourceControlRegistry, github, terminals, + outboundLookup, + httpClientTransport, }, }); diff --git a/apps/server/src/plugins/PluginInstaller.test.ts b/apps/server/src/plugins/PluginInstaller.test.ts index 16d86b1c647..3df7a356283 100644 --- a/apps/server/src/plugins/PluginInstaller.test.ts +++ b/apps/server/src/plugins/PluginInstaller.test.ts @@ -496,6 +496,32 @@ it.effect("PluginInstaller begin-confirm updates the lockfile and hot-activates" ); }); +it.effect("PluginInstaller describes filesystem and httpClient consent", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + + assert.equal( + staged.capabilityDescriptions.filesystem, + "Read and write files in your project workspace and in worktrees this plugin creates", + ); + assert.equal( + staged.capabilityDescriptions.httpClient, + "Make requests to public external HTTPS services", + ); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifest(manifest({ capabilities: ["filesystem", "httpClient"] })), + }), + ), + ), + ), +); + it.effect("PluginInstaller abort and expired tokens clean staging", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts index 4f1f303962f..0496f511bb3 100644 --- a/apps/server/src/plugins/PluginInstaller.ts +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -80,6 +80,8 @@ export const PLUGIN_CAPABILITY_DESCRIPTIONS = { "environments.read": "Read environment metadata", secrets: "Store plugin secrets", http: "Serve plugin HTTP routes", + filesystem: "Read and write files in your project workspace and in worktrees this plugin creates", + httpClient: "Make requests to public external HTTPS services", sourceControl: "Use source control integrations", textGeneration: "Request text generation", } satisfies Record; diff --git a/apps/server/src/plugins/PluginWorkspaceGrants.ts b/apps/server/src/plugins/PluginWorkspaceGrants.ts new file mode 100644 index 00000000000..b15b93d3d4f --- /dev/null +++ b/apps/server/src/plugins/PluginWorkspaceGrants.ts @@ -0,0 +1,32 @@ +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +export interface PluginWorkspaceGrants { + readonly grant: (root: string) => Effect.Effect; + readonly revoke: (root: string) => Effect.Effect; + readonly clear: Effect.Effect; + readonly snapshot: () => Effect.Effect>; +} + +export const makePluginWorkspaceGrants: Effect.Effect = Effect.gen( + function* () { + const roots = yield* Ref.make(new Set()); + + return { + grant: (root) => + Ref.update(roots, (current) => { + const next = new Set(current); + next.add(root); + return next; + }), + revoke: (root) => + Ref.update(roots, (current) => { + const next = new Set(current); + next.delete(root); + return next; + }), + clear: Ref.set(roots, new Set()), + snapshot: () => Ref.get(roots).pipe(Effect.map((current) => new Set(current))), + }; + }, +); diff --git a/apps/server/src/plugins/capabilities/FilesystemCapability.test.ts b/apps/server/src/plugins/capabilities/FilesystemCapability.test.ts new file mode 100644 index 00000000000..51530355232 --- /dev/null +++ b/apps/server/src/plugins/capabilities/FilesystemCapability.test.ts @@ -0,0 +1,293 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { FilesystemPathError, makeFilesystemCapability } from "./FilesystemCapability.ts"; +import { makePluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; + +const TestLayer = NodeServices.layer; +const layer = it.layer(TestLayer); + +const projectShell = (workspaceRoot: string, id = "project-1") => + ({ + id, + title: id, + workspaceRoot, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-03T00:00:00.000Z", + updatedAt: "2026-07-03T00:00:00.000Z", + }) as any; + +function makeCapability(input: { + readonly projectRoots: ReadonlyArray; + readonly grants: any; +}) { + return makeFilesystemCapability({ + snapshots: { + getShellSnapshot: () => + Effect.succeed({ + projects: input.projectRoots.map((root, index) => projectShell(root, `project-${index}`)), + threads: [], + } as any), + } as any, + grants: input.grants, + }); +} + +const makeTempDir = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix }); + }); + +const expectPathFailure = (effect: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* Effect.exit(effect); + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + assert.include(String(exit.cause), FilesystemPathError.name); + } + }); + +layer("FilesystemCapability", (it) => { + it.effect("rejects traversal and absolute relative paths before touching the filesystem", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* expectPathFailure(filesystem.readFileString({ root, relativePath: "../secret" })); + yield* expectPathFailure(filesystem.exists({ root, relativePath: "/tmp/secret" })); + }), + ), + ); + + it.effect("round-trips files, directories, stats, lists, roots, and idempotent remove", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.makeDirectory({ root, relativePath: "notes" }); + yield* filesystem.writeFileString({ + root, + relativePath: "notes/today.txt", + contents: "hello", + }); + assert.equal( + yield* filesystem.readFileString({ root, relativePath: "notes/today.txt" }), + "hello", + ); + assert.deepEqual( + Array.from(yield* filesystem.readFile({ root, relativePath: "notes/today.txt" })), + Array.from(new TextEncoder().encode("hello")), + ); + assert.isTrue(yield* filesystem.exists({ root, relativePath: "notes/today.txt" })); + const stat = yield* filesystem.stat({ root, relativePath: "notes/today.txt" }); + assert.equal(stat.type, "file"); + assert.equal(stat.size, 5); + assert.isAtLeast(stat.mtime, 0); + assert.deepEqual(yield* filesystem.listDir({ root, relativePath: "notes" }), [ + { name: "today.txt", relativePath: "notes/today.txt", type: "file" }, + ]); + assert.deepEqual( + (yield* filesystem.listDirRecursive({ root, relativePath: "" })).map( + (entry) => entry.relativePath, + ), + ["notes", "notes/today.txt"], + ); + assert.deepEqual(yield* filesystem.listRoots(), [root]); + + yield* filesystem.remove({ root, relativePath: "notes/today.txt" }); + yield* filesystem.remove({ root, relativePath: "notes/today.txt" }); + assert.isFalse(yield* filesystem.exists({ root, relativePath: "notes/today.txt" })); + }), + ), + ); + + it.effect("rejects symlink leaves and symlinked parents for sensitive operations", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(outside, "secret.txt"), "no")); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(outside, "secret.txt"), NodePath.join(root, "leaf-link")), + ); + yield* Effect.promise(() => + NodeFSP.symlink(outside, NodePath.join(root, "parent-link"), "dir"), + ); + + yield* expectPathFailure(filesystem.readFileString({ root, relativePath: "leaf-link" })); + yield* expectPathFailure( + filesystem.writeFileString({ root, relativePath: "leaf-link", contents: "changed" }), + ); + yield* expectPathFailure( + filesystem.makeDirectory({ root, relativePath: "parent-link/created-outside" }), + ); + assert.isFalse( + yield* Effect.promise(() => + NodeFSP.stat(NodePath.join(outside, "created-outside")) + .then(() => true) + .catch(() => false), + ), + ); + + yield* filesystem.writeFileString({ root, relativePath: "safe.txt", contents: "safe" }); + yield* expectPathFailure( + filesystem.rename({ + root, + fromRelativePath: "safe.txt", + toRelativePath: "parent-link/safe.txt", + }), + ); + }), + ), + ); + + it.effect("removes recursively without following symlinks outside the root", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.makeDirectory({ root, relativePath: "nested" }); + yield* filesystem.writeFileString({ + root, + relativePath: "nested/inside.txt", + contents: "inside", + }); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(outside, "keep.txt"), "keep")); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(outside, "keep.txt"), NodePath.join(root, "nested", "out")), + ); + + yield* filesystem.remove({ root, relativePath: "nested" }); + + assert.isFalse(yield* filesystem.exists({ root, relativePath: "nested" })); + assert.equal( + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(outside, "keep.txt"), "utf8")), + "keep", + ); + }), + ), + ); + + it.effect("fails exclusive create, no-overwrite rename, and read/write caps", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.createFileExclusive({ root, relativePath: "one.txt", contents: "one" }); + assert.equal( + (yield* Effect.exit( + filesystem.createFileExclusive({ root, relativePath: "one.txt", contents: "two" }), + ))._tag, + "Failure", + ); + yield* filesystem.writeFileString({ root, relativePath: "two.txt", contents: "two" }); + assert.equal( + (yield* Effect.exit( + filesystem.rename({ root, fromRelativePath: "one.txt", toRelativePath: "two.txt" }), + ))._tag, + "Failure", + ); + + const tooLarge = "x".repeat(16 * 1024 * 1024 + 1); + assert.equal( + (yield* Effect.exit( + filesystem.writeFileString({ root, relativePath: "large.txt", contents: tooLarge }), + ))._tag, + "Failure", + ); + yield* filesystem.writeFileString({ root, relativePath: "small.txt", contents: "abcdef" }); + assert.equal( + yield* filesystem.readFileStringCapped({ + root, + relativePath: "small.txt", + maxBytes: 3, + }), + "abc", + ); + }), + ), + ); + + it.effect("grants worktree roots after VCS creation and rejects them after removal", () => + Effect.scoped( + Effect.gen(function* () { + const projectRoot = yield* makeTempDir("plugin-fs-project-"); + const worktreeRoot = yield* makeTempDir("plugin-fs-worktree-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [projectRoot], grants }); + + yield* filesystem.writeFileString({ + root: projectRoot, + relativePath: "project.txt", + contents: "project", + }); + yield* expectPathFailure( + filesystem.writeFileString({ + root: worktreeRoot, + relativePath: "worktree.txt", + contents: "denied", + }), + ); + + yield* grants.grant(worktreeRoot); + yield* filesystem.writeFileString({ + root: worktreeRoot, + relativePath: "worktree.txt", + contents: "allowed", + }); + assert.deepEqual( + (yield* filesystem.listRoots()).toSorted(), + [projectRoot, worktreeRoot].toSorted(), + ); + + yield* grants.revoke(worktreeRoot); + yield* expectPathFailure( + filesystem.readFileString({ root: worktreeRoot, relativePath: "worktree.txt" }), + ); + }), + ), + ); + + it.effect("keeps resolved absolute paths out of plugin-facing messages", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + const outsideFile = NodePath.join(outside, "secret.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(outsideFile, "no")); + yield* Effect.promise(() => NodeFSP.symlink(outsideFile, NodePath.join(root, "link"))); + + const error = yield* filesystem + .readFileString({ root, relativePath: "link" }) + .pipe(Effect.flip); + const realOutsideFile = yield* Effect.promise(() => NodeFSP.realpath(outsideFile)); + + assert.notInclude(error.message, outsideFile); + assert.deepInclude((error as any).data, { resolvedPath: realOutsideFile }); + }), + ), + ); +}); diff --git a/apps/server/src/plugins/capabilities/FilesystemCapability.ts b/apps/server/src/plugins/capabilities/FilesystemCapability.ts new file mode 100644 index 00000000000..5cf6488d318 --- /dev/null +++ b/apps/server/src/plugins/capabilities/FilesystemCapability.ts @@ -0,0 +1,896 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { DirEntry, FileStat, FilesystemCapability } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { PluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; + +const FILE_MAX_BYTES = 16 * 1024 * 1024; +const LIST_RECURSIVE_MAX_ENTRIES = 500; +const READ_CHUNK_BYTES = 64 * 1024; + +type FilesystemOperation = + | "list-roots" + | "read-file" + | "read-file-string" + | "read-file-string-capped" + | "write-file" + | "create-file-exclusive" + | "exists" + | "stat" + | "list-dir" + | "list-dir-recursive" + | "make-directory" + | "remove" + | "rename"; + +interface FilesystemPathContext { + readonly root: string; + readonly relativePath: string; +} + +export class FilesystemPathError extends Schema.TaggedErrorClass()( + "FilesystemPathError", + { + root: Schema.String, + relativePath: Schema.String, + operation: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `Filesystem path '${this.relativePath}' in root '${this.root}' is not allowed: ${this.reason}`; + } +} + +export class FilesystemIoError extends Schema.TaggedErrorClass()( + "FilesystemIoError", + { + root: Schema.String, + relativePath: Schema.String, + operation: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `Filesystem operation '${this.operation}' failed for '${this.relativePath}' in root '${this.root}': ${this.reason}`; + } +} + +type FilesystemError = FilesystemPathError | FilesystemIoError; + +class NodePathNotFound extends Error { + readonly _tag = "NodePathNotFound"; +} + +const isFilesystemIoError = Schema.is(FilesystemIoError); + +const isNodeNotFound = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause as { readonly code?: unknown }).code === "ENOENT"; + +const isNodeSymlinkLoop = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause as { readonly code?: unknown }).code === "ELOOP"; + +const pathError = ( + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, + data?: unknown, +) => + new FilesystemPathError({ + ...context, + operation, + reason, + ...(data === undefined ? {} : { data }), + }); + +const ioError = ( + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, + data?: unknown, +) => + new FilesystemIoError({ + ...context, + operation, + reason, + ...(data === undefined ? {} : { data }), + }); + +const containsRealPath = (realRoot: string, realTarget: string): boolean => { + const relative = NodePath.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !NodePath.isAbsolute(relative)); +}; + +const isAbsoluteRelativePath = (relativePath: string): boolean => + NodePath.isAbsolute(relativePath) || + relativePath.startsWith("\\") || + /^[a-zA-Z]:[\\/]/u.test(relativePath); + +function parseRelativePath( + context: FilesystemPathContext, + operation: FilesystemOperation, +): Effect.Effect, FilesystemPathError> { + if (context.relativePath.includes("\0")) { + return Effect.fail(pathError(context, operation, "path contains a NUL byte")); + } + if (isAbsoluteRelativePath(context.relativePath)) { + return Effect.fail(pathError(context, operation, "relativePath must not be absolute")); + } + const normalized = context.relativePath.replace(/\\/gu, "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + if (segments.includes("..")) { + return Effect.fail(pathError(context, operation, "relativePath must not contain '..'")); + } + return Effect.succeed(segments); +} + +const outputRelativePath = (base: string, name: string) => + [...base.replace(/\\/gu, "/").split("/").filter(Boolean), name].join("/"); + +const statType = (stat: NodeFS.Stats): FileStat["type"] => { + if (stat.isFile()) return "file"; + if (stat.isDirectory()) return "directory"; + return "other"; +}; + +const lstatOrNull = ( + absPath: string, + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, +) => + Effect.tryPromise({ + try: () => NodeFSP.lstat(absPath), + catch: (cause) => + isNodeNotFound(cause) + ? new NodePathNotFound() + : ioError(context, operation, reason, { + resolvedPath: absPath, + cause, + }), + }).pipe( + Effect.catch((error) => + error instanceof NodePathNotFound ? Effect.succeed(null) : Effect.fail(error), + ), + ); + +const readProjectRoots = (snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]) => + snapshots + .getShellSnapshot() + .pipe(Effect.map((snapshot) => snapshot.projects.map((p) => p.workspaceRoot))); + +function snapshotGrantedRoots(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; +}): Effect.Effect, Error> { + return Effect.gen(function* () { + const projectRoots = yield* readProjectRoots(input.snapshots); + const worktreeRoots = [...(yield* input.grants.snapshot())]; + return [...new Set([...projectRoots, ...worktreeRoots])]; + }); +} + +function requireRoot(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; +}): Effect.Effect< + { readonly roots: ReadonlyArray; readonly realRoot: string }, + FilesystemError +> { + return Effect.gen(function* () { + const roots = yield* snapshotGrantedRoots(input).pipe( + Effect.mapError((cause) => + ioError(input.context, input.operation, "failed to read granted roots", { cause }), + ), + ); + if (!roots.includes(input.context.root)) { + return yield* pathError(input.context, input.operation, "root is not granted", { + roots, + }); + } + const realRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.context.root), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve root", { cause }), + }); + return { roots, realRoot }; + }); +} + +function realpathExistingTarget(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly segments: ReadonlyArray; +}): Effect.Effect { + return Effect.gen(function* () { + const logicalTarget = NodePath.join(input.context.root, ...input.segments); + const realTarget = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(logicalTarget), + catch: (cause) => + isNodeNotFound(cause) + ? pathError(input.context, input.operation, "path does not exist", { + resolvedPath: logicalTarget, + cause, + }) + : ioError(input.context, input.operation, "failed to resolve path", { + resolvedPath: logicalTarget, + cause, + }), + }); + if (!containsRealPath(input.realRoot, realTarget)) { + return yield* pathError(input.context, input.operation, "path resolves outside root", { + resolvedRoot: input.realRoot, + resolvedPath: realTarget, + }); + } + return realTarget; + }); +} + +function resolveParent(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly parentSegments: ReadonlyArray; + readonly create: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + let current = input.realRoot; + for (const segment of input.parentSegments) { + if (segment === ".") continue; + const candidate = NodePath.join(current, segment); + let lstat = yield* lstatOrNull( + candidate, + input.context, + input.operation, + "failed to inspect parent path", + ); + if (lstat === null) { + if (!input.create) { + return yield* ioError(input.context, input.operation, "parent path does not exist", { + resolvedPath: candidate, + }); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.mkdir(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to create directory", { + resolvedPath: candidate, + cause, + }), + }); + lstat = yield* Effect.tryPromise({ + try: () => NodeFSP.lstat(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to inspect created directory", { + resolvedPath: candidate, + cause, + }), + }); + } + const realCandidate = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve parent path", { + resolvedPath: candidate, + cause, + }), + }); + if (!containsRealPath(input.realRoot, realCandidate)) { + return yield* pathError(input.context, input.operation, "parent resolves outside root", { + resolvedRoot: input.realRoot, + resolvedPath: realCandidate, + }); + } + const stat = lstat.isSymbolicLink() + ? yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realCandidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to inspect resolved parent", { + resolvedPath: realCandidate, + cause, + }), + }) + : lstat; + if (!stat.isDirectory()) { + return yield* ioError(input.context, input.operation, "parent path is not a directory", { + resolvedPath: realCandidate, + }); + } + current = realCandidate; + } + return current; + }); +} + +function resolveLeafParent(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly segments: ReadonlyArray; + readonly createParent: boolean; +}): Effect.Effect<{ readonly realParent: string; readonly leaf: string }, FilesystemError> { + return Effect.gen(function* () { + const leaf = input.segments.at(-1); + if (!leaf || leaf === ".") { + return yield* pathError(input.context, input.operation, "path must include a final entry"); + } + const realParent = yield* resolveParent({ + context: input.context, + operation: input.operation, + realRoot: input.realRoot, + parentSegments: input.segments.slice(0, -1), + create: input.createParent, + }); + return { realParent, leaf }; + }); +} + +function ensureNoSymlinkLeaf(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; +}): Effect.Effect { + return Effect.gen(function* () { + const lstat = yield* lstatOrNull( + input.target, + input.context, + input.operation, + "failed to inspect target", + ); + if (lstat?.isSymbolicLink()) { + return yield* pathError(input.context, input.operation, "symlink leaf is not allowed", { + resolvedPath: input.target, + }); + } + }); +} + +function openNoFollow(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; + readonly flags: number; +}): Effect.Effect { + return Effect.tryPromise({ + try: () => NodeFSP.open(input.target, input.flags | NodeFS.constants.O_NOFOLLOW, 0o666), + catch: (cause) => + isNodeSymlinkLoop(cause) + ? pathError(input.context, input.operation, "symlink leaf is not allowed", { + resolvedPath: input.target, + cause, + }) + : ioError(input.context, input.operation, "failed to open file", { + resolvedPath: input.target, + cause, + }), + }); +} + +function closeHandle(handle: NodeFSP.FileHandle) { + return Effect.promise(() => handle.close()).pipe(Effect.ignore); +} + +function readBytesFromHandle(input: { + readonly handle: NodeFSP.FileHandle; + readonly maxBytes: number; + readonly failOnOverflow: boolean; + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; +}): Effect.Effect { + return Effect.gen(function* () { + const chunks: Buffer[] = []; + let total = 0; + const readLimit = input.failOnOverflow ? input.maxBytes + 1 : input.maxBytes; + while (total < readLimit) { + const buffer = Buffer.alloc(Math.min(READ_CHUNK_BYTES, readLimit - total)); + const result = yield* Effect.tryPromise({ + try: () => input.handle.read(buffer, 0, buffer.byteLength, null), + catch: (cause) => ioError(input.context, input.operation, "failed to read file", { cause }), + }); + if (result.bytesRead === 0) break; + chunks.push(buffer.subarray(0, result.bytesRead)); + total += result.bytesRead; + } + if (input.failOnOverflow && total > input.maxBytes) { + return yield* ioError(input.context, input.operation, "file exceeds the size limit", { + limit: input.maxBytes, + actual: total, + }); + } + return new Uint8Array(Buffer.concat(chunks, total)); + }); +} + +function readExistingFile(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realPath: string; + readonly maxBytes: number; + readonly failOnOverflow: boolean; +}): Effect.Effect { + return Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => NodeFSP.open(input.realPath, NodeFS.constants.O_RDONLY), + catch: (cause) => + ioError(input.context, input.operation, "failed to open file", { + resolvedPath: input.realPath, + cause, + }), + }), + (handle) => + Effect.gen(function* () { + const stat = yield* Effect.tryPromise({ + try: () => handle.stat(), + catch: (cause) => + ioError(input.context, input.operation, "failed to stat file", { + resolvedPath: input.realPath, + cause, + }), + }); + if (!stat.isFile()) { + return yield* ioError(input.context, input.operation, "path is not a file", { + resolvedPath: input.realPath, + }); + } + return yield* readBytesFromHandle({ ...input, handle }); + }), + closeHandle, + ); +} + +function writeBytesToTarget(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; + readonly contents: Uint8Array; + readonly exclusive: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.contents.byteLength > FILE_MAX_BYTES) { + return yield* ioError(input.context, input.operation, "file exceeds the size limit", { + limit: FILE_MAX_BYTES, + actual: input.contents.byteLength, + }); + } + yield* ensureNoSymlinkLeaf(input); + const flags = + NodeFS.constants.O_WRONLY | + NodeFS.constants.O_CREAT | + (input.exclusive ? NodeFS.constants.O_EXCL : NodeFS.constants.O_TRUNC); + yield* Effect.acquireUseRelease( + openNoFollow({ ...input, flags }), + (handle) => + Effect.tryPromise({ + try: () => handle.writeFile(input.contents), + catch: (cause) => + ioError(input.context, input.operation, "failed to write file", { + resolvedPath: input.target, + cause, + }), + }), + closeHandle, + ); + }); +} + +function dirEntryFor(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly absEntry: string; + readonly relativePath: string; + readonly name: string; +}): Effect.Effect { + return Effect.gen(function* () { + const realEntry = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.absEntry), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve directory entry", { + resolvedPath: input.absEntry, + cause, + }), + }).pipe(Effect.orElseSucceed(() => null)); + if (realEntry === null || !containsRealPath(input.realRoot, realEntry)) { + return null; + } + const stat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realEntry), + catch: (cause) => + ioError(input.context, input.operation, "failed to stat directory entry", { + resolvedPath: realEntry, + cause, + }), + }).pipe(Effect.orElseSucceed(() => null)); + if (stat === null) return null; + return { + name: input.name, + relativePath: input.relativePath, + type: statType(stat), + }; + }); +} + +function makeCapability(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; +}): FilesystemCapability { + const prepare = (context: FilesystemPathContext, operation: FilesystemOperation) => + Effect.gen(function* () { + const segments = yield* parseRelativePath(context, operation); + const root = yield* requireRoot({ ...input, context, operation }); + return { ...root, segments }; + }); + + const writeFileBytes = ( + context: FilesystemPathContext, + contents: Uint8Array, + exclusive: boolean, + operation: "write-file" | "create-file-exclusive", + ) => + Effect.gen(function* () { + const { realRoot, segments } = yield* prepare(context, operation); + const { realParent, leaf } = yield* resolveLeafParent({ + context, + operation, + realRoot, + segments, + createParent: true, + }); + yield* writeBytesToTarget({ + context, + operation, + target: NodePath.join(realParent, leaf), + contents, + exclusive, + }); + }); + + const removePath = ( + context: FilesystemPathContext, + realRoot: string, + absPath: string, + operation: FilesystemOperation, + ): Effect.Effect => + Effect.gen(function* () { + const lstat = yield* lstatOrNull(absPath, context, operation, "failed to inspect path"); + if (lstat === null) return; + if (lstat.isSymbolicLink()) { + yield* Effect.tryPromise({ + try: () => NodeFSP.unlink(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove symlink", { + resolvedPath: absPath, + cause, + }), + }); + return; + } + const realPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(absPath), + catch: (cause) => + ioError(context, operation, "failed to resolve path", { resolvedPath: absPath, cause }), + }); + if (!containsRealPath(realRoot, realPath)) { + return yield* pathError(context, operation, "path resolves outside root", { + resolvedRoot: realRoot, + resolvedPath: realPath, + }); + } + if (lstat.isDirectory()) { + const entries = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(absPath), + catch: (cause) => + ioError(context, operation, "failed to read directory", { + resolvedPath: absPath, + cause, + }), + }); + for (const entry of entries) { + yield* removePath(context, realRoot, NodePath.join(absPath, entry), operation); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.rmdir(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove directory", { + resolvedPath: absPath, + cause, + }), + }); + return; + } + yield* Effect.tryPromise({ + try: () => NodeFSP.unlink(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove file", { resolvedPath: absPath, cause }), + }); + }); + + return { + listRoots: () => + snapshotGrantedRoots(input).pipe( + Effect.map((roots) => [...roots].sort((left, right) => left.localeCompare(right))), + Effect.mapError((cause) => + ioError({ root: "", relativePath: "" }, "list-roots", "failed to read roots", { + cause, + }), + ), + ), + readFile: (context) => + Effect.gen(function* () { + const operation = "read-file" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + return yield* readExistingFile({ + context, + operation, + realPath, + maxBytes: FILE_MAX_BYTES, + failOnOverflow: true, + }); + }), + readFileString: (context) => + Effect.gen(function* () { + const bytes = yield* makeCapability(input).readFile(context); + return new TextDecoder().decode(bytes); + }), + readFileStringCapped: (context) => + Effect.gen(function* () { + const operation = "read-file-string-capped" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const maxBytes = Math.max(0, Math.min(Math.floor(context.maxBytes), FILE_MAX_BYTES)); + const bytes = yield* readExistingFile({ + context, + operation, + realPath, + maxBytes, + failOnOverflow: false, + }); + return new TextDecoder().decode(bytes); + }), + writeFile: (request) => writeFileBytes(request, request.contents, false, "write-file"), + writeFileString: (request) => + writeFileBytes(request, new TextEncoder().encode(request.contents), false, "write-file"), + createFileExclusive: (request) => + writeFileBytes( + request, + typeof request.contents === "string" + ? new TextEncoder().encode(request.contents) + : request.contents, + true, + "create-file-exclusive", + ), + exists: (context) => + Effect.gen(function* () { + const operation = "exists" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const logicalTarget = NodePath.join(context.root, ...segments); + const lstat = yield* lstatOrNull( + logicalTarget, + context, + operation, + "failed to inspect path", + ); + if (lstat === null) return false; + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + return containsRealPath(realRoot, realPath); + }), + stat: (context) => + Effect.gen(function* () { + const operation = "stat" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const stat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realPath), + catch: (cause) => + ioError(context, operation, "failed to stat path", { resolvedPath: realPath, cause }), + }); + return { + type: statType(stat), + size: stat.size, + mtime: stat.mtimeMs, + }; + }), + listDir: (context) => + Effect.gen(function* () { + const operation = "list-dir" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const stat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realPath), + catch: (cause) => + ioError(context, operation, "failed to stat directory", { + resolvedPath: realPath, + cause, + }), + }); + if (!stat.isDirectory()) { + return yield* ioError(context, operation, "path is not a directory", { + resolvedPath: realPath, + }); + } + const entries = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(realPath), + catch: (cause) => + ioError(context, operation, "failed to read directory", { + resolvedPath: realPath, + cause, + }), + }); + const results: DirEntry[] = []; + for (const name of entries.sort((left, right) => left.localeCompare(right))) { + const entry = yield* dirEntryFor({ + context, + operation, + realRoot, + absEntry: NodePath.join(realPath, name), + relativePath: outputRelativePath(context.relativePath, name), + name, + }); + if (entry) results.push(entry); + } + return results; + }), + listDirRecursive: (context) => + Effect.gen(function* () { + const operation = "list-dir-recursive" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const results: DirEntry[] = []; + const walk = (absDir: string, relativeDir: string): Effect.Effect => + Effect.gen(function* () { + if (results.length >= LIST_RECURSIVE_MAX_ENTRIES) return; + const entries = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(absDir), + catch: (cause) => + ioError(context, operation, "failed to read directory", { + resolvedPath: absDir, + cause, + }), + }); + for (const name of entries.sort((left, right) => left.localeCompare(right))) { + if (results.length >= LIST_RECURSIVE_MAX_ENTRIES) return; + const relPath = outputRelativePath(relativeDir, name); + const entry = yield* dirEntryFor({ + context, + operation, + realRoot, + absEntry: NodePath.join(absDir, name), + relativePath: relPath, + name, + }); + if (!entry) continue; + results.push(entry); + if (entry.type === "directory") { + const childRealPath = yield* realpathExistingTarget({ + context, + operation, + realRoot, + segments: relPath.split("/").filter(Boolean), + }); + yield* walk(childRealPath, relPath); + } + } + }); + yield* walk(realPath, context.relativePath); + return results; + }), + makeDirectory: (context) => + Effect.gen(function* () { + const operation = "make-directory" as const; + const { realRoot, segments } = yield* prepare(context, operation); + yield* resolveParent({ + context, + operation, + realRoot, + parentSegments: segments, + create: true, + }); + }), + remove: (context) => + Effect.gen(function* () { + const operation = "remove" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const { realParent, leaf } = yield* resolveLeafParent({ + context, + operation, + realRoot, + segments, + createParent: false, + }).pipe( + Effect.catch((error) => + isFilesystemIoError(error) && error.reason === "parent path does not exist" + ? Effect.succeed({ realParent: "", leaf: "" }) + : Effect.fail(error), + ), + ); + if (!leaf) return; + yield* removePath(context, realRoot, NodePath.join(realParent, leaf), operation); + }), + rename: (request) => + Effect.gen(function* () { + const operation = "rename" as const; + const fromContext = { root: request.root, relativePath: request.fromRelativePath }; + const toContext = { root: request.root, relativePath: request.toRelativePath }; + const { realRoot, segments: fromSegments } = yield* prepare(fromContext, operation); + const toSegments = yield* parseRelativePath(toContext, operation); + const from = yield* resolveLeafParent({ + context: fromContext, + operation, + realRoot, + segments: fromSegments, + createParent: false, + }); + const to = yield* resolveLeafParent({ + context: toContext, + operation, + realRoot, + segments: toSegments, + createParent: false, + }); + const fromAbs = NodePath.join(from.realParent, from.leaf); + const toAbs = NodePath.join(to.realParent, to.leaf); + const fromLstat = yield* Effect.tryPromise({ + try: () => NodeFSP.lstat(fromAbs), + catch: (cause) => + ioError(fromContext, operation, "failed to inspect source", { + resolvedPath: fromAbs, + cause, + }), + }); + if (!fromLstat.isSymbolicLink()) { + const fromReal = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(fromAbs), + catch: (cause) => + ioError(fromContext, operation, "failed to resolve source", { + resolvedPath: fromAbs, + cause, + }), + }); + if (!containsRealPath(realRoot, fromReal)) { + return yield* pathError(fromContext, operation, "source resolves outside root", { + resolvedRoot: realRoot, + resolvedPath: fromReal, + }); + } + } + const toExists = + (yield* lstatOrNull(toAbs, toContext, operation, "failed to inspect destination")) !== + null; + if (toExists) { + return yield* ioError(toContext, operation, "destination already exists", { + resolvedPath: toAbs, + }); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.rename(fromAbs, toAbs), + catch: (cause) => + ioError(fromContext, operation, "failed to rename path", { + fromResolvedPath: fromAbs, + toResolvedPath: toAbs, + cause, + }), + }); + }), + }; +} + +export const makeFilesystemCapability = makeCapability; diff --git a/apps/server/src/plugins/capabilities/HttpClientCapability.test.ts b/apps/server/src/plugins/capabilities/HttpClientCapability.test.ts new file mode 100644 index 00000000000..3cdeb8b1643 --- /dev/null +++ b/apps/server/src/plugins/capabilities/HttpClientCapability.test.ts @@ -0,0 +1,211 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + HttpClientError, + HttpEgressBlockedError, + makeHttpClientCapability, + type PluginHttpClientTransport, +} from "./HttpClientCapability.ts"; + +const encoder = new TextEncoder(); + +function responseFor(input: { + readonly url: URL; + readonly method: string; + readonly status?: number; + readonly headers?: Record; + readonly body?: string | Uint8Array | ArrayBuffer | null; +}) { + const request = HttpClientRequest.make(input.method as "GET")(input.url.toString()); + return HttpClientResponse.fromWeb( + request, + new Response(input.body ?? "", { + status: input.status ?? 200, + headers: input.headers ?? {}, + }), + ); +} + +function makeClient(input: { + readonly lookup?: (host: string) => Effect.Effect, Error>; + readonly transport?: PluginHttpClientTransport; + readonly calls?: Array<{ readonly host: string; readonly address: string }>; +}) { + return makeHttpClientCapability({ + lookup: input.lookup ?? (() => Effect.succeed(["140.82.112.3"])), + transport: + input.transport ?? + ((request) => + Effect.sync(() => { + input.calls?.push({ + host: request.url.hostname, + address: request.address.address, + }); + return responseFor({ + url: request.url, + method: request.method, + headers: { "x-transport": "stub" }, + body: "ok", + }); + })), + }); +} + +describe("HttpClientCapability", () => { + it.effect("rejects non-https and private egress before transport", () => + Effect.gen(function* () { + const calls: unknown[] = []; + const client = makeClient({ + lookup: () => Effect.succeed(["10.0.0.1"]), + transport: () => + Effect.sync(() => { + calls.push("transport"); + return responseFor({ url: new URL("https://never.test"), method: "GET" }); + }), + }); + + const httpError = yield* client + .request({ method: "GET", url: "http://example.test" }) + .pipe(Effect.flip); + const privateError = yield* client + .request({ method: "GET", url: "https://internal.test" }) + .pipe(Effect.flip); + + assert.instanceOf(httpError, HttpEgressBlockedError); + assert.instanceOf(privateError, HttpEgressBlockedError); + assert.deepEqual(calls, []); + }), + ); + + it.effect("pins the transport to the validated resolved address", () => + Effect.gen(function* () { + const calls: Array<{ readonly host: string; readonly address: string }> = []; + const client = makeClient({ + calls, + lookup: () => Effect.succeed(["140.82.112.3", "140.82.113.4"]), + }); + + const result = yield* client.request({ method: "GET", url: "https://github.com/api" }); + + assert.equal(result.status, 200); + assert.equal(new TextDecoder().decode(result.body), "ok"); + assert.deepEqual(calls, [{ host: "github.com", address: "140.82.112.3" }]); + }), + ); + + it.effect("rejects headers with control characters (CRLF injection) before transport", () => + Effect.gen(function* () { + const calls: Array<{ readonly host: string; readonly address: string }> = []; + const client = makeClient({ calls }); + + const result = yield* Effect.exit( + client.request({ + method: "GET", + url: "https://github.com/api", + headers: { "x-evil": "value\r\nx-injected: 1" }, + }), + ); + + assert.isTrue(result._tag === "Failure"); + assert.deepEqual(calls, []); + }), + ); + + it.effect("rejects an oversized request body before transport", () => + Effect.gen(function* () { + const calls: Array<{ readonly host: string; readonly address: string }> = []; + const client = makeClient({ calls }); + + const result = yield* Effect.exit( + client.request({ + method: "POST", + url: "https://github.com/api", + body: new Uint8Array(33 * 1024 * 1024), + }), + ); + + assert.isTrue(result._tag === "Failure"); + assert.deepEqual(calls, []); + }), + ); + + it.effect("surfaces redirects without following them", () => + Effect.gen(function* () { + const client = makeClient({ + transport: (request) => + Effect.succeed( + responseFor({ + url: request.url, + method: request.method, + status: 302, + headers: { location: "https://example.test/next" }, + }), + ), + }); + + const result = yield* client.request({ method: "GET", url: "https://example.test/start" }); + + assert.equal(result.status, 302); + assert.equal(result.headers.location, "https://example.test/next"); + }), + ); + + it.effect("enforces response caps and maps timeout/transport failures", () => + Effect.gen(function* () { + const tooLargeClient = makeClient({ + transport: (request) => + Effect.succeed( + responseFor({ + url: request.url, + method: request.method, + body: encoder.encode("abcdef").buffer, + }), + ), + }); + const timeoutClient = makeClient({ + transport: () => + Effect.fail(new HttpClientError({ host: "example.test", reason: "timeout" })), + }); + + const tooLarge = yield* tooLargeClient + .request({ + method: "GET", + url: "https://example.test/large", + maxResponseBytes: 3, + }) + .pipe(Effect.flip); + const timeout = yield* timeoutClient + .request({ method: "GET", url: "https://example.test/timeout", timeoutMs: 1 }) + .pipe(Effect.flip); + + assert.instanceOf(tooLarge, HttpClientError); + assert.include(tooLarge.message, "example.test"); + assert.instanceOf(timeout, HttpClientError); + }), + ); + + it.effect("allows http loopback only under T3_PLUGIN_DEV", () => + Effect.gen(function* () { + const previous = process.env.T3_PLUGIN_DEV; + const client = makeClient({ lookup: () => Effect.succeed(["127.0.0.1"]) }); + try { + delete process.env.T3_PLUGIN_DEV; + assert.instanceOf( + yield* client.request({ method: "GET", url: "http://localhost:5173" }).pipe(Effect.flip), + HttpEgressBlockedError, + ); + process.env.T3_PLUGIN_DEV = "1"; + const result = yield* client.request({ method: "GET", url: "http://localhost:5173" }); + assert.equal(result.status, 200); + } finally { + if (previous === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous; + } + } + }), + ); +}); diff --git a/apps/server/src/plugins/capabilities/HttpClientCapability.ts b/apps/server/src/plugins/capabilities/HttpClientCapability.ts new file mode 100644 index 00000000000..dbe129dfb78 --- /dev/null +++ b/apps/server/src/plugins/capabilities/HttpClientCapability.ts @@ -0,0 +1,310 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeHttp from "node:http"; +import * as NodeHttps from "node:https"; +import * as NodeStream from "node:stream"; + +import type { HttpClientCapability, HttpClientRequestInput } from "@t3tools/plugin-sdk"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + defaultLookup, + OutboundUrlError, + OutboundUrlValidator, + type ResolvedAddress, + type UrlValidatorDeps, +} from "../OutboundUrlValidator.ts"; +import { readHttpResponseBytesCapped } from "../readHttpResponseBytesCapped.ts"; + +const DEFAULT_RESPONSE_MAX_BYTES = 8 * 1024 * 1024; +const HARD_RESPONSE_MAX_BYTES = 32 * 1024 * 1024; +const REQUEST_BODY_MAX_BYTES = 32 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; +const HARD_TIMEOUT_MS = 120_000; +// Reject header names/values carrying CR/LF or other control chars so a plugin +// forwarding attacker-influenced data cannot inject/smuggle a second header. +const hasControlChars = (value: string): boolean => { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +}; + +export class HttpEgressBlockedError extends Schema.TaggedErrorClass()( + "HttpEgressBlockedError", + { + host: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `HTTP egress to '${this.host}' is blocked: ${this.reason}`; + } +} + +export class HttpClientError extends Schema.TaggedErrorClass()("HttpClientError", { + host: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), +}) { + override get message(): string { + return `HTTP request to '${this.host}' failed: ${this.reason}`; + } +} + +const isHttpClientError = Schema.is(HttpClientError); + +export interface PluginPinnedHttpRequest { + readonly url: URL; + readonly method: string; + readonly headers: Readonly>; + readonly body: Uint8Array | null; + readonly timeoutMs: number; + readonly address: ResolvedAddress; +} + +export type PluginHttpClientTransport = ( + request: PluginPinnedHttpRequest, +) => Effect.Effect; + +type HttpClientJsonRequestInput = Omit & { + readonly body?: unknown; +}; +type HttpClientGetJsonInput = Omit; + +export class PluginHttpClientTransportService extends Context.Service< + PluginHttpClientTransportService, + PluginHttpClientTransport +>()("t3/plugins/capabilities/HttpClientCapability/PluginHttpClientTransportService") {} + +const bodyToBytes = (body: HttpClientRequestInput["body"]): Uint8Array | null => { + if (body === undefined) return null; + return typeof body === "string" ? new TextEncoder().encode(body) : body; +}; + +const clampPositiveInteger = (value: number | undefined, fallback: number, hardMax: number) => { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.min(Math.floor(value), hardMax); +}; + +const hostForMessage = (rawUrl: string): string => { + try { + return new URL(rawUrl).hostname || rawUrl; + } catch { + return rawUrl; + } +}; + +const validateHeaders = ( + headers: Readonly> | undefined, + host: string, +): Effect.Effect>, HttpClientError> => { + if (!headers) return Effect.succeed({}); + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (hasControlChars(name) || hasControlChars(value)) { + return Effect.fail( + new HttpClientError({ host, reason: "request header contains control characters" }), + ); + } + normalized[name] = value; + } + return Effect.succeed(normalized); +}; + +function nodeHeadersToWebHeaders(headers: NodeHttp.IncomingHttpHeaders): Headers { + const webHeaders = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) webHeaders.append(name, item); + } else { + webHeaders.set(name, value); + } + } + return webHeaders; +} + +function makeResponse(input: { + readonly url: URL; + readonly method: string; + readonly response: NodeHttp.IncomingMessage; +}): HttpClientResponse.HttpClientResponse { + const request = HttpClientRequest.make(input.method as "GET")(input.url.toString()); + const body = NodeStream.Readable.toWeb(input.response) as ReadableStream; + return HttpClientResponse.fromWeb( + request, + new Response(body, { + status: input.response.statusCode ?? 0, + headers: nodeHeadersToWebHeaders(input.response.headers), + }), + ); +} + +const nodePinnedTransport: PluginHttpClientTransport = (input) => + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const client = input.url.protocol === "http:" ? NodeHttp : NodeHttps; + const request = client.request( + input.url, + { + method: input.method, + headers: input.headers, + timeout: input.timeoutMs, + lookup: (_hostname, _options, callback) => { + callback(null, input.address.address, input.address.family); + }, + }, + (response) => { + resolve(makeResponse({ url: input.url, method: input.method, response })); + }, + ); + request.on("timeout", () => { + request.destroy(new Error("timeout")); + }); + request.on("error", reject); + if (input.body) { + request.write(Buffer.from(input.body)); + } + request.end(); + }), + catch: (cause) => + new HttpClientError({ + host: input.url.hostname, + reason: cause instanceof Error && cause.message ? cause.message : "transport failed", + data: { cause }, + }), + }); + +export const PluginHttpClientTransportLive = Layer.succeed( + PluginHttpClientTransportService, + nodePinnedTransport, +); + +const parseJson = (bytes: Uint8Array, host: string): Effect.Effect => + Effect.try({ + // @effect-diagnostics-next-line preferSchemaOverJson:off -- SDK convenience wrapper returns caller-typed JSON. + try: () => JSON.parse(new TextDecoder().decode(bytes)) as A, + catch: (cause) => + new HttpClientError({ + host, + reason: "response body is not valid JSON", + data: { cause }, + }), + }); + +export function makeHttpClientCapability(input?: { + readonly lookup?: UrlValidatorDeps["lookup"] | undefined; + readonly transport?: PluginHttpClientTransport | undefined; +}): HttpClientCapability { + const lookup = input?.lookup ?? defaultLookup; + const transport = input?.transport ?? nodePinnedTransport; + + const request: HttpClientCapability["request"] = (requestInput) => + Effect.gen(function* () { + const host = hostForMessage(requestInput.url); + const resolved = yield* OutboundUrlValidator.resolve(requestInput.url, { + lookup, + allowHttpLoopback: process.env.T3_PLUGIN_DEV === "1", + }).pipe( + Effect.mapError( + (error: OutboundUrlError) => + new HttpEgressBlockedError({ + host, + reason: error.reason, + data: { cause: error }, + }), + ), + ); + const headers = yield* validateHeaders(requestInput.headers, host); + const requestBody = bodyToBytes(requestInput.body); + if (requestBody !== null && requestBody.byteLength > REQUEST_BODY_MAX_BYTES) { + return yield* new HttpClientError({ + host, + reason: "request body exceeded the size limit", + data: { limit: REQUEST_BODY_MAX_BYTES, actual: requestBody.byteLength }, + }); + } + const maxResponseBytes = clampPositiveInteger( + requestInput.maxResponseBytes, + DEFAULT_RESPONSE_MAX_BYTES, + HARD_RESPONSE_MAX_BYTES, + ); + const timeoutMs = clampPositiveInteger( + requestInput.timeoutMs, + DEFAULT_TIMEOUT_MS, + HARD_TIMEOUT_MS, + ); + const response = yield* transport({ + url: resolved.url, + method: requestInput.method.toUpperCase(), + headers, + body: requestBody, + timeoutMs, + address: resolved.addresses[0]!, + }); + const body = yield* readHttpResponseBytesCapped({ + response, + maxBytes: maxResponseBytes, + tooLarge: (actual) => + new HttpClientError({ + host: resolved.url.hostname, + reason: "response body exceeded the size limit", + data: { limit: maxResponseBytes, actual }, + }), + readFailed: (cause) => + isHttpClientError(cause) + ? cause + : new HttpClientError({ + host: resolved.url.hostname, + reason: "failed to read response body", + data: { cause }, + }), + }); + return { + status: response.status, + headers: response.headers, + body, + }; + }); + + return { + request, + requestJson: (jsonInput: HttpClientJsonRequestInput) => + Effect.gen(function* () { + const { body: jsonBody, ...requestRest } = jsonInput; + let body: string | undefined; + if (jsonBody !== undefined) { + // @effect-diagnostics-next-line preferSchemaOverJson:off -- SDK convenience wrapper accepts arbitrary JSON payloads. + body = JSON.stringify(jsonBody); + } + const response = yield* request({ + ...requestRest, + ...(body === undefined ? {} : { body }), + headers: { + accept: "application/json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + ...jsonInput.headers, + }, + }); + return yield* parseJson(response.body, hostForMessage(jsonInput.url)); + }), + getJson: (url: string, jsonInput: HttpClientGetJsonInput = {}) => + request({ + ...jsonInput, + method: "GET", + url, + headers: { + accept: "application/json", + ...jsonInput.headers, + }, + }).pipe(Effect.flatMap((response) => parseJson(response.body, hostForMessage(url)))), + }; +} diff --git a/apps/server/src/plugins/capabilities/VcsCapability.test.ts b/apps/server/src/plugins/capabilities/VcsCapability.test.ts index f00171192ed..252df6328bd 100644 --- a/apps/server/src/plugins/capabilities/VcsCapability.test.ts +++ b/apps/server/src/plugins/capabilities/VcsCapability.test.ts @@ -16,6 +16,7 @@ import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import * as ServerConfig from "../../config.ts"; +import { makePluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; import { makeVcsCapability, PluginVcsPathError } from "./VcsCapability.ts"; const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { @@ -95,7 +96,8 @@ it.layer(TestLayer)("VcsCapability", (it) => { yield* initRepoWithCommit(repo); const gitDriver = yield* GitVcsDriver.GitVcsDriver; const checkpointStore = yield* CheckpointStore.CheckpointStore; - const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + const grants = yield* makePluginWorkspaceGrants; + const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore, grants }); const rejected = yield* Effect.exit(vcs.status({ worktreePath: "relative/path" })); expect(rejected._tag).toBe("Failure"); @@ -110,6 +112,7 @@ it.layer(TestLayer)("VcsCapability", (it) => { newBranch: "feature/worktree", }); expect(created.worktree.path).toBe(worktreePath); + expect([...(yield* grants.snapshot())]).toContain(worktreePath); const listed = yield* vcs.listWorktrees({ repoRoot: repo }); const fileSystem = yield* FileSystem.FileSystem; @@ -120,6 +123,7 @@ it.layer(TestLayer)("VcsCapability", (it) => { expect(canonicalListedPaths.includes(canonicalWorktreePath)).toBe(true); yield* vcs.removeWorktree({ repoRoot: repo, path: worktreePath, force: true }); + expect([...(yield* grants.snapshot())]).not.toContain(worktreePath); const afterRemove = yield* vcs.listWorktrees({ repoRoot: repo }); const canonicalAfterRemovePaths = yield* Effect.forEach( afterRemove.worktrees, diff --git a/apps/server/src/plugins/capabilities/VcsCapability.ts b/apps/server/src/plugins/capabilities/VcsCapability.ts index c45eb1f7096..ea6913d5390 100644 --- a/apps/server/src/plugins/capabilities/VcsCapability.ts +++ b/apps/server/src/plugins/capabilities/VcsCapability.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import type { CheckpointStore } from "../../checkpointing/CheckpointStore.ts"; import type * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import type { PluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; export class PluginVcsPathError extends Schema.TaggedErrorClass()( "PluginVcsPathError", @@ -96,6 +97,7 @@ function gitCommandError(input: { export function makeVcsCapability(input: { readonly git: GitVcsDriver.GitVcsDriver["Service"]; readonly checkpoints: CheckpointStore["Service"]; + readonly grants?: PluginWorkspaceGrants | undefined; }): VcsCapability { const executeDiff = (request: { readonly worktreePath: string; @@ -136,13 +138,17 @@ export function makeVcsCapability(input: { Effect.gen(function* () { const cwd = yield* requireAbsolute("repoRoot", request.repoRoot); const path = yield* requireAbsolute("path", request.path); - return yield* input.git.createWorktree({ + const result = yield* input.git.createWorktree({ cwd, refName: request.ref, newRefName: request.newBranch, baseRefName: request.baseRef, path, }); + if (input.grants) { + yield* input.grants.grant(result.worktree.path ?? path); + } + return result; }), removeWorktree: (request) => @@ -154,6 +160,9 @@ export function makeVcsCapability(input: { path, force: request.force, }); + if (input.grants) { + yield* input.grants.revoke(path); + } }), createBranch: (request) => diff --git a/apps/server/src/plugins/readHttpResponseBytesCapped.ts b/apps/server/src/plugins/readHttpResponseBytesCapped.ts index 55e1d0e4fd5..b5081c37be7 100644 --- a/apps/server/src/plugins/readHttpResponseBytesCapped.ts +++ b/apps/server/src/plugins/readHttpResponseBytesCapped.ts @@ -1,16 +1,28 @@ -import { PluginManagementError } from "@t3tools/contracts/plugin"; import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import type { HttpClientResponse } from "effect/unstable/http"; -const isPluginManagementError = Schema.is(PluginManagementError); +interface CappedReadExpectedFailure { + readonly _tag: "CappedReadExpectedFailure"; + readonly error: E; +} -export const readHttpResponseBytesCapped = (input: { +const expectedFailure = (error: E): CappedReadExpectedFailure => ({ + _tag: "CappedReadExpectedFailure", + error, +}); + +const isExpectedFailure = (cause: unknown): cause is CappedReadExpectedFailure => + typeof cause === "object" && + cause !== null && + "_tag" in cause && + (cause as { readonly _tag?: unknown })._tag === "CappedReadExpectedFailure"; + +export const readHttpResponseBytesCapped = (input: { readonly response: HttpClientResponse.HttpClientResponse; readonly maxBytes: number; - readonly tooLarge: (observedBytes: number) => PluginManagementError; - readonly readFailed: (cause: unknown) => PluginManagementError; + readonly tooLarge: (observedBytes: number) => E; + readonly readFailed: (cause: unknown) => E; }) => input.response.stream.pipe( Stream.runFoldEffect( @@ -18,7 +30,7 @@ export const readHttpResponseBytesCapped = (input: { (acc, chunk) => { const total = acc.total + chunk.byteLength; if (total > input.maxBytes) { - return Effect.fail(input.tooLarge(total)); + return Effect.fail(expectedFailure(input.tooLarge(total))); } acc.chunks.push(chunk); return Effect.succeed({ chunks: acc.chunks, total }); @@ -33,5 +45,7 @@ export const readHttpResponseBytesCapped = (input: { } return bytes; }), - Effect.mapError((cause) => (isPluginManagementError(cause) ? cause : input.readFailed(cause))), + Effect.mapError((cause) => + isExpectedFailure(cause) ? cause.error : input.readFailed(cause), + ), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 69d720ef7fd..10986c5ea47 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; +import { PluginHttpClientTransportLive } from "./plugins/capabilities/HttpClientCapability.ts"; import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import { pluginHttpRouteLayer } from "./plugins/PluginHttpRoutes.ts"; import { pluginWebRouteLayer } from "./plugins/PluginWebRoutes.ts"; @@ -45,6 +46,7 @@ import * as PluginMigrator from "./plugins/PluginMigrator.ts"; import * as PluginModuleLoader from "./plugins/PluginModuleLoader.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as PluginRuntimeRegistry from "./plugins/PluginRuntimeRegistry.ts"; +import { OutboundUrlLookupLive } from "./plugins/OutboundUrlValidator.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; @@ -322,6 +324,8 @@ const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( TerminalLayerLive, ServerSecretStore.layer, ServerEnvironment.layer, + OutboundUrlLookupLive, + PluginHttpClientTransportLive, ); const PluginHostLayerLive = PluginHost.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayerLive), diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..df34fc4d651 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildRootGroups, buildThreadActionItems, + executeCommandPaletteActionItem, filterCommandPaletteGroups, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -163,3 +165,73 @@ describe("buildThreadActionItems", () => { expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); }); + +describe("plugin command palette group", () => { + it("omits the Plugins group when no plugin commands are registered", () => { + const actionItems = [ + { + kind: "action" as const, + value: "action:settings", + searchTerms: ["settings"], + title: "Open settings", + icon: null, + run: async () => undefined, + }, + ]; + + expect(buildRootGroups({ actionItems, recentThreadItems: [] })).toEqual( + buildRootGroups({ actionItems, recentThreadItems: [], pluginCommandItems: [] }), + ); + }); + + it("adds registered plugin commands as a searchable Plugins group", () => { + const run = vi.fn(async () => undefined); + const groups = buildRootGroups({ + actionItems: [], + recentThreadItems: [], + pluginCommandItems: [ + { + kind: "action", + value: "plugin:hello-board:refresh", + searchTerms: ["Refresh board", "hello-board", "refresh"], + title: "Refresh board", + description: "Reload plugin notes", + icon: null, + run, + }, + ], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.label).toBe("Plugins"); + const filtered = filterCommandPaletteGroups({ + activeGroups: groups, + query: "refresh", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + expect(filtered[0]?.items[0]?.value).toBe("plugin:hello-board:refresh"); + }); + + it("contains throwing command actions through the shared executor", async () => { + const onError = vi.fn(); + executeCommandPaletteActionItem( + { + kind: "action", + value: "plugin:hello-board:boom", + searchTerms: ["boom"], + title: "Boom", + icon: null, + run: async () => { + throw new Error("boom"); + }, + }, + onError, + ); + + await Promise.resolve(); + await Promise.resolve(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb1..8134fffcfd3 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -333,12 +333,16 @@ export function getCommandPaletteMode(input: { export function buildRootGroups(input: { actionItems: ReadonlyArray; + pluginCommandItems?: ReadonlyArray | undefined; recentThreadItems: ReadonlyArray; }): CommandPaletteGroup[] { const groups: CommandPaletteGroup[] = []; if (input.actionItems.length > 0) { groups.push({ value: "actions", label: "Actions", items: input.actionItems }); } + if ((input.pluginCommandItems?.length ?? 0) > 0) { + groups.push({ value: "plugins", label: "Plugins", items: input.pluginCommandItems ?? [] }); + } if (input.recentThreadItems.length > 0) { groups.push({ value: "recent-threads", @@ -349,6 +353,13 @@ export function buildRootGroups(input: { return groups; } +export function executeCommandPaletteActionItem( + item: CommandPaletteActionItem, + onError: (error: unknown) => void, +): void { + void item.run().catch(onError); +} + export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string { switch (mode) { case "root": diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..37474537bd9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -29,6 +29,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + PlugIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; @@ -96,6 +97,7 @@ import { buildProjectActionItems, buildRootGroups, buildThreadActionItems, + executeCommandPaletteActionItem, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, @@ -127,6 +129,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import { pluginUiRegistryAtom } from "../plugins/PluginUiHost"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -476,6 +479,7 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const pluginUiRegistry = useAtomValue(pluginUiRegistryAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -715,6 +719,21 @@ function OpenCommandPaletteDialog(props: { [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); + const pluginCommandItems = useMemo( + () => + pluginUiRegistry.commands.map((command) => ({ + kind: "action", + value: `plugin:${command.pluginId}:${command.id}`, + searchTerms: [command.title, command.description ?? "", command.pluginId, command.id], + title: command.title, + ...(command.description ? { description: command.description } : {}), + icon: , + run: async () => { + await command.run(command.context); + }, + })), + [pluginUiRegistry.commands], + ); function pushPaletteView(view: CommandPaletteView): void { setViewStack((previousViews) => [ @@ -1056,7 +1075,7 @@ function OpenCommandPaletteDialog(props: { }, }); - const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const rootGroups = buildRootGroups({ actionItems, pluginCommandItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; const activeGroups = @@ -1532,7 +1551,7 @@ function OpenCommandPaletteDialog(props: { setOpen(false); } - void item.run().catch((error: unknown) => { + executeCommandPaletteActionItem(item, (error: unknown) => { toastManager.add( stackedThreadToast({ type: "error", diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx index 18cec0f453b..03aa1237e8a 100644 --- a/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx +++ b/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx @@ -46,9 +46,7 @@ describe("Plugins settings logic", () => { expect(pluginRequiresRelaunch(plugin({ state: "disabled-by-host" }))).toBe(true); expect(pluginRequiresRelaunch(plugin({ state: "active" }))).toBe(false); - expect(effectiveInstallSourceId(ALL_PLUGIN_SOURCES_VALUE, [{ id: "src-one" }])).toBe( - "src-one", - ); + expect(effectiveInstallSourceId(ALL_PLUGIN_SOURCES_VALUE, [{ id: "src-one" }])).toBe("src-one"); expect( effectiveInstallSourceId(ALL_PLUGIN_SOURCES_VALUE, [{ id: "src-one" }, { id: "src-two" }]), ).toBeNull(); diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts b/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts index 71eb29070c4..315076cf22d 100644 --- a/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts +++ b/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts @@ -76,7 +76,7 @@ export function effectiveInstallSourceId( if (selectedSourceId !== ALL_PLUGIN_SOURCES_VALUE) { return selectedSourceId; } - return sources.length === 1 ? sources[0]?.id ?? null : null; + return sources.length === 1 ? (sources[0]?.id ?? null) : null; } export function humanErrorMessage(error: unknown, fallback = "The operation failed."): string { diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.tsx index 342bb2b1629..b9ec1299c2d 100644 --- a/apps/web/src/components/settings/plugins/PluginsSettings.tsx +++ b/apps/web/src/components/settings/plugins/PluginsSettings.tsx @@ -132,12 +132,8 @@ interface PluginSettingsCommands { readonly abortInstall: ( input: PluginInstallConfirmInput, ) => Promise>; - readonly setEnabled: ( - input: PluginSetEnabledInput, - ) => Promise>; - readonly uninstall: ( - input: PluginUninstallInput, - ) => Promise>; + readonly setEnabled: (input: PluginSetEnabledInput) => Promise>; + readonly uninstall: (input: PluginUninstallInput) => Promise>; readonly beginUpgrade: ( input: PluginUpgradeBeginInput, ) => Promise>; @@ -537,7 +533,12 @@ function CatalogEntryRow({
{entry.author ? (

- By {entry.author.url ? {entry.author.name} : entry.author.name} + By{" "} + {entry.author.url ? ( + {entry.author.name} + ) : ( + entry.author.name + )}

) : null} @@ -654,7 +655,9 @@ function ConsentDialog({ !open && onCancel()}> - {actionLabel} {stagedAction?.entryName ?? "plugin"} + + {actionLabel} {stagedAction?.entryName ?? "plugin"} + Review the capabilities this plugin requests before continuing. @@ -662,7 +665,9 @@ function ConsentDialog({ {capabilityDescriptions.length === 0 ? ( -

This plugin does not request host capabilities.

+

+ This plugin does not request host capabilities. +

) : (
{capabilityDescriptions.map(([capability, description]) => ( @@ -675,9 +680,7 @@ function ConsentDialog({ )} - }> - Cancel - + }>Cancel + + {status === "loading" + ? "Calling workflow.listNeedsAttentionTickets…" + : status === "error" + ? "RPC failed" + : status === "ok" + ? count !== null + ? `RPC ok — ${count} ticket(s) need attention` + : "RPC ok" + : ""} + +
+ {error !== null ? ( +
+            {error}
+          
+ ) : null} + {status === "ok" ? ( +
{JSON.stringify(result, null, 2)}
+ ) : null} +
+ +
+
Plugin context
+
+          {JSON.stringify(
+            {
+              pluginId,
+              path,
+              location: typeof window !== "undefined" ? window.location.pathname : null,
+            },
+            null,
+            2,
+          )}
+        
+
+ + ); +} + +export default defineWebPlugin({ + register: (ctx) => { + ctx.registerRoute({ + path: "boards", + component: (props) => ( + + ), + }); + ctx.registerSidebarSection({ + id: "workflow-boards", + title: "Workflow Boards", + render: ({ routeBasePath }) => ( +
+ Boards + + ), + }); + }, +}); From c0e830034a5d437c92532d4bc5eadb387c4a1226 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sat, 4 Jul 2026 15:16:34 -0400 Subject: [PATCH 54/75] feat(plugin-sdk-web): surface the host modules the board UI needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-export the remaining host surface consumed by the workflow-boards board UI so it can import everything from `@t3tools/plugin-sdk-web`: - lib/utils (cn, randomUUID) - hooks/useTheme (useTheme), hooks/useSettings (usePrimarySettings) - session-logic (formatDuration) - state/server (primaryServerProvidersAtom) - providerInstances (derive/sortProviderInstanceEntries) - modelSelection (getAppModelOptionsForInstance, AppModelOption) - the diff stack: DiffStatLabel, diffRendering helpers, and FileDiff from @pierre/diffs/react (added as a dependency so the barrel type- resolves; it re-uses the host's worker-pool-aware copy at runtime) These are thin re-exports of live host modules — a separately-built plugin externalises the SDK and shares the host's singletons through the import map, so no duplicate React/atoms/provider state. Board-domain helpers (e.g. nextDefaultBoardName) are intentionally NOT re-exported here — they belong plugin-local. plugin-sdk-web typecheck + tests green. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- packages/plugin-sdk-web/package.json | 1 + packages/plugin-sdk-web/src/index.ts | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/plugin-sdk-web/package.json b/packages/plugin-sdk-web/package.json index d5ea5d0a938..045e3d44555 100644 --- a/packages/plugin-sdk-web/package.json +++ b/packages/plugin-sdk-web/package.json @@ -14,6 +14,7 @@ "test": "vp test run" }, "dependencies": { + "@pierre/diffs": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*" diff --git a/packages/plugin-sdk-web/src/index.ts b/packages/plugin-sdk-web/src/index.ts index b33150a66e2..0f679910204 100644 --- a/packages/plugin-sdk-web/src/index.ts +++ b/packages/plugin-sdk-web/src/index.ts @@ -65,6 +65,38 @@ export { export { useAtomCommand } from "../../../apps/web/src/state/use-atom-command.ts"; export { useAtomQueryRunner } from "../../../apps/web/src/state/use-atom-query-runner.ts"; +// Host surface consumed by the workflow-boards board UI (and available to any +// plugin). These are re-exports of live host modules — a separately-built plugin +// externalises `@t3tools/plugin-sdk-web`, so at runtime it shares the host's +// singleton instances (React, atoms, settings, provider state) through the import +// map rather than bundling its own copies. +export { cn, randomUUID } from "../../../apps/web/src/lib/utils.ts"; +export { useTheme } from "../../../apps/web/src/hooks/useTheme.ts"; +export { usePrimarySettings } from "../../../apps/web/src/hooks/useSettings.ts"; +export { formatDuration } from "../../../apps/web/src/session-logic.ts"; +export { primaryServerProvidersAtom } from "../../../apps/web/src/state/server.ts"; +export { + deriveProviderInstanceEntries, + sortProviderInstanceEntries, +} from "../../../apps/web/src/providerInstances.ts"; +export { + getAppModelOptionsForInstance, + type AppModelOption, +} from "../../../apps/web/src/modelSelection.ts"; +// Diff-rendering stack (ticket diffs). `FileDiff` comes from `@pierre/diffs/react` +// (the host already depends on it for chat diffs) and relies on the host's +// worker-pool context provider being mounted around the app. +export { DiffStatLabel } from "../../../apps/web/src/components/chat/DiffStatLabel.tsx"; +export { + buildFileDiffRenderKey, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, + type RenderablePatch, + type DiffThemeName, +} from "../../../apps/web/src/lib/diffRendering.ts"; +export { FileDiff } from "@pierre/diffs/react"; + export const hostCompat = { hostApiVersion: HOST_API_VERSION, importMapExternals: pluginSdkWebExternalDependencies, From 7268fe50353b3c758da6061cbb04535f93e22a8a Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sat, 4 Jul 2026 15:27:38 -0400 Subject: [PATCH 55/75] feat(workflow-boards): plugin-local workflow data layer over the RPC bridge The host no longer carries any workflow client state (it moved into this plugin), so re-implement the board UI's data layer on the plugin RPC bridge instead of the deleted client-runtime workflow atoms: - workflowApi.ts: the `WorkflowApi` facade (extracted from the host's former `EnvironmentApi["workflow"]`) plus `createWorkflowApi(rpc)`, which maps every method to `rpc.call("workflow.", input)` and folds `subscribeBoard` off `rpc.subscribe`. The board component tree is prop-drilled with this facade and keeps working unchanged; the server plugin already exposes all 46 `workflow.*` methods, so the mapping is 1:1. - boardState.ts: the pure `applyBoardStreamItem` reducer, ported verbatim from the host's former client-runtime board-state module. - useBoardState.ts: folds the live `workflow.subscribeBoard` stream through that reducer into an atom the board route reads with `useAtomValue` (replacing the host's `workflowEnvironment.board(...)`). Fixture typecheck green. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- fixtures/workflow-boards/web/boardState.ts | 167 ++++++++++ fixtures/workflow-boards/web/useBoardState.ts | 62 ++++ fixtures/workflow-boards/web/workflowApi.ts | 312 ++++++++++++++++++ 3 files changed, 541 insertions(+) create mode 100644 fixtures/workflow-boards/web/boardState.ts create mode 100644 fixtures/workflow-boards/web/useBoardState.ts create mode 100644 fixtures/workflow-boards/web/workflowApi.ts diff --git a/fixtures/workflow-boards/web/boardState.ts b/fixtures/workflow-boards/web/boardState.ts new file mode 100644 index 00000000000..f6f1b31430d --- /dev/null +++ b/fixtures/workflow-boards/web/boardState.ts @@ -0,0 +1,167 @@ +// Pure board-stream reducer. Ported verbatim from the host's former +// client-runtime `state/boardState.ts` (the host no longer carries workflow +// client state — it moved into this plugin). Folds a `BoardStreamItem` stream +// (`workflow.subscribeBoard`) into a render-ready `BoardState`. See +// ./useBoardState.ts for the atom/hook that drives it off the plugin RPC bridge. +import type { BoardStreamItem } from "../contracts/workflow.ts"; + +export interface BoardState { + readonly projectId: string | null; + readonly boardId: string | null; + readonly boardName: string; + readonly lanes: ReadonlyArray<{ + readonly key: string; + readonly name: string; + readonly entry: string; + readonly pipelineStepCount: number; + readonly wipLimit?: number | undefined; + readonly terminal?: boolean | undefined; + readonly actions?: + | ReadonlyArray<{ + readonly label: string; + readonly to: string; + readonly hint?: string | undefined; + }> + | undefined; + readonly admittedTicketIds: ReadonlyArray; + readonly queuedTicketIds: ReadonlyArray; + }>; + readonly ticketIds: ReadonlyArray; + readonly ticketById: Record< + string, + { + readonly ticketId: string; + readonly title: string; + readonly description?: string | undefined; + readonly currentLaneKey: string; + readonly status: string; + readonly queuedAt?: string | undefined; + readonly totalTokens?: number | undefined; + readonly unresolvedDependencyCount?: number | undefined; + readonly tokenBudget?: number | undefined; + readonly updatedAt?: string | undefined; + readonly totalDurationMs?: number | undefined; + readonly pr?: + | { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly ciState?: "pending" | "success" | "failure" | undefined; + } + | undefined; + } + >; +} + +export const emptyBoardState: BoardState = { + projectId: null, + boardId: null, + boardName: "", + lanes: [], + ticketIds: [], + ticketById: {}, +}; + +const isQueuedTicket = (ticket: BoardState["ticketById"][string]): boolean => + ticket.status === "queued" || ticket.queuedAt !== undefined; + +const buildLaneGroups = ( + lanes: BoardState["lanes"], + ticketIds: ReadonlyArray, + ticketById: BoardState["ticketById"], +): BoardState["lanes"] => + lanes.map((lane) => { + const admittedTicketIds: string[] = []; + const queuedTicketIds: string[] = []; + for (const ticketId of ticketIds) { + const ticket = ticketById[ticketId]; + if (!ticket || ticket.currentLaneKey !== lane.key) { + continue; + } + if (isQueuedTicket(ticket)) { + queuedTicketIds.push(ticketId); + } else { + admittedTicketIds.push(ticketId); + } + } + + return { + ...lane, + admittedTicketIds, + queuedTicketIds, + }; + }); + +export const applyBoardStreamItem = (state: BoardState, item: BoardStreamItem): BoardState => { + if (item.kind === "snapshot") { + const ticketById: BoardState["ticketById"] = {}; + for (const ticket of item.snapshot.tickets) { + ticketById[ticket.ticketId] = { + ticketId: ticket.ticketId, + title: ticket.title, + ...(ticket.description === undefined ? {} : { description: ticket.description }), + currentLaneKey: ticket.currentLaneKey, + status: ticket.status, + ...(ticket.queuedAt === undefined ? {} : { queuedAt: ticket.queuedAt }), + ...(ticket.totalTokens === undefined ? {} : { totalTokens: ticket.totalTokens }), + ...(ticket.unresolvedDependencyCount === undefined + ? {} + : { unresolvedDependencyCount: ticket.unresolvedDependencyCount }), + ...(ticket.tokenBudget === undefined ? {} : { tokenBudget: ticket.tokenBudget }), + ...(ticket.updatedAt === undefined ? {} : { updatedAt: ticket.updatedAt }), + ...(ticket.totalDurationMs === undefined + ? {} + : { totalDurationMs: ticket.totalDurationMs }), + ...(ticket.pr === undefined ? {} : { pr: ticket.pr }), + }; + } + const ticketIds = item.snapshot.tickets.map((ticket) => ticket.ticketId); + const lanes = buildLaneGroups( + item.snapshot.board.lanes.map((lane) => ({ + ...lane, + admittedTicketIds: [], + queuedTicketIds: [], + })), + ticketIds, + ticketById, + ); + + return { + projectId: item.snapshot.projectId, + boardId: item.snapshot.board.boardId, + boardName: item.snapshot.board.name, + lanes, + ticketIds, + ticketById, + }; + } + + const ticket = item.ticket; + const exists = state.ticketById[ticket.ticketId] !== undefined; + const ticketIds = exists ? state.ticketIds : [...state.ticketIds, ticket.ticketId]; + const ticketById = { + ...state.ticketById, + [ticket.ticketId]: { + ticketId: ticket.ticketId, + title: ticket.title, + ...(ticket.description === undefined ? {} : { description: ticket.description }), + currentLaneKey: ticket.currentLaneKey, + status: ticket.status, + ...(ticket.queuedAt === undefined ? {} : { queuedAt: ticket.queuedAt }), + ...(ticket.totalTokens === undefined ? {} : { totalTokens: ticket.totalTokens }), + ...(ticket.unresolvedDependencyCount === undefined + ? {} + : { unresolvedDependencyCount: ticket.unresolvedDependencyCount }), + ...(ticket.tokenBudget === undefined ? {} : { tokenBudget: ticket.tokenBudget }), + ...(ticket.updatedAt === undefined ? {} : { updatedAt: ticket.updatedAt }), + ...(ticket.totalDurationMs === undefined ? {} : { totalDurationMs: ticket.totalDurationMs }), + ...(ticket.pr === undefined ? {} : { pr: ticket.pr }), + }, + }; + return { + ...state, + lanes: buildLaneGroups(state.lanes, ticketIds, ticketById), + ticketIds, + ticketById, + }; +}; diff --git a/fixtures/workflow-boards/web/useBoardState.ts b/fixtures/workflow-boards/web/useBoardState.ts new file mode 100644 index 00000000000..ae759515bb1 --- /dev/null +++ b/fixtures/workflow-boards/web/useBoardState.ts @@ -0,0 +1,62 @@ +// Live board state for the board route. +// +// The host's former `workflowEnvironment.board(...)` folded the +// `workflow.subscribeBoard` stream into a `BoardState` through a +// `createEnvironmentRpcSubscriptionAtomFamily`. That host machinery is gone, so +// here we fold the same stream — obtained through the plugin RPC bridge — with +// the ported `applyBoardStreamItem` reducer, exposed as an atom the board route +// reads with `useAtomValue`. + +import { + AsyncResult, + Atom, + getConnectionAtomRuntime, + type PluginWebRpc, + useAtomValue, +} from "@t3tools/plugin-sdk-web"; +import * as Stream from "effect/Stream"; +import { useMemo } from "react"; + +import { WORKFLOW_WS_METHODS } from "../contracts/workflow.ts"; +import type { BoardStreamItem } from "../contracts/workflow.ts"; +import { applyBoardStreamItem, emptyBoardState, type BoardState } from "./boardState.ts"; + +// Stable fallback for "no board selected" so the hook can call `useAtomValue` +// unconditionally (React hook rules) without opening a subscription. +const idleBoardStateAtom = Atom.make(() => AsyncResult.initial()); + +/** + * Build an atom that folds the live board subscription into `BoardState`. Each + * `boardId` gets its own atom (its own subscription); mounting/unmounting is + * handled by the atom registry when the hook (un)mounts. + */ +export function makeBoardStateAtom( + rpc: PluginWebRpc, + boardId: string, +): Atom.Atom> { + const runtime = getConnectionAtomRuntime(); + const folded = rpc + .subscribe(WORKFLOW_WS_METHODS.subscribeBoard, { boardId }) + .pipe( + Stream.scan(emptyBoardState, (state, item) => + applyBoardStreamItem(state, item as BoardStreamItem), + ), + ); + return runtime.atom(folded); +} + +/** + * Subscribe to a board's live folded state. Returns `AsyncResult` + * (Initial while the first snapshot is in flight, Success once folded). Pass + * `null` when no board is selected. + */ +export function useBoardState( + rpc: PluginWebRpc, + boardId: string | null, +): AsyncResult.AsyncResult { + const atom = useMemo( + () => (boardId === null ? idleBoardStateAtom : makeBoardStateAtom(rpc, boardId)), + [rpc, boardId], + ); + return useAtomValue(atom); +} diff --git a/fixtures/workflow-boards/web/workflowApi.ts b/fixtures/workflow-boards/web/workflowApi.ts new file mode 100644 index 00000000000..4154f56962b --- /dev/null +++ b/fixtures/workflow-boards/web/workflowApi.ts @@ -0,0 +1,312 @@ +// Plugin-local workflow data layer. +// +// The board component tree is prop-drilled with an `api: WorkflowApi` facade +// (the shape that used to be `readEnvironmentApi(env).workflow` on the host). +// The host no longer carries any workflow client state — it was moved into this +// plugin — so we re-back the SAME facade with the plugin RPC bridge: every method +// maps to `rpc.call("workflow.", input)`, and `subscribeBoard` folds the +// `workflow.subscribeBoard` stream. The server plugin exposes all of these +// methods (see ../contracts/workflow.ts `WORKFLOW_WS_METHODS`), so the mapping is +// 1:1 and the board UI keeps working unchanged. + +import { + AsyncResult, + getAppAtomRegistry, + getConnectionAtomRuntime, + type PluginWebRpc, +} from "@t3tools/plugin-sdk-web"; +import type { MessageId, ProjectId } from "@t3tools/contracts"; + +import { WORKFLOW_WS_METHODS } from "../contracts/workflow.ts"; +import type { + AgentSelection, + BoardId, + BoardListEntry, + BoardSnapshot, + BoardStreamItem, + LaneKey, + StepRunId, + TicketAttachment, + TicketDiff, + TicketId, + WorkflowBoardDigest, + WorkflowBoardMetrics, + WorkflowBoardVersionSummary, + WorkflowCreateBoardInput, + WorkflowCreateWorkflowBoardInput, + WorkflowCreateWorkflowBoardResult, + WorkflowDefinitionEncoded, + WorkflowDryRunResult, + WorkflowDryRunScenario, + WorkflowGenerateWorkflowDraftInput, + WorkflowGenerateWorkflowDraftResult, + WorkflowGetBoardDefinitionResult, + WorkflowGetBoardProposalResult, + WorkflowGetBoardVersionResult, + WorkflowImportBoardInput, + WorkflowImportBoardResult, + WorkflowIntakeResult, + WorkflowListBoardProposalsResult, + WorkflowListBoardTemplatesResult, + WorkflowProposeBoardImprovementInput, + WorkflowProposeBoardImprovementResult, + WorkflowRenameBoardInput, + WorkflowResolveBoardProposalInput, + WorkflowResolveBoardProposalResult, + WorkflowRevertBoardProposalResult, + WorkflowSaveBoardDefinitionInput, + WorkflowSaveBoardDefinitionResult, + WorkflowTicketArtifactsResult, + WorkflowTicketDetailView, + WorkflowWebhookConfig, + WorkSourceConnectionView, + WorkSourceProviderName, +} from "../contracts/workflow.ts"; +import type { + CreateOutboundConnectionInput, + OutboundConnectionView, +} from "../contracts/outbound.ts"; +import type { + ImportWorkItemsResult, + ListImportableWorkItemsResult, +} from "../contracts/workSource.ts"; + +/** + * The board UI facade. Extracted from the host's former + * `EnvironmentApi["workflow"]` (packages/contracts ipc.ts) so the prop-drilled + * board component tree keeps its `api: WorkflowApi` typing without depending on + * any host workflow types. + */ +export interface WorkflowApi { + listBoards: (input: { readonly projectId: ProjectId }) => Promise>; + createBoard: ( + input: WorkflowCreateBoardInput, + ) => Promise<{ readonly boardId: BoardId; readonly snapshot: BoardSnapshot }>; + importBoard: (input: WorkflowImportBoardInput) => Promise; + createWorkflowBoard: ( + input: WorkflowCreateWorkflowBoardInput, + ) => Promise; + generateWorkflowDraft: ( + input: WorkflowGenerateWorkflowDraftInput, + ) => Promise; + listBoardTemplates: (input: {}) => Promise; + deleteBoard: (input: { readonly boardId: BoardId }) => Promise; + renameBoard: (input: WorkflowRenameBoardInput) => Promise; + getBoard: (input: { readonly boardId: BoardId }) => Promise; + getBoardDefinition: (input: { + readonly boardId: BoardId; + }) => Promise; + saveBoardDefinition: ( + input: WorkflowSaveBoardDefinitionInput, + ) => Promise; + listBoardVersions: (input: { + readonly boardId: BoardId; + }) => Promise>; + getBoardVersion: (input: { + readonly boardId: BoardId; + readonly versionId: number; + }) => Promise; + subscribeBoard: ( + input: { readonly boardId: BoardId }, + callback: (event: BoardStreamItem) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; + createTicket: (input: { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: LaneKey; + readonly dependsOn?: ReadonlyArray | undefined; + readonly tokenBudget?: number | undefined; + }) => Promise<{ readonly ticketId: TicketId }>; + editTicket: (input: { + readonly ticketId: TicketId; + readonly title?: string | undefined; + readonly description?: string | undefined; + readonly dependsOn?: ReadonlyArray | undefined; + readonly tokenBudget?: number | null | undefined; + }) => Promise; + moveTicket: (input: { readonly ticketId: TicketId; readonly toLane: LaneKey }) => Promise; + runLane: (input: { readonly ticketId: TicketId }) => Promise; + resolveApproval: (input: { + readonly stepRunId: StepRunId; + readonly approved: boolean; + }) => Promise; + answerTicketStep: (input: { + readonly stepRunId: StepRunId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }) => Promise; + postTicketMessage: (input: { + readonly ticketId: TicketId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }) => Promise; + editTicketMessage: (input: { + readonly ticketId: TicketId; + readonly messageId: MessageId; + readonly body: string; + }) => Promise; + setProjectScriptTrust: (input: { + readonly projectId: ProjectId; + readonly trusted: boolean; + }) => Promise; + cancelStep: (input: { readonly stepRunId: StepRunId }) => Promise; + getTicketDetail: (input: { readonly ticketId: TicketId }) => Promise; + getTicketDiff: (input: { readonly ticketId: TicketId }) => Promise; + intakeTickets: (input: { + readonly boardId: BoardId; + readonly braindump: string; + readonly agent: AgentSelection; + }) => Promise; + listTicketArtifacts: (input: { + readonly ticketId: TicketId; + }) => Promise; + getWebhookConfig: (input: { + readonly boardId: BoardId; + readonly rotate?: boolean | undefined; + }) => Promise; + getBoardDigest: (input: { + readonly boardId: BoardId; + readonly windowHours?: number | undefined; + }) => Promise; + getBoardMetrics: (input: { + readonly boardId: BoardId; + readonly windowDays?: number | undefined; + }) => Promise; + dryRunBoard: (input: { + readonly definition: WorkflowDefinitionEncoded; + readonly startLane: LaneKey; + readonly scenario: WorkflowDryRunScenario; + }) => Promise; + listWorkSourceConnections: ( + input: Record, + ) => Promise>; + createWorkSourceConnection: (input: { + readonly provider: WorkSourceProviderName; + readonly displayName: string; + readonly token: string; + readonly authMode?: "pat" | "basic" | "bearer"; + readonly baseUrl?: string; + readonly email?: string; + }) => Promise; + deleteWorkSourceConnection: (input: { readonly connectionRef: string }) => Promise; + listOutboundConnections: ( + input: Record, + ) => Promise<{ readonly connections: ReadonlyArray }>; + createOutboundConnection: ( + input: CreateOutboundConnectionInput, + ) => Promise<{ readonly connection: OutboundConnectionView }>; + deleteOutboundConnection: (input: { readonly connectionRef: string }) => Promise; + proposeBoardImprovement: ( + input: WorkflowProposeBoardImprovementInput, + ) => Promise; + listBoardProposals: (input: { + readonly boardId: BoardId; + }) => Promise; + getBoardProposal: (input: { + readonly proposalId: string; + }) => Promise; + resolveBoardProposal: ( + input: WorkflowResolveBoardProposalInput, + ) => Promise; + revertBoardProposal: (input: { + readonly proposalId: string; + }) => Promise; + listImportableWorkItems: (input: { + readonly boardId: BoardId; + }) => Promise; + importWorkItems: (input: { + readonly boardId: BoardId; + readonly sourceId: string; + readonly externalIds: ReadonlyArray; + readonly destinationLane?: LaneKey; + }) => Promise; +} + +/** + * Raw board subscription: mount an atom over the `workflow.subscribeBoard` stream + * on the host's connection runtime and forward each emitted `BoardStreamItem` to + * the callback. Mirrors the host's former `subscribeBoardRaw` (registry.mount + + * registry.subscribe on the raw stream atom). Returns an unsubscribe that both + * detaches the listener and unmounts the atom so the stream fiber is interrupted. + */ +function subscribeBoardRaw( + rpc: PluginWebRpc, + input: { readonly boardId: BoardId }, + callback: (event: BoardStreamItem) => void, +): () => void { + const runtime = getConnectionAtomRuntime(); + const registry = getAppAtomRegistry(); + const stream = rpc.subscribe(WORKFLOW_WS_METHODS.subscribeBoard, input); + const atom = runtime.atom(stream); + const unmount = registry.mount(atom); + const unsubscribe = registry.subscribe(atom, (result) => { + if (AsyncResult.isSuccess(result)) { + callback(result.value as BoardStreamItem); + } + }); + return () => { + unsubscribe(); + unmount(); + }; +} + +/** + * Build a `WorkflowApi` facade backed by the plugin RPC bridge. Every unary + * method resolves `rpc.call("workflow.", input)`; `subscribeBoard` folds + * the live board stream. + */ +export function createWorkflowApi(rpc: PluginWebRpc): WorkflowApi { + const call = (method: string, input: unknown): Promise => rpc.call(method, input) as Promise; + const M = WORKFLOW_WS_METHODS; + return { + listBoards: (input) => call(M.listBoards, input), + createBoard: (input) => call(M.createBoard, input), + importBoard: (input) => call(M.importBoard, input), + createWorkflowBoard: (input) => call(M.createWorkflowBoard, input), + generateWorkflowDraft: (input) => call(M.generateWorkflowDraft, input), + listBoardTemplates: (input) => call(M.listBoardTemplates, input), + deleteBoard: (input) => call(M.deleteBoard, input), + renameBoard: (input) => call(M.renameBoard, input), + getBoard: (input) => call(M.getBoard, input), + getBoardDefinition: (input) => call(M.getBoardDefinition, input), + saveBoardDefinition: (input) => call(M.saveBoardDefinition, input), + listBoardVersions: (input) => call(M.listBoardVersions, input), + getBoardVersion: (input) => call(M.getBoardVersion, input), + subscribeBoard: (input, callback) => subscribeBoardRaw(rpc, input, callback), + createTicket: (input) => call(M.createTicket, input), + editTicket: (input) => call(M.editTicket, input), + moveTicket: (input) => call(M.moveTicket, input), + runLane: (input) => call(M.runLane, input), + resolveApproval: (input) => call(M.resolveApproval, input), + answerTicketStep: (input) => call(M.answerTicketStep, input), + postTicketMessage: (input) => call(M.postTicketMessage, input), + editTicketMessage: (input) => call(M.editTicketMessage, input), + setProjectScriptTrust: (input) => call(M.setProjectScriptTrust, input), + cancelStep: (input) => call(M.cancelStep, input), + getTicketDetail: (input) => call(M.getTicketDetail, input), + getTicketDiff: (input) => call(M.getTicketDiff, input), + intakeTickets: (input) => call(M.intakeTickets, input), + listTicketArtifacts: (input) => call(M.listTicketArtifacts, input), + getWebhookConfig: (input) => call(M.getWebhookConfig, input), + getBoardDigest: (input) => call(M.getBoardDigest, input), + getBoardMetrics: (input) => call(M.getBoardMetrics, input), + dryRunBoard: (input) => call(M.dryRunBoard, input), + listWorkSourceConnections: (input) => call(M.listWorkSourceConnections, input), + createWorkSourceConnection: (input) => call(M.createWorkSourceConnection, input), + deleteWorkSourceConnection: (input) => call(M.deleteWorkSourceConnection, input), + listOutboundConnections: (input) => call(M.listOutboundConnections, input), + createOutboundConnection: (input) => call(M.createOutboundConnection, input), + deleteOutboundConnection: (input) => call(M.deleteOutboundConnection, input), + proposeBoardImprovement: (input) => call(M.proposeBoardImprovement, input), + listBoardProposals: (input) => call(M.listBoardProposals, input), + getBoardProposal: (input) => call(M.getBoardProposal, input), + resolveBoardProposal: (input) => call(M.resolveBoardProposal, input), + revertBoardProposal: (input) => call(M.revertBoardProposal, input), + listImportableWorkItems: (input) => call(M.listImportableWorkItems, input), + importWorkItems: (input) => call(M.importWorkItems, input), + }; +} From d8e0179f3bfba66212e7765db2f6a61cb2f311a3 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sat, 4 Jul 2026 15:31:33 -0400 Subject: [PATCH 56/75] docs(workflow-boards): turnkey B3 board-UI port plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the complete execution plan for porting the 58-file / 17.5k-LOC board UI into the plugin web bundle: source→destination file map, the per-prefix import-rewrite rules (host surface → plugin-sdk-web, board-domain → relative plugin-local, per-symbol @t3tools/contracts split board-vs-generic), the useWorkflowApi shim, plugin route/sidebar registration, build+tsconfig deltas, the live verification harness, and known risks (FileDiff worker-pool context, route project context, Tailwind class emission). B1 (SDK gaps) + B2 (data layer) already shipped. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .plans/sp-b3-board-ui-port.md | 127 ++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 .plans/sp-b3-board-ui-port.md diff --git a/.plans/sp-b3-board-ui-port.md b/.plans/sp-b3-board-ui-port.md new file mode 100644 index 00000000000..2f6bf67b100 --- /dev/null +++ b/.plans/sp-b3-board-ui-port.md @@ -0,0 +1,127 @@ +# SP-B / B3 — Port the board UI into the plugin web bundle + +**Status:** B1 (SDK gaps) + B2 (data layer) DONE + committed + typecheck-green. Plumbing +verified live end-to-end. B3 (this doc) = the mechanical 58-file UI port. B4 = live E2E verify. + +**Goal:** Move the fork's board UI (`~/Developer/t3code` on `ft/hyperion`, 17,470 LOC / 58 files) +into `fixtures/workflow-boards/web/`, rewire imports, register the route + sidebar, build. + +--- + +## Source (fork) → destination (plugin) file map + +Fork root `apps/web/src`; plugin root `fixtures/workflow-boards/web`. + +- `workflow/*.ts` → `web/workflow/*.ts` — EXCEPT these (already replaced in B2, do NOT copy): + - `useWorkflowApi.ts` → replaced by `web/workflowApi.ts` (`createWorkflowApi`) + - `boardState.ts` (9-line re-export) → replaced by `web/boardState.ts` + `web/useBoardState.ts` +- `state/workflow.ts` (`workflowEnvironment`) → replaced by `web/useBoardState.ts` (folded board) + + `web/workflowApi.ts` (imperative facade). Do NOT copy. +- `components/board/*.tsx` → `web/components/board/*.tsx` +- `components/board/editor/**` → `web/components/board/editor/**` +- `routes/_chat.$environmentId.board.tsx` → `web/boardRoute.tsx` (adapted: see "Route registration") +- Copy `.test.*` files too (fixture test runner is `vp test run`); skip `__screenshots__/`. + +`nextDefaultBoardName` (from fork `components/Sidebar.logic.ts:260`) is board-domain and NOT in the +worktree host → copy that ONE function into `web/boardLocalUtils.ts` and import it there. + +## Import rewrite rules (apply to every copied file) + +Host surface → **`@t3tools/plugin-sdk-web`** (all already re-exported by B1; multiple import +statements from the SDK in one file are fine): +- `~/components/ui/*` +- `~/components/chat/ProviderModelPicker`, `~/components/chat/TraitsPicker`, `~/components/chat/DiffStatLabel` +- `~/components/ChatMarkdown` +- `~/lib/utils` (cn, randomUUID), `~/lib/diffRendering` +- `~/hooks/useSettings` (usePrimarySettings), `~/hooks/useTheme` (useTheme) +- `~/session-logic` (formatDuration), `~/state/server` (primaryServerProvidersAtom) +- `~/providerInstances`, `~/modelSelection` +- `@pierre/diffs/react` `FileDiff` → `@t3tools/plugin-sdk-web` (B1 re-exports it) + +Plugin-local (relative paths — script them with `node path.relative`, they vary by file depth): +- `~/workflow/X` → relative to `web/workflow/X` +- `~/components/board/X` → relative to `web/components/board/X` +- `~/workflow/useWorkflowApi` → `web/workflowApi` (`useWorkflowApi` shim, see below) +- `~/state/workflow` (`workflowEnvironment`) + `~/workflow/boardState` → `web/useBoardState` + `web/boardState` +- `~/components/Sidebar.logic` (`nextDefaultBoardName`) → `web/boardLocalUtils` + +`@t3tools/contracts` — **per-symbol split** (board types were removed from the worktree host and now +live in the fixture). Board-specific types (the 21 set below) → `../contracts/workflow.ts` (or +`outbound.ts` / `workSource.ts`); generic types stay `@t3tools/contracts`: +- BOARD (→ fixture): AutoPullCriteria, BoardId, BoardListEntry, BoardStreamItem, ImportableWorkItemView, + LaneKey, StepKey, summarizeAutoPull, TicketId, TicketDiff (aliased TicketDiffData), WorkflowBoardDigest, + WorkflowBoardMetrics, WorkflowDefinition, WorkflowDefinitionEncoded, WorkflowDryRunHop, WorkflowDryRunResult, + WorkflowLintError, WorkflowSourceConfig, WorkflowTicketArtifact, WorkflowWebhookConfig, WorkSourceProviderName + (+ the full facade I/O set — all 46 confirmed present in fixture contracts) + - `outbound.ts`: CreateOutboundConnectionInput, OutboundConnectionView + - `workSource.ts`: WorkSourceConnectionView, ImportWorkItemsResult, ListImportableWorkItemsResult +- GENERIC (keep `@t3tools/contracts`): EnvironmentApi (see route note), EnvironmentId, ProjectId, + ProviderInstanceId, ProviderOptionSelection, ScopedProjectRef, MessageId + +`@t3tools/client-runtime/*` — 2 leaf files use it (`workflow/boardListState.ts`, +`workflow/resolveRecentAgent.ts`) plus the route (`state/shell`, `state/runtime`, `state/board-state`). +Inspect each: `state/board-state` reducer is already ported (`web/boardState.ts`); `state/runtime` +(executeAtomQuery/runAtomCommand) is available via SDK (`useAtomCommand`/`useAtomQueryRunner`) or +`getConnectionAtomRuntime`; `state/shell` usage TBD per-call. Redirect to SDK/plugin-local equivalents. + +`EnvironmentApi["workflow"]` → the plugin-local `WorkflowApi` type from `web/workflowApi.ts`. Grep the +board files for `EnvironmentApi["workflow"]` / `type WorkflowApi =` and repoint to `web/workflowApi`. + +## `useWorkflowApi` shim + +Board components call `useWorkflowApi(environmentId)`. In the plugin the RPC is the plugin's +`PluginWebRpc` (from the route component's `ctx.rpc` / `PluginRouteComponentProps`), not env-keyed. +Add to `web/workflowApi.ts`: +```ts +export function useWorkflowApi(rpc: PluginWebRpc): WorkflowApi { + return useMemo(() => createWorkflowApi(rpc), [rpc]); +} +``` +The route obtains `rpc` from the plugin route props and prop-drills `api` into the tree (unchanged). + +## Board route → plugin route registration + +Fork route `_chat.$environmentId.board.tsx` reads `environmentId` from TanStack route params + a +`boardId` search param, calls `useWorkflowApi(environmentId)` + `useEnvironmentQuery(workflowEnvironment.board(...))`. +Adapt into a plugin route component registered via `defineWebPlugin` `registerRoute({ path: "boards", component })`: +- `rpc` ← plugin route props; `api = useWorkflowApi(rpc)`. +- folded board ← `useBoardState(rpc, boardId)` (replaces `useEnvironmentQuery(workflowEnvironment.board(...))`). +- `boardId` selection: plugin routes get `location`/`path` (see Phase-0 fixture `PluginRouteComponentProps`); + derive `boardId` from a query param or in-plugin state. Board LIST selection may need a small local router. +- Sidebar section (`registerSidebarSection`) lists boards (via `api.listBoards`) linking to + `${routeBasePath}/boards?board=` — reuse the Phase-0 sidebar pattern (already live-verified). + +## Build + tsconfig + +- `fixtures/workflow-boards/scripts/build.mjs`: the web esbuild step already externalizes + `@effect/atom-react`, `@t3tools/plugin-sdk-web`, `effect`, `effect/*`, `react*`. Add any new externals + only if needed (e.g. keep `@pierre/diffs` OUT — it comes via the SDK). Ensure the entry stays + `web/index.tsx` and it imports the ported route/sidebar. +- `fixtures/workflow-boards/tsconfig.json`: already has DOM lib + react-jsx + `~/*`→apps/web paths (for + the SDK's apps/web re-exports). The copied board files must NOT rely on `~/*`→apps/web (that would pull + host copies) — they use rewritten imports, so this is fine. Add `web/**/*.tsx` is already included. +- Verify: `pnpm --filter @t3tools/fixture-workflow-boards run typecheck` (tsgo) → 0 errors. + +## Verification (B4) + +Reuse the live harness proven this session: +1. `pnpm --filter @t3tools/web exec vp build` (static web). +2. `node apps/server/src/bin.ts start --auto-bootstrap-project-from-cwd --base-dir --port 13902 --no-browser` with `T3_PLUGIN_DEV=1`. +3. Install+activate the rebuilt fixture tarball (or hot-swap the extracted `web/` dir), fresh Chrome + `--remote-debugging-port=9222` at the pair URL. +4. Drive over CDP (clear cache before reload — versioned plugin asset path caches): navigate to + `//p/workflow-boards/boards`, assert the real board renders, create a board, open a ticket + drawer, edit the workflow definition, confirm live `subscribeBoard` updates. +5. Gates: fixture typecheck, web+server typecheck, `plugins.test.ts`, `plugin-sdk-web` test. + +## Known risks / watch-items + +- `FileDiff` (TicketDiff) needs the host's `DiffWorkerPoolProvider` mounted around the app; if the plugin + route isn't inside it, ticket diffs won't render — degrade gracefully or confirm the provider wraps + `PluginUiHost`. (Low priority — one component.) +- Plugin routes don't yet receive project/environment context beyond `location` — board-list needs a + `projectId` for `listBoards`. Derive from the route env segment (`//p/...`) or add context to + `PluginRouteComponentProps` (host change). This is the main open design point for the route. +- Tailwind: plugin ships no compiled CSS; the board UI uses host utility classes + CSS vars. The host + build scans host source only, so classes used ONLY by the plugin won't be emitted. Board UI mostly + reuses host components (which carry their classes) — spot-check after first render. From 5d2a94d67371052204a0358264b34b0804570a50 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sat, 4 Jul 2026 15:44:09 -0400 Subject: [PATCH 57/75] feat(plugins): pass environmentId + search to plugin route components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin route components only received `{ pluginId, path }`, so a plugin route could not read the active environment id or its own navigation state (search params) without hacking `window.location`. The board UI plugin needs both — `environmentId` to scope host-side project/ticket references and `?boardId=`/`?ticket=` to drive its single-board view. Extend `PluginRouteComponentProps` with `environmentId: string | null` and `search: Record`, and have the host plugin-route (`/_chat/$environmentId/p/$pluginId/$`) pass the route param + normalized search through. Navigation stays ``-based off the sidebar `routeBasePath`. web + plugin-sdk-web typecheck green; PluginUiHost tests pass. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .../routes/_chat.$environmentId.p.$pluginId.$.tsx | 15 +++++++++++++++ packages/plugin-sdk-web/src/index.ts | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx index fbf45e6796e..d6240717383 100644 --- a/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx +++ b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx @@ -30,8 +30,21 @@ function PluginRouteNotFound() { ); } +function normalizeSearch(search: Record): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(search)) { + if (typeof value === "string") { + normalized[key] = value; + } else if (typeof value === "number" || typeof value === "boolean") { + normalized[key] = String(value); + } + } + return normalized; +} + function PluginRouteView() { const params = Route.useParams(); + const search = Route.useSearch() as Record; const snapshot = useAtomValue(pluginUiRegistryAtom); const pluginId = PluginId.make(params.pluginId); const route = resolvePluginRouteRegistration(snapshot, pluginId, splatFromParams(params)); @@ -51,6 +64,8 @@ function PluginRouteView() { {createElement(route.component as FunctionComponent, { pluginId: route.pluginId, path: route.path, + environmentId: typeof params.environmentId === "string" ? params.environmentId : null, + search: normalizeSearch(search), })} diff --git a/packages/plugin-sdk-web/src/index.ts b/packages/plugin-sdk-web/src/index.ts index 0f679910204..7b7cd49b888 100644 --- a/packages/plugin-sdk-web/src/index.ts +++ b/packages/plugin-sdk-web/src/index.ts @@ -132,6 +132,14 @@ export type PluginComponent> = (props: Props) => u export interface PluginRouteComponentProps { readonly pluginId: PluginId; readonly path: string; + // The active environment id from the route (`//p//...`), + // or null if unavailable. Plugin routes need it to scope host-side references + // (e.g. project/ticket cwd) even though `rpc` is already environment-bound. + readonly environmentId: string | null; + // The route's search params (string values), so a plugin route can read its own + // navigation state (e.g. `?boardId=...&ticket=...`) without touching the host + // router. Navigate by rendering `` off the sidebar `routeBasePath`. + readonly search: Readonly>; } export interface PluginRouteRegistration { From 1142f78a4e1d1f1e86c2e2666082c4ca20275cf8 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sat, 4 Jul 2026 16:16:03 -0400 Subject: [PATCH 58/75] feat(workflow-boards): port the board UI into the plugin web bundle (B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the full board UI (~17.5k LOC, 55 files) from the host into the plugin web bundle. Host `~/...` imports are rewired to the plugin-sdk-web barrel (UI/util/diff/provider surface), plugin-local relative paths (board-domain), or the fixture contracts (board types). The TanStack board route becomes a plugin route component driven by injected `rpc`/`api`, plugin search params, and `useBoardState`; index.tsx registers the `boards` route + a Workflow Boards sidebar section. Built by a codex pass over the pre-copied files, then reviewed (Claude + an independent adversarial pass) and fixed: - Sidebar board list now resolves the environment's REAL project(s) via `useEnvironmentProjectRefs` and aggregates their boards, instead of mis-branding the environment id as a project id (which silently listed nothing). `useEnvironmentProjectRefs` is newly re-exported from the SDK. - Documented the intentional plugin degradations vs the host: live orchestration/terminal panes (agent-activity feed, agent-session dialog, script-output viewer) render empty because the RPC bridge exposes only `workflow.*`, not host orchestration/terminal RPCs; `resolveRecentAgent` falls back to the default agent (no host composer/thread state); ticket cwd is unresolved (no project workspace lookup yet). - Kept `terminal.attach(..., restartIfNotRunning:false)` — the worktree's post-#2978 terminal API has no `attachHistory`. - Annotated the two benign `as never` casts (SDK types the subscription Effect context as `unknown`; runtime-safe on the host connection runtime). Gates: fixture typecheck 0 errors, web bundle builds (947kb), web + plugin-sdk-web typecheck green. Live board render/RPC verification (B4) next. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- fixtures/workflow-boards/tsconfig.json | 10 +- .../workflow-boards/web/boardLocalUtils.ts | 17 + fixtures/workflow-boards/web/boardRoute.tsx | 878 ++++++++ .../components/board/AddFromIssuesDialog.tsx | 407 ++++ .../components/board/AgentSessionDialog.tsx | 199 ++ .../components/board/BoardDigestDialog.tsx | 185 ++ .../components/board/BoardHeaderControls.tsx | 583 ++++++ .../components/board/BoardMetricsDialog.tsx | 398 ++++ .../web/components/board/BoardView.tsx | 105 + .../components/board/CreateWorkflowDialog.tsx | 1250 +++++++++++ .../components/board/ImportBoardDialog.tsx | 269 +++ .../web/components/board/IntakeDialog.tsx | 362 ++++ .../web/components/board/LaneColumn.tsx | 82 + .../board/MarkdownComposerField.tsx | 114 ++ .../components/board/SelfImproveDialog.tsx | 922 +++++++++ .../web/components/board/StepActivityFeed.tsx | 107 + .../web/components/board/TicketArtifacts.tsx | 89 + .../web/components/board/TicketCard.tsx | 202 ++ .../web/components/board/TicketDiff.tsx | 142 ++ .../web/components/board/TicketDrawer.tsx | 1822 +++++++++++++++++ .../components/board/WebhookConfigDialog.tsx | 207 ++ .../board/editor/AutoPullCriteriaEditor.tsx | 273 +++ .../components/board/editor/DryRunPanel.tsx | 150 ++ .../web/components/board/editor/LaneForm.tsx | 288 +++ .../web/components/board/editor/LaneList.tsx | 81 + .../board/editor/OutboundSection.tsx | 501 +++++ .../board/editor/PipelineEditor.tsx | 164 ++ .../components/board/editor/RoutingEditor.tsx | 427 ++++ .../components/board/editor/SourceWizard.tsx | 909 ++++++++ .../board/editor/SourcesSection.tsx | 313 +++ .../components/board/editor/StepFields.tsx | 876 ++++++++ .../board/editor/WorkflowEditor.tsx | 707 +++++++ .../board/editor/WorkflowEditorFullscreen.tsx | 132 ++ .../board/editor/agentStepSelection.ts | 91 + .../board/editor/canvas/CanvasView.tsx | 580 ++++++ .../board/editor/canvas/LaneCard.tsx | 239 +++ .../board/editor/canvas/RoutingEdges.tsx | 730 +++++++ .../board/editor/canvas/RoutingHandles.tsx | 218 ++ .../board/editor/canvas/StepBlock.tsx | 163 ++ .../board/editor/canvas/canvasLayout.ts | 226 ++ .../board/editor/canvas/edgeRouting.ts | 235 +++ .../board/editor/history/DiffView.tsx | 126 ++ .../editor/history/VersionHistoryPanel.tsx | 218 ++ .../components/board/editor/selectorDraft.ts | 124 ++ fixtures/workflow-boards/web/index.tsx | 271 ++- fixtures/workflow-boards/web/useBoardState.ts | 7 +- .../web/workflow/agingFormat.ts | 43 + .../web/workflow/boardListState.ts | 40 + .../workflow-boards/web/workflow/boardRpc.ts | 124 ++ .../web/workflow/downloadJson.ts | 14 + .../web/workflow/dryRunFormat.ts | 36 + .../web/workflow/editorModel.ts | 591 ++++++ .../web/workflow/importPicker.ts | 54 + .../web/workflow/intakeState.ts | 73 + .../web/workflow/jiraConnectionForm.ts | 66 + .../web/workflow/resolveRecentAgent.ts | 74 + .../web/workflow/routeDecision.ts | 103 + .../web/workflow/usageFormat.ts | 49 + .../web/workflow/useNowTick.ts | 16 + fixtures/workflow-boards/web/workflowApi.ts | 28 +- packages/plugin-sdk-web/src/index.ts | 4 + 61 files changed, 17565 insertions(+), 149 deletions(-) create mode 100644 fixtures/workflow-boards/web/boardLocalUtils.ts create mode 100644 fixtures/workflow-boards/web/boardRoute.tsx create mode 100644 fixtures/workflow-boards/web/components/board/AddFromIssuesDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/AgentSessionDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/BoardDigestDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/BoardHeaderControls.tsx create mode 100644 fixtures/workflow-boards/web/components/board/BoardMetricsDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/BoardView.tsx create mode 100644 fixtures/workflow-boards/web/components/board/CreateWorkflowDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/ImportBoardDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/IntakeDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/LaneColumn.tsx create mode 100644 fixtures/workflow-boards/web/components/board/MarkdownComposerField.tsx create mode 100644 fixtures/workflow-boards/web/components/board/SelfImproveDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/StepActivityFeed.tsx create mode 100644 fixtures/workflow-boards/web/components/board/TicketArtifacts.tsx create mode 100644 fixtures/workflow-boards/web/components/board/TicketCard.tsx create mode 100644 fixtures/workflow-boards/web/components/board/TicketDiff.tsx create mode 100644 fixtures/workflow-boards/web/components/board/TicketDrawer.tsx create mode 100644 fixtures/workflow-boards/web/components/board/WebhookConfigDialog.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/AutoPullCriteriaEditor.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/DryRunPanel.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/LaneForm.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/LaneList.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/OutboundSection.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/PipelineEditor.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/RoutingEditor.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/SourceWizard.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/SourcesSection.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/StepFields.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/WorkflowEditor.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/WorkflowEditorFullscreen.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/agentStepSelection.ts create mode 100644 fixtures/workflow-boards/web/components/board/editor/canvas/CanvasView.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/canvas/LaneCard.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/canvas/RoutingEdges.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/canvas/RoutingHandles.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/canvas/StepBlock.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/canvas/canvasLayout.ts create mode 100644 fixtures/workflow-boards/web/components/board/editor/canvas/edgeRouting.ts create mode 100644 fixtures/workflow-boards/web/components/board/editor/history/DiffView.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/history/VersionHistoryPanel.tsx create mode 100644 fixtures/workflow-boards/web/components/board/editor/selectorDraft.ts create mode 100644 fixtures/workflow-boards/web/workflow/agingFormat.ts create mode 100644 fixtures/workflow-boards/web/workflow/boardListState.ts create mode 100644 fixtures/workflow-boards/web/workflow/boardRpc.ts create mode 100644 fixtures/workflow-boards/web/workflow/downloadJson.ts create mode 100644 fixtures/workflow-boards/web/workflow/dryRunFormat.ts create mode 100644 fixtures/workflow-boards/web/workflow/editorModel.ts create mode 100644 fixtures/workflow-boards/web/workflow/importPicker.ts create mode 100644 fixtures/workflow-boards/web/workflow/intakeState.ts create mode 100644 fixtures/workflow-boards/web/workflow/jiraConnectionForm.ts create mode 100644 fixtures/workflow-boards/web/workflow/resolveRecentAgent.ts create mode 100644 fixtures/workflow-boards/web/workflow/routeDecision.ts create mode 100644 fixtures/workflow-boards/web/workflow/usageFormat.ts create mode 100644 fixtures/workflow-boards/web/workflow/useNowTick.ts diff --git a/fixtures/workflow-boards/tsconfig.json b/fixtures/workflow-boards/tsconfig.json index 7237064e026..590ad319318 100644 --- a/fixtures/workflow-boards/tsconfig.json +++ b/fixtures/workflow-boards/tsconfig.json @@ -16,13 +16,21 @@ "erasableSyntaxOnly": false, "verbatimModuleSyntax": false, "paths": { - "~/*": ["../../apps/web/src/*"] + "~/*": ["../../apps/web/src/*"], + "lucide-react": ["../../apps/web/node_modules/lucide-react"], + "@dnd-kit/core": ["../../apps/web/node_modules/@dnd-kit/core"], + "@dnd-kit/sortable": ["../../apps/web/node_modules/@dnd-kit/sortable"], + "@dnd-kit/utilities": ["../../apps/web/node_modules/@dnd-kit/utilities"], + "class-variance-authority": ["../../apps/web/node_modules/class-variance-authority"], + "react-dom": ["../../apps/web/node_modules/@types/react-dom"], + "react-dom/*": ["../../apps/web/node_modules/@types/react-dom/*"] }, "plugins": [{ "name": "@effect/language-service" }] }, "include": [ "server/**/*.ts", "contracts/**/*.ts", + "web/**/*.ts", "web/**/*.tsx", "../../apps/web/src/*.d.ts" ] diff --git a/fixtures/workflow-boards/web/boardLocalUtils.ts b/fixtures/workflow-boards/web/boardLocalUtils.ts new file mode 100644 index 00000000000..13f7d86c834 --- /dev/null +++ b/fixtures/workflow-boards/web/boardLocalUtils.ts @@ -0,0 +1,17 @@ +// Board-domain helpers that are plugin-local (not part of the host surface). +// `nextDefaultBoardName` was ported from the host's `components/Sidebar.logic.ts` +// because it is board-specific and lives only in this plugin. + +export function nextDefaultBoardName(existingNames: readonly string[]): string { + const existing = new Set(existingNames); + const baseName = "Workflow board"; + if (!existing.has(baseName)) { + return baseName; + } + for (let index = 2; ; index += 1) { + const candidate = `${baseName} ${index}`; + if (!existing.has(candidate)) { + return candidate; + } + } +} diff --git a/fixtures/workflow-boards/web/boardRoute.tsx b/fixtures/workflow-boards/web/boardRoute.tsx new file mode 100644 index 00000000000..7d0ba43b5f4 --- /dev/null +++ b/fixtures/workflow-boards/web/boardRoute.tsx @@ -0,0 +1,878 @@ +// @effect-diagnostics globalDate:off globalTimers:off +import { + AsyncResult, + Button, + Input, + Sheet, + SheetPopup, + SidebarInset, + SidebarTrigger, + stackedThreadToast, + toastManager, + type PluginRouteComponentProps, + type PluginWebRpc, +} from "@t3tools/plugin-sdk-web"; +import type { WorkflowEnvironmentApi as EnvironmentApi } from "./workflowApi"; +import { + BoardId, + LaneKey, + StepRunId, + TicketId +} from "../contracts/workflow.ts"; +import type { + AgentSelection, + BoardSnapshot, + TicketAttachment, + WorkflowDefinitionEncoded, + WorkflowTicketDetailView +} from "../contracts/workflow.ts"; +import { + EnvironmentId, + MessageId, + ProjectId +} from "@t3tools/contracts"; + +import { DatabaseIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { BoardHeaderControls } from "./components/board/BoardHeaderControls"; +import { BoardView } from "./components/board/BoardView"; +import { WorkflowEditor } from "./components/board/editor/WorkflowEditor"; +import { WorkflowEditorFullscreen } from "./components/board/editor/WorkflowEditorFullscreen"; +import { TicketDrawer } from "./components/board/TicketDrawer"; +import { countNeedsAttention } from "./workflow/agingFormat"; +import { useNowTick } from "./workflow/useNowTick"; +import { emptyBoardState, type BoardState } from "./boardState"; +import { + answerTicketStep, + createTicket, + editTicket, + editTicketMessage, + moveTicket, + postTicketMessage, + resolveApproval, + subscribeBoard, +} from "./workflow/boardRpc"; +import { useBoardState } from "./useBoardState"; +import type { WorkflowApi } from "./workflowApi"; + +const RIGHT_PANEL_SHEET_CLASS_NAME = + "w-[min(42vw,28rem)] min-w-80 max-w-[28rem] p-0 max-[760px]:w-[min(88vw,24rem)] max-[760px]:min-w-0 wco:mt-[env(titlebar-area-height)] wco:h-[calc(100%-env(titlebar-area-height))] wco:max-h-[calc(100%-env(titlebar-area-height))]"; + +function RightPanelSheet(props: { + readonly children: ReactNode; + readonly open: boolean; + readonly onClose: () => void; +}) { + return ( + { + if (!open) { + props.onClose(); + } + }} + > + + {props.children} + + + ); +} + +export interface BoardRouteSearch { + readonly boardId?: string | undefined; + /** Deep-link target: opens this ticket's drawer on load (notifications/webhooks). */ + readonly ticket?: string | undefined; +} + +export interface BoardRouteProps extends PluginRouteComponentProps { + readonly rpc: PluginWebRpc; + readonly api: WorkflowApi; +} + +export interface BoardRouteEmptyState { + readonly title: string; + readonly description: string | null; +} + +export function getBoardRouteEmptyState(input: { + readonly boardId: BoardId | null; + readonly boardLoadError: string | null; +}): BoardRouteEmptyState | null { + if (!input.boardId) { + return { + title: "No board selected.", + description: null, + }; + } + + if (input.boardLoadError) { + return { + title: "Board not found.", + description: input.boardLoadError, + }; + } + + return null; +} + +const parseBoardRouteSearch = (search: Record): BoardRouteSearch => { + const boardId = typeof search.boardId === "string" ? search.boardId.trim() : ""; + const ticket = typeof search.ticket === "string" ? search.ticket.trim() : ""; + return { ...(boardId ? { boardId } : {}), ...(ticket ? { ticket } : {}) }; +}; + +export interface BoardRouteAnswerInput { + readonly stepRunId: string; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; +} + +export interface BoardRouteEditInput { + readonly ticketId: string; + readonly title?: string | undefined; + readonly description?: string | undefined; +} + +export interface BoardRouteMessageEditInput { + readonly ticketId: string; + readonly messageId: string; + readonly body: string; +} + +const environmentApiUnavailable = () => new Error("Environment API unavailable."); + +// Max consecutive 2s polls while waiting for a running agent step's dispatch +// thread to appear (~30s). Bounds the self-re-arming detail poll so a stalled +// dispatch can't refetch getTicketDetail forever for every open drawer. +const MAX_THREAD_POLL_ATTEMPTS = 15; + +export const submitTicketAnswerFromBoardRoute = ( + api: Pick | null | undefined, + input: BoardRouteAnswerInput, + reloadTicketDetail: () => void, +): Promise => { + if (!api) { + return Promise.reject(environmentApiUnavailable()); + } + + return answerTicketStep(api as EnvironmentApi, { + stepRunId: StepRunId.make(input.stepRunId), + ...(input.text === undefined ? {} : { text: input.text }), + ...(input.attachments === undefined ? {} : { attachments: input.attachments }), + }).then(reloadTicketDetail); +}; + +export const submitTicketEditFromBoardRoute = ( + api: Pick | null | undefined, + input: BoardRouteEditInput, + reloadTicketDetail: () => void, +): Promise => { + if (!api) { + return Promise.reject(environmentApiUnavailable()); + } + + return editTicket(api as EnvironmentApi, { + ticketId: TicketId.make(input.ticketId), + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.description === undefined ? {} : { description: input.description }), + }).then(reloadTicketDetail); +}; + +export const submitTicketMessageEditFromBoardRoute = ( + api: Pick | null | undefined, + input: BoardRouteMessageEditInput, + reloadTicketDetail: () => void, +): Promise => { + if (!api) { + return Promise.reject(environmentApiUnavailable()); + } + + return editTicketMessage(api as EnvironmentApi, { + ticketId: TicketId.make(input.ticketId), + messageId: MessageId.make(input.messageId), + body: input.body, + }).then(reloadTicketDetail); +}; + +export function BoardRoute(props: BoardRouteProps) { + const { rpc, api } = props; + const rawEnvironmentId = props.environmentId; + const { boardId: rawBoardId, ticket: rawTicket } = parseBoardRouteSearch(props.search); + const [selectedTicketId, setSelectedTicketId] = useState(null); + const [ticketDetail, setTicketDetail] = useState(null); + const [ticketDetailError, setTicketDetailError] = useState(null); + const [ticketDetailReloadKey, setTicketDetailReloadKey] = useState(0); + const [boardLoadError, setBoardLoadError] = useState(null); + const [boardHasSources, setBoardHasSources] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); + // Incremented each time the board-level "Set up a source" CTA is clicked. + // Passed to WorkflowEditor so it can open the Sources wizard on mount. + const [editorSourcesTrigger, setEditorSourcesTrigger] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const ticketStatusRef = useRef(new Map()); + const selectedTicketIdRef = useRef(null); + selectedTicketIdRef.current = selectedTicketId; + const lastDetailTicketIdRef = useRef(null); + // Bounds the "wait for the dispatch thread" poll below so a step that never + // gets a providerThreadId (stalled dispatch) can't re-poll getTicketDetail + // every 2s for the lifetime of the open drawer. + const threadPollAttemptsRef = useRef(0); + const environmentId = useMemo( + () => (rawEnvironmentId ? EnvironmentId.make(rawEnvironmentId) : null), + [rawEnvironmentId], + ); + const boardId = useMemo(() => (rawBoardId ? BoardId.make(rawBoardId) : null), [rawBoardId]); + + // Full EnvironmentApi-shaped object for child components that expect the wide type. + // Only `workflow` is populated: the plugin RPC bridge exposes the `workflow.*` + // methods, but NOT host `orchestration`/`terminal` RPCs. So the live "Agent + // activity" feed, the agent-session dialog, and the script-output viewer render + // empty (their `if (!api?.orchestration)` / `if (!api?.terminal)` guards hold). + // Restoring them needs the SDK to expose orchestration.subscribeThread + + // terminal.attachHistory — tracked as a follow-up. + const routeApi = useMemo(() => ({ workflow: api }) as EnvironmentApi, [api]); + + // Board state from the folded subscription atom. + const boardQuery = useBoardState(rpc, boardId); + const state = AsyncResult.isSuccess(boardQuery) ? boardQuery.value : emptyBoardState; + // Intentionally undefined: the host derived a ticket cwd from the board's project + // workspaceRoot (for resolving relative file paths in ticket markdown). The plugin + // does not yet resolve the project workspace, so relative paths render unresolved. + const ticketCwd: string | undefined = undefined; + + const emptyState = getBoardRouteEmptyState({ boardId, boardLoadError }); + + useEffect(() => { + setBoardLoadError(null); + if (!boardId) { + setEditorOpen(false); + return; + } + + let cancelled = false; + void api.getBoard({ boardId }).then( + () => { + if (!cancelled) { + setBoardLoadError(null); + } + }, + (error: unknown) => { + if (!cancelled) { + setBoardLoadError(errorMessage(error)); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [boardId, environmentId, api]); + + useEffect(() => { + setBoardHasSources(false); + if (!boardId) { + return; + } + + let cancelled = false; + void api.getBoardDefinition({ boardId }).then( + ({ definition }: { definition: WorkflowDefinitionEncoded }) => { + if (!cancelled) { + setBoardHasSources((definition.sources?.length ?? 0) > 0); + } + }, + () => { + // Silently ignore: the button simply won't appear if the definition + // can't be loaded (e.g. network error, board not found). + if (!cancelled) { + setBoardHasSources(false); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [boardId, environmentId, api]); + + useEffect(() => { + // The ticket drawer selection (and its detail/error state) is scoped to a + // single board/environment. When either changes, close the drawer so it + // can't linger open on a ticket that isn't part of the current board. + setSelectedTicketId(null); + setTicketDetail(null); + setTicketDetailError(null); + }, [boardId, environmentId]); + + useEffect(() => { + // Deep link: a notification/webhook URL targets a specific ticket via the + // `ticket` search param. Seed the drawer selection from it — declared AFTER + // the board-switch reset above so it isn't immediately cleared on load. + if (rawTicket) { + setSelectedTicketId(TicketId.make(rawTicket)); + } else { + // The `ticket` param is absent (e.g. back/forward navigation away from a + // deep link) — treat its removal as authoritative and close the drawer so + // stale detail can't linger. This effect re-runs only when rawTicket/board/ + // env change, so an in-app manual selection (which doesn't touch the param) + // is never clobbered. + setSelectedTicketId(null); + } + }, [rawTicket, boardId, environmentId]); + + useEffect(() => { + if (!boardId) { + return; + } + + // Drop any ticket statuses carried over from a previously-viewed board so + // stale ticket IDs can't fire spurious status-change toasts after a switch. + ticketStatusRef.current.clear(); + + return subscribeBoard(routeApi, boardId, { + onSnapshot: (snapshot) => { + // Re-seed from scratch so the first ticket-stream update after a + // snapshot reads as a transition (or not) against fresh statuses, and + // so a re-snapshot for a new board never leaves stale entries behind. + ticketStatusRef.current.clear(); + for (const ticket of snapshot.tickets) { + ticketStatusRef.current.set(ticket.ticketId, ticket.status); + } + }, + onTicketUpdate: (ticket) => { + if (ticket.ticketId === selectedTicketIdRef.current) { + setTicketDetailReloadKey((key) => key + 1); + } + const previousStatus = ticketStatusRef.current.get(ticket.ticketId); + ticketStatusRef.current.set(ticket.ticketId, ticket.status); + notifyTicketStatusChange(ticket, previousStatus, selectedTicketIdRef.current); + }, + }); + }, [boardId, routeApi]); + + useEffect(() => { + // A running agent step gets its dispatch thread shortly after StepStarted + // is broadcast; poll the detail briefly so the live activity feed appears + // without waiting for the next workflow event. + if (!ticketDetail) { + return; + } + const needsThread = ticketDetail.steps.some( + (step) => + step.stepType === "agent" && + (step.status === "running" || step.status === "dispatch_requested") && + step.providerThreadId === undefined, + ); + if (!needsThread) { + // Thread arrived (or the step left the running/dispatch state): reset the + // budget so a later step in the same ticket gets a fresh window. + threadPollAttemptsRef.current = 0; + return; + } + // Cap the poll. The thread normally appears within a couple of seconds; if a + // dispatch stalls and never projects a providerThreadId, stop after ~30s + // (workflow-event broadcasts still refresh the detail) instead of polling + // getTicketDetail forever for every open drawer. + if (threadPollAttemptsRef.current >= MAX_THREAD_POLL_ATTEMPTS) { + return; + } + const timer = setTimeout(() => { + threadPollAttemptsRef.current += 1; + setTicketDetailReloadKey((key) => key + 1); + }, 2_000); + return () => clearTimeout(timer); + }, [ticketDetail]); + + const visibleState = useMemo( + () => filterBoardStateByQuery(state, searchQuery), + [state, searchQuery], + ); + + useEffect(() => { + if (!selectedTicketId) { + lastDetailTicketIdRef.current = null; + setTicketDetail(null); + setTicketDetailError(null); + return; + } + + let cancelled = false; + // Only clear the rendered detail when the selection actually changed + // (scoped to the environment/board so stale detail never survives a + // navigation); same-ticket revalidation keeps the previous detail (and + // the drawer's in-progress state) while the refresh is in flight. + const detailKey = `${environmentId}:${boardId ?? ""}:${selectedTicketId}`; + if (lastDetailTicketIdRef.current !== detailKey) { + lastDetailTicketIdRef.current = detailKey; + // New ticket selected: restart the thread-poll budget. + threadPollAttemptsRef.current = 0; + setTicketDetail(null); + } + setTicketDetailError(null); + + void api.getTicketDetail({ ticketId: selectedTicketId }).then( + (detail: WorkflowTicketDetailView) => { + if (!cancelled) { + setTicketDetail(detail); + } + }, + (error: unknown) => { + if (!cancelled) { + setTicketDetailError(errorMessage(error)); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [environmentId, boardId, selectedTicketId, ticketDetailReloadKey, api]); + + const handleMove = useCallback( + (ticketId: string, toLane: string): Promise => { + // moveTicket fails on a not-found ticket (e.g. it was deleted, or already + // moved by another client between render and drop). The drag/drop onMove + // contract is fire-and-forget, so catch here: surface a brief toast and + // refresh the board snapshot (the ticket may be gone or in a new lane) + // instead of leaking an unhandled rejection or showing a scary error. + return moveTicket(routeApi, TicketId.make(ticketId), LaneKey.make(toLane)).then( + undefined, + () => { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Couldn't move ticket", + description: "It may have already moved or been deleted. Refreshing the board.", + }), + ); + if (boardId) { + void api.getBoard({ boardId }).then(undefined, () => undefined); + } + }, + ); + }, + [environmentId, boardId, api, routeApi], + ); + const handleOpenTicket = useCallback((ticketId: string) => { + setEditorOpen(false); + setSelectedTicketId(TicketId.make(ticketId)); + }, []); + const closeTicketDrawer = useCallback(() => { + setSelectedTicketId(null); + }, []); + const reloadTicketDetail = useCallback(() => { + setTicketDetailReloadKey((key) => key + 1); + }, []); + const handleApprove = useCallback( + (stepRunId: string, approved: boolean): Promise => { + return resolveApproval(routeApi, StepRunId.make(stepRunId), approved).then(reloadTicketDetail); + }, + [routeApi, reloadTicketDetail], + ); + const handleAnswerStep = useCallback( + (input: BoardRouteAnswerInput): Promise => { + return submitTicketAnswerFromBoardRoute(routeApi, input, reloadTicketDetail); + }, + [routeApi, reloadTicketDetail], + ); + const handlePostComment = useCallback( + (input: { + readonly ticketId: string; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }): Promise => { + return postTicketMessage(routeApi, { + ticketId: TicketId.make(input.ticketId), + ...(input.text === undefined ? {} : { text: input.text }), + ...(input.attachments === undefined ? {} : { attachments: input.attachments }), + }).then(reloadTicketDetail); + }, + [routeApi, reloadTicketDetail], + ); + const handleEditTicket = useCallback( + (input: BoardRouteEditInput): Promise => { + return submitTicketEditFromBoardRoute(routeApi, input, reloadTicketDetail); + }, + [routeApi, reloadTicketDetail], + ); + const handleEditMessage = useCallback( + (messageId: string, body: string): Promise => { + if (!selectedTicketId) { + return Promise.reject(environmentApiUnavailable()); + } + return submitTicketMessageEditFromBoardRoute( + routeApi, + { ticketId: selectedTicketId, messageId, body }, + reloadTicketDetail, + ); + }, + [routeApi, reloadTicketDetail, selectedTicketId], + ); + const handleRunLane = useCallback(() => { + if (!selectedTicketId) { + return; + } + + // Mirror handleMove: a runLane RPC can reject (lane not runnable, script + // trust revoked between render and click, server error). Surface a toast + // and still reload the detail so the drawer reflects current state, instead + // of leaking an unhandled rejection with no user feedback. + void api.runLane({ ticketId: selectedTicketId }).then(reloadTicketDetail, (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't run the lane", + description: actionErrorMessage(error), + }), + ); + reloadTicketDetail(); + }); + }, [environmentId, reloadTicketDetail, selectedTicketId, api]); + const handleDrawerMove = useCallback( + (toLane: string): Promise => { + if (!selectedTicketId) { + return Promise.resolve(); + } + + // Await the move RPC before reloading the detail so the drawer doesn't + // briefly render the stale lane/actions while the move commits. + return handleMove(selectedTicketId, toLane).then(reloadTicketDetail); + }, + [handleMove, reloadTicketDetail, selectedTicketId], + ); + const handleCreateTicket = useCallback( + (input: { + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: string; + readonly dependsOn?: ReadonlyArray | undefined; + readonly tokenBudget?: number | undefined; + }) => { + if (!boardId) { + return; + } + + // The New-ticket form closes its dialog synchronously after calling this, + // so a rejected create (validation, duplicate, budget, server error) would + // otherwise be fully silent. Surface a toast on failure instead of leaking + // an unhandled rejection. + void createTicket(routeApi, { + boardId, + title: input.title, + ...(input.description === undefined ? {} : { description: input.description }), + initialLane: LaneKey.make(input.initialLane), + ...(input.dependsOn === undefined || input.dependsOn.length === 0 + ? {} + : { dependsOn: input.dependsOn.map((ticketId) => TicketId.make(ticketId)) }), + ...(input.tokenBudget === undefined ? {} : { tokenBudget: input.tokenBudget }), + }).then(undefined, (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Couldn't create "${input.title}"`, + description: actionErrorMessage(error), + }), + ); + }); + }, + [boardId, routeApi], + ); + const handleCreateTicketAsync = useCallback( + async (input: { + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: string; + readonly dependsOn?: ReadonlyArray | undefined; + }) => { + if (!boardId) { + throw new Error("No board selected."); + } + const created = await createTicket(routeApi, { + boardId, + title: input.title, + ...(input.description === undefined ? {} : { description: input.description }), + initialLane: LaneKey.make(input.initialLane), + ...(input.dependsOn === undefined || input.dependsOn.length === 0 + ? {} + : { dependsOn: input.dependsOn.map((ticketId) => TicketId.make(ticketId)) }), + }); + return created.ticketId as string; + }, + [boardId, routeApi], + ); + const handleProposeTickets = useCallback( + async (braindump: string, agent: AgentSelection) => { + if (!boardId) { + throw new Error("No board selected."); + } + const result = await api.intakeTickets({ boardId, braindump, agent }); + return result.proposals; + }, + [boardId, api], + ); + const handleFetchDigest = useCallback(async () => { + if (!boardId) { + throw new Error("No board selected."); + } + return await api.getBoardDigest({ boardId }); + }, [boardId, api]); + const handleFetchMetrics = useCallback( + async (windowDays: 1 | 7 | 30) => { + if (!boardId) { + throw new Error("No board selected."); + } + return await api.getBoardMetrics({ boardId, windowDays }); + }, + [boardId, api], + ); + const handleFetchWebhookConfig = useCallback( + async (rotate: boolean) => { + if (!boardId) { + throw new Error("No board selected."); + } + return await api.getWebhookConfig({ boardId, ...(rotate ? { rotate } : {}) }); + }, + [boardId, api], + ); + const attentionNow = useNowTick(60_000); + const needsAttentionCount = useMemo( + () => + countNeedsAttention( + state.ticketIds + .map((ticketId) => state.ticketById[ticketId]) + .filter((ticket) => ticket !== undefined), + attentionNow, + ), + [state.ticketIds, state.ticketById, attentionNow], + ); + const handleRefresh = useCallback(() => { + if (!boardId) { + return; + } + void api.getBoard({ boardId }).then(undefined, () => undefined); + }, [boardId, api]); + + const handleToggleWorkflowEditor = useCallback(() => { + setEditorOpen((open) => { + const nextOpen = !open; + if (nextOpen) { + setSelectedTicketId(null); + } + return nextOpen; + }); + }, []); + + /** Opens the editor directly to the Sources wizard (board empty-state CTA). */ + const handleOpenEditorToSources = useCallback(() => { + setSelectedTicketId(null); + setEditorOpen(true); + setEditorSourcesTrigger((n) => n + 1); + }, []); + const handleWorkflowSaved = useCallback( + (_snapshot: BoardSnapshot, definition: WorkflowDefinitionEncoded) => { + // Board state is now maintained automatically by the folded board atom — + // no manual applyBoardStreamItem or setProjectBoards side-effects needed. + // Derive whether the board now has sources from the saved definition + // rather than assuming any save implies sources exist — a lane rename or + // settings change triggers onSaved too, and must not dismiss the CTA. + setBoardHasSources((definition.sources?.length ?? 0) > 0); + }, + [], + ); + const closeWorkflowEditor = useCallback(() => { + setEditorOpen(false); + }, []); + + return ( + <> + +
+
+ +
+

+ {state.boardName || "Workflow Board"} +

+
+ {boardId ? ( + setSearchQuery(event.currentTarget.value)} + /> + ) : null} + ({ + ticketId, + title: state.ticketById[ticketId]?.title ?? ticketId, + }))} + workflowEditorOpen={editorOpen} + api={routeApi} + onCreateTicket={handleCreateTicket} + onProposeTickets={handleProposeTickets} + onCreateTicketAsync={handleCreateTicketAsync} + onToggleWorkflowEditor={handleToggleWorkflowEditor} + needsAttentionCount={needsAttentionCount} + onFetchDigest={handleFetchDigest} + onFetchMetrics={handleFetchMetrics} + onFetchWebhookConfig={handleFetchWebhookConfig} + boardHasSources={boardHasSources} + onRefresh={handleRefresh} + /> +
+ {emptyState ? ( +
+
+
{emptyState.title}
+ {emptyState.description ? ( +
{emptyState.description}
+ ) : null} +
+
+ ) : ( +
+ + {boardId && !boardHasSources ? ( +
+

+ No sources configured. Tickets from GitHub Issues or Asana can be pulled in + automatically. +

+ +
+ ) : null} +
+ )} +
+
+ + {boardId ? ( + + ) : ( +
+ Environment API unavailable. +
+ )} +
+ + {ticketDetail ? ( + + ) : ( +
+ {ticketDetailError ?? "Loading ticket..."} +
+ )} +
+ + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unable to load ticket detail."; +} + +/** Error text for a failed action (create/run) toast, with a neutral fallback. */ +function actionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Something went wrong. Please try again."; +} + +export function filterBoardStateByQuery(state: BoardState, query: string): BoardState { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) { + return state; + } + const matches = (ticketId: string): boolean => { + const ticket = state.ticketById[ticketId]; + if (!ticket) { + return false; + } + return ( + ticket.title.toLowerCase().includes(needle) || + (ticket.description?.toLowerCase().includes(needle) ?? false) + ); + }; + return { + ...state, + ticketIds: state.ticketIds.filter(matches), + lanes: state.lanes.map((lane) => ({ + ...lane, + admittedTicketIds: lane.admittedTicketIds.filter(matches), + queuedTicketIds: lane.queuedTicketIds.filter(matches), + })), + }; +} + +function notifyTicketStatusChange( + ticket: { readonly ticketId: string; readonly title: string; readonly status: string }, + previousStatus: string | undefined, + openTicketId: TicketId | null, +): void { + if ( + previousStatus === undefined || + previousStatus === ticket.status || + openTicketId === ticket.ticketId + ) { + return; + } + if (ticket.status === "waiting_on_user") { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: `"${ticket.title}" is waiting on you`, + description: "Open the ticket to answer or approve.", + }), + ); + return; + } + if (ticket.status === "failed" || ticket.status === "blocked") { + // Pipeline failures with no route project as "blocked", so both statuses + // mean the same thing to the user: this ticket needs attention. + toastManager.add( + stackedThreadToast({ + type: "error", + title: `"${ticket.title}" needs attention`, + description: "Open the ticket to see what went wrong.", + }), + ); + } +} diff --git a/fixtures/workflow-boards/web/components/board/AddFromIssuesDialog.tsx b/fixtures/workflow-boards/web/components/board/AddFromIssuesDialog.tsx new file mode 100644 index 00000000000..7e7807b5b8d --- /dev/null +++ b/fixtures/workflow-boards/web/components/board/AddFromIssuesDialog.tsx @@ -0,0 +1,407 @@ +import type { + ImportableWorkItemView, + ListImportableWorkItemsResult +} from "../../../contracts/workSource.ts"; +import type { WorkflowEnvironmentApi as EnvironmentApi } from "../../workflowApi"; +import { BoardId } from "../../../contracts/workflow.ts"; +import { useEffect, useRef, useState } from "react"; + +import { Badge } from "@t3tools/plugin-sdk-web"; +import { Button } from "@t3tools/plugin-sdk-web"; +import { Checkbox } from "@t3tools/plugin-sdk-web"; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "@t3tools/plugin-sdk-web"; +import { Input } from "@t3tools/plugin-sdk-web"; +import { toastManager } from "@t3tools/plugin-sdk-web"; +import { + applyPickerFilters, + defaultChecked, + groupSelectedBySource, + selectionKey, + type FilterState, +} from "../../workflow/importPicker"; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function ItemRow({ + row, + checked, + onToggle, +}: { + readonly row: ImportableWorkItemView; + readonly checked: boolean; + readonly onToggle: () => void; +}) { + const isMapped = row.mappedTicketId !== null; + const isClosed = row.lifecycle === "closed"; + const isDeleted = row.lifecycle === "deleted"; + const disabled = isMapped || isDeleted; + + return ( +
  • + onToggle()} + aria-label={`Select ${row.title}`} + className="mt-0.5 shrink-0" + /> +
    +
    + {row.displayRef} + {row.title} + {isMapped && row.mappedLane !== null ? ( + + On board · {row.mappedLane} + + ) : null} + {isClosed ? ( + + closed + + ) : null} + {isDeleted ? ( + + deleted + + ) : null} +
    + {row.container || row.assignees.length > 0 ? ( +

    + {row.container} + {row.container && row.assignees.length > 0 ? " · " : ""} + {row.assignees.join(", ")} +

    + ) : null} +
    +
  • + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function AddFromIssuesDialog(props: { + readonly boardId: string; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onImported: () => void; + readonly api: EnvironmentApi | null | undefined; +}) { + const { boardId, open, onOpenChange, onImported, api } = props; + + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [result, setResult] = useState(null); + const [checked, setChecked] = useState>(new Set()); + const [filter, setFilter] = useState({ + search: "", + assignedToMe: false, + hideTasked: false, + }); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + // Guards an in-flight submit against the dialog closing / unmounting mid-flight, + // mirroring the load effect's `cancelled` flag. `reset()` flips `aborted` so a + // late-resolving import never writes state into a torn-down/reopened dialog. + const submitGuardRef = useRef<{ aborted: boolean }>({ aborted: false }); + + // Load items when the dialog opens (or boardId changes while open) + useEffect(() => { + if (!open || !api) { + return; + } + + let cancelled = false; + setLoading(true); + setLoadError(null); + setResult(null); + setChecked(new Set()); + setFilter({ search: "", assignedToMe: false, hideTasked: false }); + setSubmitError(null); + + void api.workflow + .listImportableWorkItems({ boardId: BoardId.make(boardId) }) + .then((res) => { + if (cancelled) return; + const initialChecked = new Set(); + for (const item of res.items) { + if (defaultChecked(item)) { + initialChecked.add(selectionKey(item)); + } + } + setResult(res); + setChecked(initialChecked); + }) + .catch((cause) => { + if (cancelled) return; + setLoadError(cause instanceof Error ? cause.message : "Failed to load work items."); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, boardId, api]); + + const reset = () => { + submitGuardRef.current.aborted = true; + setLoading(false); + setLoadError(null); + setResult(null); + setChecked(new Set()); + setFilter({ search: "", assignedToMe: false, hideTasked: false }); + setSubmitting(false); + setSubmitError(null); + }; + + const handleAdd = async () => { + if (!api || checked.size === 0 || submitting) return; + + const guard = { aborted: false }; + submitGuardRef.current = guard; + setSubmitting(true); + setSubmitError(null); + + const groups = groupSelectedBySource(checked); + let importedTotal = 0; + let skippedTotal = 0; + const failures: string[] = []; + + // Each source imports independently: a later source throwing must not discard + // the tickets an earlier source already created. We accumulate across all + // sources, then decide the outcome from the totals + collected failures. + for (const [sourceId, externalIds] of Object.entries(groups)) { + try { + const res = await api.workflow.importWorkItems({ + boardId: BoardId.make(boardId), + sourceId, + externalIds, + }); + importedTotal += res.imported.length; + skippedTotal += res.skipped.length; + } catch (cause) { + const label = sourceById.get(sourceId)?.container ?? sourceId; + const detail = cause instanceof Error ? cause.message : "Import failed."; + failures.push(`${label}: ${detail}`); + } + } + + if (guard.aborted) return; + + // Partial success still refreshes the board so the tickets that landed show up. + if (importedTotal > 0) { + onImported(); + toastManager.add({ + type: "success", + title: `Added ${importedTotal} item${importedTotal === 1 ? "" : "s"}${ + skippedTotal > 0 ? ` (${skippedTotal} already on board or out of scope)` : "" + }`, + }); + } + + if (failures.length > 0) { + // Keep the dialog open so the user sees which source(s) failed. + setSubmitError(failures.join(" ")); + setSubmitting(false); + return; + } + + setSubmitting(false); + handleOpenChange(false); + }; + + const toggleItem = (key: string) => { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + const visibleItems = + result !== null ? applyPickerFilters(result.items, filter, result.viewer) : []; + + const sources = result?.sources ?? []; + const truncated = result?.truncated ?? {}; + const sourceErrors = result?.sourceErrors ?? {}; + + // Lookup so per-source notices (and submit failures) can name the source by its + // container (e.g. "owner/repo") rather than an opaque source id. + const sourceById = new Map(sources.map((s) => [s.sourceId, s])); + + const checkedCount = checked.size; + const addDisabled = checkedCount === 0 || submitting; + + // Single close path: forwards to the parent and resets local state (which also + // aborts any in-flight submit). The Cancel button and the Dialog's own close + // affordances (Esc / backdrop) both route through here so reset runs exactly once. + const handleOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen); + if (!nextOpen) { + reset(); + } + }; + + return ( + + +
    + + Add from issues + + +
    + {/* Search / filter bar */} + {!loading && !loadError && result !== null && sources.length > 0 ? ( +
    + { + const value = e.currentTarget.value; + setFilter((f) => ({ ...f, search: value })); + }} + placeholder="Search or paste a URL…" + aria-label="Search or paste a URL" + /> +
    + + +
    +
    + ) : null} + + {/* Loading state */} + {loading ?

    Loading…

    : null} + + {/* Load error state */} + {!loading && loadError !== null ? ( +

    + {loadError} +

    + ) : null} + + {/* No sources configured */} + {!loading && loadError === null && result !== null && sources.length === 0 ? ( +

    + This board has no configured work sources. +

    + ) : null} + + {/* Items list */} + {!loading && loadError === null && result !== null && sources.length > 0 ? ( + <> + {/* Per-source errors */} + {Object.entries(sourceErrors).map(([sourceId, msg]) => + msg ? ( +

    + {sourceById.get(sourceId)?.container ?? sourceId}: {msg} +

    + ) : null, + )} + + {/* Per-source truncated notices */} + {Object.entries(truncated).map(([sourceId, isTruncated]) => + isTruncated ? ( +

    + {sourceById.get(sourceId)?.container ?? sourceId}: showing first results only + — refine your filters to see more. +

    + ) : null, + )} + + {visibleItems.length === 0 ? ( +

    No importable items found.

    + ) : ( +
      + {visibleItems.map((row) => { + const key = selectionKey(row); + return ( + toggleItem(key)} + /> + ); + })} +
    + )} + + ) : null} + + {/* Submit error */} + {submitError !== null ? ( +

    + {submitError} +

    + ) : null} +
    + + + + + +
    +
    +
    + ); +} diff --git a/fixtures/workflow-boards/web/components/board/AgentSessionDialog.tsx b/fixtures/workflow-boards/web/components/board/AgentSessionDialog.tsx new file mode 100644 index 00000000000..de417644fc7 --- /dev/null +++ b/fixtures/workflow-boards/web/components/board/AgentSessionDialog.tsx @@ -0,0 +1,199 @@ +// @effect-diagnostics globalDate:off globalTimers:off +import type { + OrchestrationMessage, + OrchestrationThreadActivity, + OrchestrationThreadStreamItem, + ThreadId +} from "@t3tools/contracts"; +import type { WorkflowEnvironmentApi as EnvironmentApi } from "../../workflowApi"; +import { MessagesSquareIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "@t3tools/plugin-sdk-web"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "@t3tools/plugin-sdk-web"; +import { cn } from "@t3tools/plugin-sdk-web"; + +interface SessionState { + readonly messages: ReadonlyArray; + readonly activities: ReadonlyArray; +} + +/** + * Upsert by id, preserving first-seen order. A streaming message is re-emitted + * under the same id as it grows, so replace in place; genuinely new messages + * (or activities) append. Mirrors StepActivityFeed's dedup-by-id behaviour. + */ +function upsertById( + current: ReadonlyArray, + incoming: ReadonlyArray, +): ReadonlyArray { + const next = [...current]; + for (const item of incoming) { + const index = next.findIndex((existing) => existing.id === item.id); + if (index === -1) { + next.push(item); + } else { + next[index] = item; + } + } + return next; +} + +/** + * Read-only view of the hidden orchestration thread behind an agent step — + * the full conversation (instruction, assistant replies) plus the activity + * log. Total transparency into what the agent actually did. + */ +export function AgentSessionDialog({ + api, + threadId, + stepKey, +}: { + readonly api: EnvironmentApi | null | undefined; + readonly threadId: ThreadId; + readonly stepKey: string; +}) { + const [open, setOpen] = useState(false); + const [session, setSession] = useState(null); + + useEffect(() => { + if (!open || !api?.orchestration) { + return; + } + setSession(null); + return api.orchestration.subscribeThread( + { threadId }, + (item: OrchestrationThreadStreamItem) => { + if (item.kind === "snapshot") { + setSession({ + messages: item.snapshot.thread.messages, + activities: item.snapshot.thread.activities, + }); + return; + } + // After the initial snapshot only incremental events arrive (the server + // never re-snapshots). Fold message/activity events into the transcript + // so a still-running step's session stays live instead of frozen. + if (item.event.type === "thread.message-sent") { + const { messageId, role, text, attachments, turnId, streaming, createdAt, updatedAt } = + item.event.payload; + const message: OrchestrationMessage = { + id: messageId, + role, + text, + ...(attachments === undefined ? {} : { attachments }), + turnId, + streaming, + createdAt, + updatedAt, + }; + setSession((current) => + current === null + ? current + : { ...current, messages: upsertById(current.messages, [message]) }, + ); + return; + } + if (item.event.type === "thread.activity-appended") { + const { activity } = item.event.payload; + setSession((current) => + current === null + ? current + : { ...current, activities: upsertById(current.activities, [activity]) }, + ); + } + }, + ); + }, [api, open, threadId]); + + return ( + + + +
    + + Agent session · {stepKey} + + Read-only transcript of the agent run behind this step. + + +
    + {session === null ? ( +

    Loading session…

    + ) : ( + <> + {session.messages.length === 0 ? ( +

    No messages recorded.

    + ) : ( +
      + {session.messages.map((message) => ( +
    1. +
      + + {message.role === "user" ? "Instruction" : "Agent"} + + +
      +

      + {message.text} +

      +
    2. + ))} +
    + )} + {session.activities.length > 0 ? ( +
    + + Activity log ({session.activities.length}) + +
      + {session.activities.map((activity) => ( +
    1. + {activity.kind} + {activity.summary} +
    2. + ))} +
    +
    + ) : null} + + )} +
    +
    +
    +
    + ); +} diff --git a/fixtures/workflow-boards/web/components/board/BoardDigestDialog.tsx b/fixtures/workflow-boards/web/components/board/BoardDigestDialog.tsx new file mode 100644 index 00000000000..5687761ce5b --- /dev/null +++ b/fixtures/workflow-boards/web/components/board/BoardDigestDialog.tsx @@ -0,0 +1,185 @@ +import type { WorkflowBoardDigest } from "../../../contracts/workflow.ts"; +import { NewspaperIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Badge } from "@t3tools/plugin-sdk-web"; +import { Button } from "@t3tools/plugin-sdk-web"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "@t3tools/plugin-sdk-web"; +import { formatDuration } from "@t3tools/plugin-sdk-web"; +import { formatTokenCount } from "../../workflow/usageFormat"; + +/** + * The board's stand-up summary: what moved, what shipped, what it cost, and + * which tickets have been waiting on a human the longest. + */ +export function BoardDigestDialog({ + disabled, + needsAttentionCount, + onFetchDigest, + open: controlledOpen, + onOpenChange, +}: { + readonly disabled: boolean; + readonly needsAttentionCount: number; + readonly onFetchDigest: () => Promise; + readonly open?: boolean; + readonly onOpenChange?: (open: boolean) => void; +}) { + const isControlled = onOpenChange !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = isControlled ? (controlledOpen ?? false) : uncontrolledOpen; + const setOpen = (next: boolean) => { + if (isControlled) { + onOpenChange(next); + } else { + setUncontrolledOpen(next); + } + }; + const [digest, setDigest] = useState(null); + const [error, setError] = useState(null); + // A close (or re-open) invalidates in-flight fetches so a slow response + // can never repopulate the dialog with stale content. + const requestRef = useRef(0); + + const load = async () => { + const requestId = ++requestRef.current; + setError(null); + setDigest(null); + try { + const next = await onFetchDigest(); + if (requestRef.current === requestId) { + setDigest(next); + } + } catch (cause) { + if (requestRef.current === requestId) { + setError(cause instanceof Error ? cause.message : "Failed to load the digest."); + } + } + }; + + // In controlled mode the parent owns the trigger, so the load the + // self-contained trigger's onClick performed must fire when the dialog + // transitions to open. + useEffect(() => { + if (isControlled && open && digest === null && error === null) { + void load(); + } + }, [isControlled, open]); + + return ( + { + setOpen(nextOpen); + if (!nextOpen) { + requestRef.current += 1; + setDigest(null); + } + }} + > + {isControlled ? null : ( + + )} + +
    + + Board digest + + The last {digest?.windowHours ?? 24} hours on this board. + + +
    + {error !== null ? ( +

    + {error} +

    + ) : digest === null ? ( +

    Loading…

    + ) : ( + <> +
    +
    +
    Shipped
    +
    {digest.shippedCount}
    +
    +
    +
    Created
    +
    {digest.createdCount}
    +
    +
    +
    Tokens spent
    +
    + {digest.totalTokens > 0 ? formatTokenCount(digest.totalTokens) : "0"} +
    +
    +
    +
    Agent time
    +
    + {digest.totalDurationMs > 0 ? formatDuration(digest.totalDurationMs) : "0"} +
    +
    +
    +
    +

    + Waiting on you +

    + {digest.needsAttention.length === 0 ? ( +

    + Nothing — the board is running itself. +

    + ) : ( +
      + {digest.needsAttention.map((ticket) => ( +
    1. + + {ticket.title} + + + {ticket.status === "blocked" ? "blocked" : "waiting"} ·{" "} + {formatDuration(ticket.sinceMs)} + +
    2. + ))} +
    + )} +
    + + )} +
    +
    +
    +
    + ); +} diff --git a/fixtures/workflow-boards/web/components/board/BoardHeaderControls.tsx b/fixtures/workflow-boards/web/components/board/BoardHeaderControls.tsx new file mode 100644 index 00000000000..95b33936fa5 --- /dev/null +++ b/fixtures/workflow-boards/web/components/board/BoardHeaderControls.tsx @@ -0,0 +1,583 @@ +import type { + AgentSelection, + WorkflowBoardDigest, + WorkflowBoardMetrics, + WorkflowWebhookConfig +} from "../../../contracts/workflow.ts"; +import type { WorkflowEnvironmentApi as EnvironmentApi } from "../../workflowApi"; +import { + BarChart2Icon, + DownloadIcon, + MoreHorizontalIcon, + NewspaperIcon, + PencilIcon, + PlusIcon, + SparklesIcon, + WandSparklesIcon, + WebhookIcon, +} from "lucide-react"; +import type { ComponentType } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { Badge } from "@t3tools/plugin-sdk-web"; +import { Button } from "@t3tools/plugin-sdk-web"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "@t3tools/plugin-sdk-web"; +import { Input } from "@t3tools/plugin-sdk-web"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@t3tools/plugin-sdk-web"; +import { Textarea } from "@t3tools/plugin-sdk-web"; +import type { IntakeTicketInput } from "../../workflow/intakeState"; + +import { AddFromIssuesDialog } from "./AddFromIssuesDialog"; +import { BoardDigestDialog } from "./BoardDigestDialog"; +import { BoardMetricsDialog } from "./BoardMetricsDialog"; +import { IntakeDialog } from "./IntakeDialog"; +import { SelfImproveDialog } from "./SelfImproveDialog"; +import { WebhookConfigDialog } from "./WebhookConfigDialog"; + +export interface BoardHeaderLane { + readonly key: string; + readonly name: string; +} + +export interface NewTicketInput { + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: string; + readonly dependsOn?: ReadonlyArray | undefined; + readonly tokenBudget?: number | undefined; +} + +export interface BoardHeaderTicketOption { + readonly ticketId: string; + readonly title: string; +} + +export const getDefaultInitialLane = (lanes: ReadonlyArray): string | null => + lanes[0]?.key ?? null; + +export function BoardHeaderControls({ + boardId, + lanes, + tickets = [], + workflowEditorOpen = false, + intakeDisabledReason, + needsAttentionCount = 0, + api, + onCreateTicket, + onCreateTicketAsync, + onProposeTickets, + onToggleWorkflowEditor, + onFetchDigest, + onFetchMetrics, + onFetchWebhookConfig, + boardHasSources = false, + onRefresh, +}: { + readonly boardId: string | null; + readonly lanes: ReadonlyArray; + readonly tickets?: ReadonlyArray; + readonly workflowEditorOpen?: boolean | undefined; + readonly intakeDisabledReason?: string | undefined; + readonly api?: EnvironmentApi | null | undefined; + readonly onCreateTicket: (input: NewTicketInput) => void; + readonly onCreateTicketAsync?: ((input: NewTicketInput) => Promise) | undefined; + readonly onProposeTickets?: + | ((braindump: string, agent: AgentSelection) => Promise>) + | undefined; + readonly onToggleWorkflowEditor?: (() => void) | undefined; + readonly needsAttentionCount?: number | undefined; + readonly onFetchDigest?: (() => Promise) | undefined; + readonly onFetchMetrics?: ((windowDays: 1 | 7 | 30) => Promise) | undefined; + readonly onFetchWebhookConfig?: ((rotate: boolean) => Promise) | undefined; + readonly boardHasSources?: boolean | undefined; + readonly onRefresh?: (() => void) | undefined; +}) { + const [open, setOpen] = useState(false); + const [activeDialog, setActiveDialog] = useState< + null | "webhook" | "digest" | "insights" | "suggest" | "intake" | "add-from-issues" + >(null); + + // Measured overflow: render the six secondary buttons inline when they fit, + // otherwise collapse them into a single "More" menu. SSR / first paint is + // always expanded (no probe) so the SSR snapshot tests see inline buttons. + const [mounted, setMounted] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const containerRef = useRef(null); + const probeRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [initialLane, setInitialLane] = useState(() => getDefaultInitialLane(lanes) ?? ""); + const [dependsOn, setDependsOn] = useState>([]); + const [tokenBudget, setTokenBudget] = useState(""); + + useEffect(() => { + if (lanes.some((lane) => lane.key === initialLane)) { + return; + } + setInitialLane(getDefaultInitialLane(lanes) ?? ""); + }, [initialLane, lanes]); + + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + const canCreateTicket = Boolean(boardId && initialLane && trimmedTitle); + + const resetForm = () => { + setTitle(""); + setDescription(""); + setInitialLane(getDefaultInitialLane(lanes) ?? ""); + setDependsOn([]); + setTokenBudget(""); + }; + + const handleCreateIntakeTickets = async ( + tickets: ReadonlyArray<{ + readonly title: string; + readonly description?: string | undefined; + readonly dependsOnIndices: ReadonlyArray; + }>, + ) => { + const lane = getDefaultInitialLane(lanes); + if (lane === null) { + return; + } + // Sequential so dependency edges can reference the ids of the tickets + // created earlier in this same batch. + const createdIds: Array = []; + for (const ticket of tickets) { + const dependsOn = ticket.dependsOnIndices + .map((index) => createdIds[index]) + .filter((ticketId): ticketId is string => ticketId !== undefined); + const input = { + title: ticket.title, + ...(ticket.description === undefined ? {} : { description: ticket.description }), + initialLane: lane, + ...(dependsOn.length > 0 ? { dependsOn } : {}), + }; + if (onCreateTicketAsync) { + createdIds.push((await onCreateTicketAsync(input)) ?? undefined); + } else { + onCreateTicket(input); + createdIds.push(undefined); + } + } + }; + + // Secondary actions, in render order. Only include an action when its + // handler/prop is present, matching the previous conditional rendering. + interface SecondaryAction { + readonly key: string; + readonly label: string; + readonly icon: ComponentType<{ className?: string }>; + readonly disabled: boolean; + readonly onSelect: () => void; + readonly pressed?: boolean; + readonly title?: string; + readonly badge?: number; + } + + const secondaryActions: ReadonlyArray = [ + ...(onFetchWebhookConfig + ? [ + { + key: "webhook", + label: "Webhook", + icon: WebhookIcon, + disabled: !boardId, + onSelect: () => setActiveDialog("webhook"), + title: "Let CI, PR automation, or cron move tickets on this board", + } satisfies SecondaryAction, + ] + : []), + ...(onFetchDigest + ? [ + { + key: "digest", + label: "Digest", + icon: NewspaperIcon, + disabled: !boardId, + onSelect: () => setActiveDialog("digest"), + title: "What happened on this board in the last 24 hours", + ...(needsAttentionCount > 0 ? { badge: needsAttentionCount } : {}), + } satisfies SecondaryAction, + ] + : []), + ...(onFetchMetrics + ? [ + { + key: "insights", + label: "Insights", + icon: BarChart2Icon, + disabled: !boardId, + onSelect: () => setActiveDialog("insights"), + title: "Board metrics and throughput charts", + } satisfies SecondaryAction, + ] + : []), + ...(onToggleWorkflowEditor + ? [ + { + key: "edit-workflow", + label: "Edit workflow", + icon: PencilIcon, + disabled: !boardId, + onSelect: onToggleWorkflowEditor, + pressed: workflowEditorOpen, + } satisfies SecondaryAction, + ] + : []), + ...(api !== undefined + ? [ + { + key: "suggest", + label: "Suggest improvements", + icon: WandSparklesIcon, + disabled: !boardId, + onSelect: () => setActiveDialog("suggest"), + title: boardId ? "Suggest AI improvements to this board" : "No board selected", + } satisfies SecondaryAction, + ] + : []), + ...(onProposeTickets + ? [ + { + key: "intake", + label: "Intake", + icon: SparklesIcon, + disabled: !boardId || lanes.length === 0 || intakeDisabledReason !== undefined, + onSelect: () => setActiveDialog("intake"), + title: + intakeDisabledReason !== undefined + ? intakeDisabledReason + : "Turn a braindump into tickets", + } satisfies SecondaryAction, + ] + : []), + ...(boardHasSources && boardId + ? [ + { + key: "add-from-issues", + label: "Add from issues", + icon: DownloadIcon, + disabled: false, + onSelect: () => setActiveDialog("add-from-issues"), + title: "Import work items from connected sources", + } satisfies SecondaryAction, + ] + : []), + ]; + + // Re-measure on mount and whenever the container resizes (sidebar toggle, + // board-name length change, window resize). The probe holds the full inline + // layout off-screen so its scrollWidth is the natural required width. + useLayoutEffect(() => { + if (!mounted) { + return; + } + const container = containerRef.current; + if (!container) { + return; + } + const measure = () => { + const probe = probeRef.current; + const node = containerRef.current; + if (!probe || !node) { + return; + } + // +4px buffer avoids flicker right at the boundary. + setCollapsed(probe.scrollWidth + 4 > node.clientWidth); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [mounted, secondaryActions.length]); + + const renderInlineAction = (action: SecondaryAction) => ( + + ); + + return ( +
    + {/* Off-screen probe: the full inline secondary layout, measured for fit. */} + {mounted && secondaryActions.length > 0 ? ( +
    + {secondaryActions.map(renderInlineAction)} +
    + ) : null} + + {secondaryActions.length > 0 ? ( + collapsed ? ( + + + } + > + + More + + + {secondaryActions.map((action) => ( + + + {action.label} + {action.badge !== undefined ? ( + + {action.badge} + + ) : null} + + ))} + + + ) : ( + secondaryActions.map(renderInlineAction) + ) + ) : null} + + {/* Controlled dialog bodies — rendered once regardless of collapse so a + menu close never unmounts an open dialog. */} + {onFetchWebhookConfig ? ( + setActiveDialog(o ? "webhook" : null)} + /> + ) : null} + {onFetchDigest ? ( + setActiveDialog(o ? "digest" : null)} + /> + ) : null} + {onFetchMetrics ? ( + setActiveDialog(o ? "insights" : null)} + /> + ) : null} + {api !== undefined ? ( + setActiveDialog(o ? "suggest" : null)} + /> + ) : null} + {onProposeTickets ? ( + setActiveDialog(o ? "intake" : null)} + /> + ) : null} + {boardHasSources && boardId ? ( + setActiveDialog(o ? "add-from-issues" : null)} + onImported={() => onRefresh?.()} + /> + ) : null} + { + setOpen(nextOpen); + if (!nextOpen) { + resetForm(); + } + }} + > + + +
    { + event.preventDefault(); + if (!canCreateTicket) { + return; + } + + const parsedBudget = Number.parseInt(tokenBudget, 10); + onCreateTicket({ + title: trimmedTitle, + ...(trimmedDescription ? { description: trimmedDescription } : {}), + initialLane, + ...(dependsOn.length > 0 ? { dependsOn } : {}), + ...(Number.isFinite(parsedBudget) && parsedBudget > 0 + ? { tokenBudget: parsedBudget } + : {}), + }); + resetForm(); + setOpen(false); + }} + > + + New ticket + + Capture the work request, context, and acceptance criteria before adding it to the + board. + + +
    + +