From 5f24c87668e72fd6136a24ed1e39e9b82435fc4b Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 20:48:35 -0400 Subject: [PATCH 1/6] Add thread ownership to projection threads Threads carry an owner (user or plugin:); plugin-owned threads are hidden from user-facing projections, activity relays, and client thread state. Includes migration 033 and the decider/projector/read-model wiring. 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 | 237 ++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 112 ++++++++- .../Services/ProjectionSnapshotQuery.ts | 8 + .../decider.projectScripts.test.ts | 52 ++++ apps/server/src/orchestration/decider.ts | 1 + .../src/orchestration/projector.test.ts | 67 +++++ 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 | 10 + 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 | 107 ++++++++ packages/contracts/src/orchestration.ts | 17 ++ 26 files changed, 847 insertions(+), 3 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..89b841246d1 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,242 @@ 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, + }); + + // 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-plugin-active"), + ThreadId.make("thread-user-active"), + ThreadId.make("thread-plugin-archived"), + ], + ); + + 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..9ba0064ff10 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,41 @@ 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", + 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 + WHERE owner = ${DEFAULT_THREAD_OWNER} + ORDER BY created_at ASC, thread_id ASC + `, + }); + + // 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", @@ -352,6 +391,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 +409,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 +423,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 +441,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 +551,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 +577,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 `, }); @@ -558,6 +603,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listLatestTurnRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionLatestTurnDbRowSchema, + execute: () => + sql` + SELECT + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + 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 + `, + }); + + // The command read model must see EVERY thread's latest turn regardless of + // owner (mirroring listAllThreadRows): the decider replays commands/events + // against it, and rehydrating a plugin-owned thread with latestTurn = null + // would diverge from the live in-memory projector, which tracks latest turns + // for all owners. Non-user threads are filtered from user-facing views only. + const listAllLatestTurnRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionLatestTurnDbRowSchema, execute: () => @@ -603,6 +678,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 +705,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 +730,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 +788,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 +834,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 +1267,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, @@ -1227,7 +1319,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadRows(undefined).pipe( + listAllThreadRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getCommandReadModel:listThreads:query", @@ -1251,7 +1343,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listLatestTurnRows(undefined).pipe( + listAllLatestTurnRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getCommandReadModel:listLatestTurns:query", @@ -1374,6 +1466,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 +1860,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 +2075,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 +2148,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..e633c683638 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.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,72 @@ 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); 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..fd62c2f8fcc 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -323,6 +323,16 @@ 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", { + 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..d51ed3446a6 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,109 @@ 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"); + + // 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", + 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..3eca543fced 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-z][a-z0-9-]{1,40}$/; +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 54e54d7e2268227aa04ef5387f534fdc28d6c288 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 22:47:10 -0400 Subject: [PATCH 2/6] fix(contracts): reject client-set thread owner (Cursor #3725) Clients could send thread.create with owner=plugin:* via the client wire union and forge a hidden thread. Add an owner-less ClientThreadCreateCommand and use it in the client-facing unions; owner stays server-injected. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/orchestration/decider.ts | 2 +- packages/contracts/src/orchestration.test.ts | 25 +++++++++++++++++++- packages/contracts/src/orchestration.ts | 21 ++++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 58e77eaec7c..9c33b473d44 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -234,7 +234,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, - owner: command.owner ?? "user", + owner: "owner" in command ? (command.owner ?? "user") : "user", modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d51ed3446a6..de6c642ef82 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + ClientOrchestrationCommand, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, ModelSelection, @@ -50,6 +51,7 @@ function getOptionValue( return options?.find((option) => option.id === id)?.value; } const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); +const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); @@ -415,7 +417,28 @@ it.effect("decodes thread ownership with legacy user defaults and plugin-owned i if (command.type !== "thread.create") { assert.fail(`Expected thread.create command, received ${command.type}.`); } - assert.strictEqual(command.owner, "plugin:test"); + assert.strictEqual("owner" in command, true); + assert.strictEqual((command as { owner?: unknown }).owner, "plugin:test"); + + const clientCommand = yield* decodeClientOrchestrationCommand({ + type: "thread.create", + commandId: "cmd-client-thread-create", + threadId: "thread-client", + projectId: "project-1", + title: "Client 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(clientCommand.type, "thread.create"); + assert.strictEqual("owner" in clientCommand, false); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3eca543fced..15cfcffef08 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -520,6 +520,22 @@ const ThreadCreateCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ClientThreadCreateCommand = Schema.Struct({ + type: Schema.Literal("thread.create"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + ), + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + createdAt: IsoDateTime, +}); + const ThreadDeleteCommand = Schema.Struct({ type: Schema.Literal("thread.delete"), commandId: CommandId, @@ -675,7 +691,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, - ThreadCreateCommand, + ClientThreadCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -696,7 +712,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, - ThreadCreateCommand, + ClientThreadCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -789,6 +805,7 @@ const InternalOrchestrationCommand = Schema.Union([ export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; export const OrchestrationCommand = Schema.Union([ + ThreadCreateCommand, DispatchableClientOrchestrationCommand, InternalOrchestrationCommand, ]); From a1c265559495d0db518c95d28a3b9e0228b18629 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 20:50:51 -0400 Subject: [PATCH 3/6] Add plugin host foundation and server plugin SDK PluginHost lifecycle (discover, activate, deactivate), lockfile store, per-plugin migrations (034), module loader + resolve hooks, plugin manifest contract, and the @t3tools/plugin-sdk server package. 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 | 299 ++++++++++++ apps/server/src/plugins/PluginHost.ts | 434 ++++++++++++++++++ .../src/plugins/PluginLockfileStore.test.ts | 146 ++++++ .../server/src/plugins/PluginLockfileStore.ts | 327 +++++++++++++ .../server/src/plugins/PluginMigrator.test.ts | 265 +++++++++++ apps/server/src/plugins/PluginMigrator.ts | 289 ++++++++++++ apps/server/src/plugins/PluginModuleLoader.ts | 144 ++++++ apps/server/src/plugins/PluginPaths.ts | 34 ++ .../src/plugins/PluginRuntimeRegistry.ts | 52 +++ apps/server/src/plugins/pluginResolveHooks.ts | 34 ++ apps/server/src/server.ts | 18 +- apps/server/src/serverRuntimeStartup.ts | 4 + apps/server/vite.config.ts | 6 +- packages/contracts/package.json | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/plugin.test.ts | 125 +++++ packages/contracts/src/plugin.ts | 203 ++++++++ packages/plugin-sdk/package.json | 19 + packages/plugin-sdk/src/index.test.ts | 15 + packages/plugin-sdk/src/index.ts | 157 +++++++ packages/plugin-sdk/tsconfig.json | 5 + pnpm-lock.yaml | 12 + 27 files changed, 2650 insertions(+), 2 deletions(-) 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..8e5f35f4b89 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,6 +30,7 @@ "@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/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..36ffc847804 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -0,0 +1,299 @@ +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..7ad9f32edbd --- /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 packageJson from "../../package.json" with { type: "json" }; +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 = packageJson.version; +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), + }), + ), + // 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; + 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..8650835776d --- /dev/null +++ b/apps/server/src/plugins/PluginLockfileStore.test.ts @@ -0,0 +1,146 @@ +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..4649599aa30 --- /dev/null +++ b/apps/server/src/plugins/PluginLockfileStore.ts @@ -0,0 +1,327 @@ +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..3caaf501fdf --- /dev/null +++ b/apps/server/src/plugins/PluginMigrator.test.ts @@ -0,0 +1,265 @@ +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("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; + 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..5ef03ccbe5e --- /dev/null +++ b/apps/server/src/plugins/PluginMigrator.ts @@ -0,0 +1,289 @@ +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 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; + 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); + + // 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({ + 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* () { + // 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; + 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); + 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( + (cause) => + new PluginMigrationExecutionError({ + pluginId, + version: migration.version, + cause, + }), + ), + ); + 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, + 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..3ae58d521dc --- /dev/null +++ b/apps/server/src/plugins/PluginModuleLoader.ts @@ -0,0 +1,144 @@ +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(realPluginDir, 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..7f8c29b7ae3 --- /dev/null +++ b/apps/server/src/plugins/PluginPaths.ts @@ -0,0 +1,34 @@ +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..8abcccbd340 --- /dev/null +++ b/apps/server/src/plugins/PluginRuntimeRegistry.ts @@ -0,0 +1,52 @@ +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..1bada6a1094 --- /dev/null +++ b/apps/server/src/plugins/pluginResolveHooks.ts @@ -0,0 +1,34 @@ +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/apps/server/vite.config.ts b/apps/server/vite.config.ts index 473df069ed7..38fc4a66b44 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -30,7 +30,11 @@ export default mergeConfig( }, }, pack: { - entry: ["src/bin.ts"], + // The ESM plugin-host resolution hook must ship as its OWN module file: + // it is loaded by URL in a loader-hook worker via `module.register` (see + // PluginModuleLoader), so it cannot be inlined into bin.mjs. Emitting it as + // a second entry produces dist/pluginResolveHooks.mjs alongside bin.mjs. + entry: ["src/bin.ts", "src/plugins/pluginResolveHooks.ts"], outDir: "dist", sourcemap: true, clean: true, 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..889c5cbf226 --- /dev/null +++ b/packages/contracts/src/plugin.test.ts @@ -0,0 +1,125 @@ +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..234604c4cb3 --- /dev/null +++ b/packages/contracts/src/plugin.ts @@ -0,0 +1,203 @@ +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..cc41cc23265 --- /dev/null +++ b/packages/plugin-sdk/src/index.ts @@ -0,0 +1,157 @@ +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; + 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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef7146e194a..2a929f4a688 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -462,6 +462,9 @@ importers: '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@t3tools/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) @@ -801,6 +804,15 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/plugin-sdk: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + effect: + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + packages/shared: dependencies: '@noble/curves': From d96dadd92318214ead320c3dc2803e14e93cf029 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 20:54:30 -0400 Subject: [PATCH 4/6] Add plugin RPC transport and plugin-aware auth scopes Plugin RPC dispatcher + catalog wiring over ws, plugin-aware auth scope model (plugin::read|operate), session/pairing-grant scope plumbing, and the client-runtime authorization surface for plugin scopes. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/auth/EnvironmentAuth.test.ts | 64 +++- apps/server/src/auth/EnvironmentAuth.ts | 24 +- .../src/auth/EnvironmentAuthAdmin.test.ts | 23 +- .../server/src/auth/PairingGrantStore.test.ts | 59 +++- apps/server/src/auth/PairingGrantStore.ts | 10 +- apps/server/src/auth/SessionStore.test.ts | 9 +- apps/server/src/auth/SessionStore.ts | 21 +- apps/server/src/auth/http.ts | 63 ++-- 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 | 4 +- apps/server/src/plugins/PluginHost.ts | 10 +- .../src/plugins/PluginLockfileStore.test.ts | 96 ++++++ .../server/src/plugins/PluginLockfileStore.ts | 177 ++++++++-- apps/server/src/plugins/PluginLogger.ts | 10 + apps/server/src/plugins/PluginModuleLoader.ts | 89 ++++- .../src/plugins/PluginRpcDispatcher.test.ts | 314 ++++++++++++++++++ .../server/src/plugins/PluginRpcDispatcher.ts | 208 ++++++++++++ apps/server/src/plugins/pluginResolveHooks.ts | 16 +- apps/server/src/server.test.ts | 41 ++- apps/server/src/server.ts | 22 +- apps/server/src/ws.ts | 46 ++- .../settings/ConnectionsSettings.tsx | 19 +- apps/web/src/environments/primary/auth.ts | 9 +- .../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 | 112 +++++++ packages/contracts/src/auth.ts | 78 ++++- packages/contracts/src/environmentHttp.ts | 6 +- packages/contracts/src/orchestration.ts | 21 +- packages/contracts/src/plugin.ts | 39 ++- packages/contracts/src/rpc.ts | 33 ++ packages/plugin-sdk/src/index.ts | 3 +- pnpm-lock.yaml | 20 +- 38 files changed, 1684 insertions(+), 243 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/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..276a00f6728 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,5 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthAdministrativeScopes } from "@t3tools/contracts"; +import { + AuthAdministrativeScopes, + AuthStandardClientScopes, + pluginReadScope, +} from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -89,13 +93,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())), ); @@ -117,6 +115,45 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("exchanges a standard-client grant for an implicitly-held plugin scope", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + // A default pairing credential holds the standard-client marker, which + // implicitly satisfies every plugin scope even though `plugin:...:read` + // is not listed verbatim in the grant. + const pairingCredential = yield* serverAuth.issuePairingCredential(); + + const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + pairingCredential.credential, + [pluginReadScope("test-plugin")], + requestMetadata, + ); + + expect(token.scope).toBe("plugin:test-plugin:read"); + }).pipe(Effect.provide(makeEnvironmentAuthLayer())), + ); + + it.effect("rejects a plugin-scope exchange when the grant is not a full standard client", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + // A constrained grant that lacks the standard-client marker must NOT get + // implicit plugin access. + const pairingCredential = yield* serverAuth.issuePairingCredential({ + scopes: ["orchestration:read"], + }); + + const error = yield* serverAuth + .exchangeBootstrapCredentialForAccessToken( + pairingCredential.credential, + [pluginReadScope("test-plugin")], + requestMetadata, + ) + .pipe(Effect.flip); + + expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); + }).pipe(Effect.provide(makeEnvironmentAuthLayer())), + ); + it.effect("inherits a constrained pairing grant when token exchange omits scope", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -167,16 +204,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..95b85d2bca6 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -3,14 +3,15 @@ import { AuthAccessWriteScope, AuthAdministrativeScopes, AuthStandardClientScopes, + satisfiesScope, type AuthAccessTokenResult, type AuthBrowserSessionResult, type AuthClientMetadata, type AuthClientSession, type AuthCreatePairingCredentialInput, - type AuthEnvironmentScope, type AuthPairingLink, type AuthPairingCredentialResult, + type AuthScope, type AuthSessionId, type AuthSessionState, type ServerAuthDescriptor, @@ -41,7 +42,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 +53,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 +63,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 +424,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 +436,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 +454,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< @@ -694,7 +695,12 @@ export const make = Effect.gen(function* () { Effect.flatMap((grant) => Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; - if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { + // Downscope by implicit satisfaction, not verbatim membership: a + // full standard-client grant implicitly holds every plugin scope + // (and plugins:manage) via the standard-client marker, so requesting + // e.g. `plugin::read` against such a grant must succeed even + // though it is not listed literally in grant.scopes. + if (!grantedScopes.every((scope) => satisfiesScope(scope, grant.scopes))) { return yield* new ServerAuthScopeNotGrantedError({}); } return yield* sessions @@ -743,7 +749,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..dd35b5498f5 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,9 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + AuthAdministrativeScopes, + AuthOrchestrationReadScope, + AuthStandardClientScopes, + pluginReadScope, +} from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; 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 Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; @@ -74,13 +82,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 +147,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"); @@ -218,6 +211,38 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); + it.effect("surfaces plugin scopes on listActive and the pairingLinkUpserted change event", () => + Effect.scoped( + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const grantedScopes = [AuthOrchestrationReadScope, pluginReadScope("acme-notes")] as const; + + const changesFiber = yield* Stream.take(bootstrapCredentials.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + const issued = yield* bootstrapCredentials.issueOneTimeToken({ scopes: grantedScopes }); + + // The active-link list carries the full granted scope set, not just + // the environment scopes. + const active = yield* bootstrapCredentials.listActive(); + const listed = active.find((entry) => entry.id === issued.id); + expect(listed?.scopes).toEqual(grantedScopes); + + // The change event mirrors it. + const changes = Array.from(yield* Fiber.join(changesFiber)); + expect(changes).toHaveLength(1); + const change = changes[0]!; + expect(change.type).toBe("pairingLinkUpserted"); + if (change.type === "pairingLinkUpserted") { + expect(change.pairingLink.scopes).toEqual(grantedScopes); + } + }), + ).pipe(Effect.provide(makePairingGrantStoreLayer())), + ); + it.effect("identifies consume-available failures and preserves their cause", () => { const repositoryFailure = new PersistenceSqlError({ operation: "consume-pairing-link", diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 588d5e3775f..dee147548fd 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -1,7 +1,7 @@ import { AuthAdministrativeScopes, AuthStandardClientScopes, - type AuthEnvironmentScope, + type AuthScope, type AuthPairingLink, type ServerAuthBootstrapMethod, } from "@t3tools/contracts"; @@ -22,7 +22,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 +198,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,6 +327,8 @@ export const make = Effect.gen(function* () { ? ({ id: row.id, credential: row.credential, + // Full persisted scopes (including plugin scopes) so granted + // capabilities are visible in the active-link list. scopes: row.scopes, subject: row.subject, label: row.label, @@ -408,6 +410,8 @@ export const make = Effect.gen(function* () { yield* emitUpsert({ id, credential, + // Emit the full granted scope set (including plugin scopes) on the + // `pairingLinkUpserted` change event. scopes: input?.scopes ?? AuthStandardClientScopes, subject: input?.subject ?? "one-time-token", ...(input?.label ? { label: input.label } : {}), 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..e7f79d239b9 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1,10 +1,10 @@ import { AuthSessionId, AuthStandardClientScopes, - AuthEnvironmentScopes, + AuthScopes, type AuthClientMetadata, type AuthClientSession, - type AuthEnvironmentScope, + type AuthScope, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -36,7 +36,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 +47,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 +363,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 +408,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 +452,16 @@ function toClientMetadata(record: { }; } -function toAuthClientSession(input: Omit): AuthClientSession { +function toAuthClientSession( + input: Omit & { + readonly scopes: ReadonlyArray; + }, +): AuthClientSession { return { ...input, + // Preserve the full scope set (including any plugin scopes) so downscoped + // sessions are represented faithfully in listSessions / change events. + scopes: input.scopes, current: false, }; } diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 71fb00b970a..8930e800c56 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -1,13 +1,8 @@ import { AuthAccessReadScope, AuthAccessWriteScope, + AuthEnvironmentScope, AuthStandardClientScopes, - AuthOrchestrationOperateScope, - AuthOrchestrationReadScope, - AuthRelayReadScope, - AuthRelayWriteScope, - AuthReviewWriteScope, - AuthTerminalOperateScope, EnvironmentAuthInvalidError, type EnvironmentAuthInvalidReason, EnvironmentHttpApi, @@ -19,9 +14,11 @@ import { EnvironmentScopeRequiredError, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, + isPluginScope, + satisfiesScope, } from "@t3tools/contracts"; -import type { AuthEnvironmentScope } from "@t3tools/contracts"; -import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; +import type { 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 +106,7 @@ export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidR ); } -export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) { +export function failEnvironmentScopeRequired(requiredScope: AuthScope) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => Effect.fail( @@ -155,12 +152,34 @@ export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope" scope: AuthEnvironmentScope, ) { const session = yield* EnvironmentAuthenticatedPrincipal; - if (!session.scopes.has(scope)) { + // Honor implicit satisfaction (satisfiesScope) rather than a verbatim set + // lookup, so a pre-upgrade standard-marker session satisfies newer scopes + // (e.g. `plugins:manage`) consistently with the WS / token-exchange paths. + if (!satisfiesScope(scope, Array.from(session.scopes))) { return yield* failEnvironmentScopeRequired(scope); } return session; }); +// Derived from the environment-scope schema literals so a newly added env scope +// is automatically an accepted token-exchange scope instead of being silently +// rejected by a hand-maintained duplicate of the list. +const TOKEN_EXCHANGE_CORE_SCOPES = new Set(AuthEnvironmentScope.literals); + +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 +269,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,12 +337,18 @@ 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"); } + const heldScopes = Array.from(session.scopes); for (const delegatedScope of delegatedScopes) { - if (!session.scopes.has(delegatedScope)) { + // Honor implicit satisfaction (satisfiesScope), not just verbatim + // membership: a full standard-client session implicitly holds + // every plugin scope and plugins:manage via the standard-client + // marker, even when those are not listed explicitly (e.g. sessions + // persisted before plugins:manage joined the standard bundle). + if (!satisfiesScope(delegatedScope, heldScopes)) { return yield* failEnvironmentScopeRequired(delegatedScope); } } 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 36ffc847804..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"); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 7ad9f32edbd..8bf538dd4f4 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -33,6 +33,7 @@ import * as ServerConfig from "../config.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; import { PluginMigrator } from "./PluginMigrator.ts"; import { PluginModuleLoader } from "./PluginModuleLoader.ts"; +import { makePluginLogger } from "./PluginLogger.ts"; import { pluginDataDir, pluginManifestPath, pluginVersionDir } from "./PluginPaths.ts"; import { PluginRuntimeRegistry } from "./PluginRuntimeRegistry.ts"; @@ -117,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 })); @@ -273,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 }); diff --git a/apps/server/src/plugins/PluginLockfileStore.test.ts b/apps/server/src/plugins/PluginLockfileStore.test.ts index 8650835776d..3a8bb0804ab 100644 --- a/apps/server/src/plugins/PluginLockfileStore.test.ts +++ b/apps/server/src/plugins/PluginLockfileStore.test.ts @@ -13,6 +13,7 @@ import * as ServerConfig from "../config.ts"; import * as PluginLockfileStoreModule from "./PluginLockfileStore.ts"; import { PluginLockfileCorruptError, + PluginLockfileLockError, PluginLockfileTransitionError, } from "./PluginLockfileStore.ts"; @@ -64,6 +65,11 @@ layer("PluginLockfileStore", (it) => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { assert.instanceOf(result.failure, PluginLockfileCorruptError); + // The wrapper message derives from the stable path only — it must name + // the path and must NOT echo the raw decode error (which could leak + // corrupt lockfile contents such as this "{not-json" input). + assert.include(result.failure.message, store.lockfilePath); + assert.notInclude(result.failure.message, "not-json"); } yield* fs.remove(store.lockfilePath, { force: true }); }), @@ -130,6 +136,44 @@ layer("PluginLockfileStore", (it) => { }), ); + it.effect("aborts the write and leaves a reclaimed lock in place when ownership is lost", () => + Effect.gen(function* () { + const store = yield* PluginLockfileStoreModule.PluginLockfileStore; + const fs = yield* FileSystem.FileSystem; + const before = yield* store.readLockfile; + + // While we hold the lock, simulate another process reclaiming it by + // overwriting the lock file with a different owner token BEFORE our write. + const result = yield* Effect.result( + store.updatePlugin(pluginId, () => + Effect.gen(function* () { + yield* fs + .writeFileString(store.advisoryLockPath, "9999:foreign-owner-token\n") + .pipe(Effect.orDie); + return makePlugin({ sha256: "should-not-be-written" }); + }), + ), + ); + + // The mutate must re-verify ownership immediately before writing and ABORT + // rather than race the process that now holds the lock. + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, PluginLockfileLockError); + } + // The lockfile was not written (the aborted mutation left it unchanged)... + const after = yield* store.readLockfile; + assert.deepEqual(after, before); + // ...and our finalizer must NOT delete the foreign-owned lock; doing so + // would break single-writer for the process that now holds it. + assert.isTrue(yield* fs.exists(store.advisoryLockPath)); + const content = yield* fs.readFileString(store.advisoryLockPath); + assert.isTrue(content.includes("foreign-owner-token")); + + yield* fs.remove(store.advisoryLockPath, { force: true }); + }), + ); + it.effect("rejects invalid state transitions", () => Effect.gen(function* () { const store = yield* PluginLockfileStoreModule.PluginLockfileStore; @@ -144,3 +188,55 @@ layer("PluginLockfileStore", (it) => { }), ); }); + +// A FileSystem whose `open(..., { flag: "wx" })` returns a File whose writeAll +// always fails, simulating an IO error (ENOSPC) after `wx` has already created +// the advisory lock file. Only the "wx" open is intercepted so the temp-file +// write path is unaffected; the file-close finalizer lives on the underlying +// acquireRelease, so overriding writeAll alone is safe. +const writeFailingFileSystem = (base: FileSystem.FileSystem): FileSystem.FileSystem => ({ + ...base, + open: (path, options) => + options?.flag === "wx" + ? base.open(path, options).pipe( + Effect.map((file) => { + const failing = Object.create(file); + failing.writeAll = () => Effect.fail({ _tag: "SimulatedWriteFailure" as const }); + return failing as FileSystem.File; + }), + ) + : base.open(path, options), +}); + +const writeFailingLayer = it.layer( + PluginLockfileStoreModule.layer.pipe( + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-lockfile-fail-" })), + ), + Layer.provideMerge( + Layer.effect( + FileSystem.FileSystem, + FileSystem.FileSystem.pipe(Effect.map(writeFailingFileSystem)), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + Layer.provideMerge(TestClock.layer()), + ), +); + +writeFailingLayer("PluginLockfileStore advisory lock cleanup", (it) => { + it.effect("removes the just-created lock file when the acquire write fails", () => + Effect.gen(function* () { + const store = yield* PluginLockfileStoreModule.PluginLockfileStore; + const fs = yield* FileSystem.FileSystem; + + const result = yield* Effect.result( + store.updatePlugin(pluginId, () => Effect.succeed(makePlugin())), + ); + assert.isTrue(Result.isFailure(result)); + // `wx` created the lock file, but writeAll failed; onError must remove it + // so a partial acquire does not orphan a lock that would block all + // mutations until STALE_LOCK_MS elapses. + assert.isFalse(yield* fs.exists(store.advisoryLockPath)); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginLockfileStore.ts b/apps/server/src/plugins/PluginLockfileStore.ts index 4649599aa30..c0d17317184 100644 --- a/apps/server/src/plugins/PluginLockfileStore.ts +++ b/apps/server/src/plugins/PluginLockfileStore.ts @@ -15,6 +15,7 @@ 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 NodeCrypto from "node:crypto"; import * as ServerConfig from "../config.ts"; import { pluginAdvisoryLockPath, pluginLockfilePath } from "./PluginPaths.ts"; @@ -35,10 +36,13 @@ export class PluginLockfileReadError extends Schema.TaggedErrorClass()( "PluginLockfileCorruptError", - { path: Schema.String, detail: Schema.String, cause: Schema.Defect() }, + { path: Schema.String, cause: Schema.Defect() }, ) { + // Derive the message from the stable `path` only — never from the stringified + // decode error — so the wrapper cannot leak corrupt lockfile contents. The + // underlying failure is preserved on `cause` (Schema.Defect) for diagnostics. override get message(): string { - return `Plugin lockfile at ${this.path} is corrupt: ${this.detail}`; + return `Plugin lockfile at ${this.path} is corrupt.`; } } @@ -95,12 +99,21 @@ export class PluginLockfileStore extends Context.Service< PluginLockfile, PluginLockfileReadError | PluginLockfileCorruptError >; - readonly updatePlugin: ( + readonly updateSources: ( + fn: ( + sources: ReadonlyArray, + lockfile: PluginLockfile, + ) => Effect.Effect< + ReadonlyArray, + PluginLockfileStoreError + >, + ) => Effect.Effect; + readonly updatePlugin: ( id: PluginId, fn: ( context: PluginLockfileMutationContext, - ) => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect, + ) => Effect.Effect; readonly removePlugin: ( id: PluginId, ) => Effect.Effect; @@ -133,7 +146,6 @@ const readLockfileFromPath = (lockfilePath: string) => (cause) => new PluginLockfileCorruptError({ path: lockfilePath, - detail: String(cause), cause, }), ), @@ -178,6 +190,11 @@ const acquireAdvisoryLock = (input: { Effect.acquireRelease( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + // Owner token written into the lock file on acquire and re-checked in the + // finalizer: a random nonce makes it unique to THIS holder even across + // stale-lock reclamation, so a slow/paused prior holder's finalizer cannot + // delete a different process's now-valid lock and break single-writer. + const ownerToken = `${process.pid}:${NodeCrypto.randomUUID()}`; yield* fs .makeDirectory(input.pluginsDir, { recursive: true }) .pipe( @@ -189,23 +206,42 @@ const acquireAdvisoryLock = (input: { 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`), + // `wx` created the file; only the write/sync can now fail. If it does + // (ENOSPC/IO) or is interrupted mid-write, remove the file we just + // created — acquire has not succeeded, so acquireRelease's release is + // not registered, and an orphaned lock would block all lockfile + // mutations until STALE_LOCK_MS elapses. + yield* file.writeAll(new TextEncoder().encode(`${ownerToken}\n`)).pipe( + Effect.andThen(file.sync), + Effect.onError(() => + fs.remove(input.advisoryLockPath, { force: true }).pipe(Effect.ignore), + ), ); - yield* file.sync; }), ); const opened = yield* openLock.pipe(Effect.result); - if (Result.isSuccess(opened)) return input.advisoryLockPath; + if (Result.isSuccess(opened)) return { path: input.advisoryLockPath, token: ownerToken }; - const stat = yield* fs - .stat(input.advisoryLockPath) - .pipe( + const statOption = yield* fs.stat(input.advisoryLockPath).pipe( + Effect.map((info) => Option.some(info)), + // The lock was released between openLock failing and this stat — it is + // free now, so retry acquisition instead of reporting spurious + // contention as a lock error. + Effect.catchIf(isNotFound, () => Effect.succeed(Option.none())), + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); + if (Option.isNone(statOption)) { + yield* openLock.pipe( Effect.mapError( (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), ), ); + return { path: input.advisoryLockPath, token: ownerToken }; + } + const stat = statOption.value; const mtime = Option.getOrUndefined(stat.mtime); const ageMs = mtime ? (yield* Clock.currentTimeMillis) - mtime.getTime() : 0; if (ageMs <= STALE_LOCK_MS) { @@ -215,12 +251,64 @@ const acquireAdvisoryLock = (input: { }); } + // Re-check the lock's mtime immediately before removing it. Between the + // first stat above and now, another process may have released this stale + // lock and a third re-acquired it. Removing a lock that was refreshed in + // the meantime would delete a fresh, valid lock and break the + // single-writer guarantee. Only reclaim when the file is unchanged (same + // stale mtime) or already gone; if it was refreshed, treat it as live + // contention and fail rather than clobber it. + const stillStale = yield* fs.stat(input.advisoryLockPath).pipe( + Effect.map((current) => { + const currentMtime = Option.getOrUndefined(current.mtime); + return ( + currentMtime !== undefined && + mtime !== undefined && + currentMtime.getTime() === mtime.getTime() + ); + }), + // Already released between our checks — safe to (re)acquire. + Effect.catchIf(isNotFound, () => Effect.succeed(true)), + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); + if (!stillStale) { + return yield* new PluginLockfileLockError({ + path: input.advisoryLockPath, + cause: opened.failure, + }); + } + yield* Effect.logWarning("Reclaiming stale plugin lockfile advisory lock", { path: input.advisoryLockPath, ageMs, }); + // Claim the stale lock ATOMICALLY by renaming it to a token-unique path + // before removing it. `remove` is not exclusive: two reclaimers that both + // pass the stillStale mtime check would both `remove` and then one could + // clobber the other's freshly-created lock (P2's remove landing between P1's + // create and P1's use), leaving BOTH believing they hold the lock and losing + // a lockfile mutation. Only ONE racer can rename a given source path — the + // loser sees the source already gone (NotFound) and reports contention. + const reclaimPath = `${input.advisoryLockPath}.reclaim-${ownerToken.replaceAll(/[^a-zA-Z0-9._-]/g, "_")}`; + const claimed = yield* fs.rename(input.advisoryLockPath, reclaimPath).pipe( + Effect.as(true), + // Another reclaimer won (renamed/removed it first) — treat as live + // contention rather than clobbering their now-valid lock. + Effect.catchIf(isNotFound, () => Effect.succeed(false)), + Effect.mapError( + (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), + ), + ); + if (!claimed) { + return yield* new PluginLockfileLockError({ + path: input.advisoryLockPath, + cause: opened.failure, + }); + } yield* fs - .remove(input.advisoryLockPath, { force: true }) + .remove(reclaimPath, { force: true }) .pipe( Effect.mapError( (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), @@ -231,11 +319,22 @@ const acquireAdvisoryLock = (input: { (cause) => new PluginLockfileLockError({ path: input.advisoryLockPath, cause }), ), ); - return input.advisoryLockPath; + return { path: input.advisoryLockPath, token: ownerToken }; }), - (lockPath) => + ({ path: lockPath, token }) => FileSystem.FileSystem.pipe( - Effect.flatMap((fs) => fs.remove(lockPath, { force: true })), + Effect.flatMap((fs) => + // Only remove the lock if it still carries OUR token. If a stale-lock + // reclaimer overwrote it with its own token, leave that valid lock in + // place instead of clobbering it. + fs + .readFileString(lockPath) + .pipe( + Effect.flatMap((content) => + content.trim() === token ? fs.remove(lockPath, { force: true }) : Effect.void, + ), + ), + ), Effect.ignore, ), ); @@ -257,17 +356,37 @@ export const make = Effect.fn("PluginLockfileStore.make")(function* () { const readLockfile = provideLocalServices(readLockfileFromPath(lockfilePath)); - const mutate = ( - update: (lockfile: PluginLockfile) => Effect.Effect, + const mutate = ( + update: ( + lockfile: PluginLockfile, + ) => Effect.Effect, ) => provideLocalServices( semaphore.withPermits(1)( Effect.scoped( acquireAdvisoryLock({ pluginsDir: config.pluginsDir, advisoryLockPath }).pipe( - Effect.flatMap(() => + Effect.flatMap((lock) => Effect.gen(function* () { const current = yield* readLockfile; const next = yield* update(current); + // Re-verify we still own the advisory lock immediately before + // writing. If this fiber/process was paused past STALE_LOCK_MS + // (GC/CPU starvation) another process may have reclaimed the lock + // and become the writer; writing now would race it and lose an + // update. The owner token is unique per acquisition, so a mismatch + // means we were reclaimed. + const owner = yield* fs.readFileString(lock.path).pipe( + Effect.map((content) => content.trim()), + Effect.orElseSucceed(() => ""), + ); + if (owner !== lock.token) { + return yield* new PluginLockfileLockError({ + path: lock.path, + cause: new Error( + "advisory lock ownership lost before write (reclaimed by another writer)", + ), + }); + } yield* writeLockfileToPath({ pluginsDir: config.pluginsDir, lockfilePath, @@ -281,8 +400,13 @@ export const make = Effect.fn("PluginLockfileStore.make")(function* () { ), ); - const updatePlugin: PluginLockfileStore["Service"]["updatePlugin"] = (id, fn) => - mutate((lockfile) => + const updatePlugin = ( + id: PluginId, + fn: ( + context: PluginLockfileMutationContext, + ) => Effect.Effect, + ): Effect.Effect => + mutate((lockfile) => Effect.gen(function* () { const current = lockfile.plugins[id]; const nextPlugin = yield* fn({ lockfile, current }); @@ -296,6 +420,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 +450,7 @@ export const make = Effect.fn("PluginLockfileStore.make")(function* () { lockfilePath, advisoryLockPath, readLockfile, + updateSources, updatePlugin, removePlugin, transition, 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/PluginModuleLoader.ts b/apps/server/src/plugins/PluginModuleLoader.ts index 3ae58d521dc..e3dae519b1b 100644 --- a/apps/server/src/plugins/PluginModuleLoader.ts +++ b/apps/server/src/plugins/PluginModuleLoader.ts @@ -5,7 +5,8 @@ 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 Semaphore from "effect/Semaphore"; +import * as NodeURL from "node:url"; import * as ServerConfig from "../config.ts"; @@ -75,9 +76,17 @@ export const make = Effect.fn("PluginModuleLoader.make")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const ensureHostSingletonResolution = Effect.gen(function* () { - if (hostResolutionHookRegistered) return; - hostResolutionHookRegistered = true; + // Serialize concurrent callers so a late one (e.g. an enable/install RPC racing + // host.start) WAITS for the in-flight registration to finish before importing + // any plugin. The previous bare boolean flipped to `true` BEFORE the async + // register() completed, so a racing caller imported a plugin before the resolve + // hook existed and its `import "effect"` resolved off the host singleton (or + // failed), sticking the plugin as persistently "failed". The hook is a + // PROCESS-GLOBAL node registration, so the module-level guard still keeps it to + // one successful registration per process even across loader instances. + const registrationGate = yield* Semaphore.make(1); + + const registerHook = Effect.gen(function* () { const nodeModule = yield* Effect.promise(() => import("node:module")); if (typeof nodeModule.register !== "function") { yield* Effect.logWarning( @@ -85,22 +94,76 @@ export const make = Effect.fn("PluginModuleLoader.make")(function* () { ); return; } + // The loader hook must be its own module file (it runs in a loader-hook + // worker, loaded by URL), so it cannot be inlined into bin.mjs. It ships next + // to this file in source (./pluginResolveHooks.ts) and next to bin.mjs in the + // bundle (./plugins/pluginResolveHooks.mjs, emitted as a second pack entry). + // Register whichever exists so host-singleton resolution works in both the + // source server (dev) and the bundled server (desktop / production). + const hookCandidates = [ + new URL("./plugins/pluginResolveHooks.mjs", import.meta.url), + new URL("./pluginResolveHooks.mjs", import.meta.url), + new URL("./pluginResolveHooks.ts", import.meta.url), + ]; + let hookUrl: URL | undefined; + for (const candidate of hookCandidates) { + const present = yield* fs + .exists(NodeURL.fileURLToPath(candidate)) + .pipe(Effect.orElseSucceed(() => false)); + if (present) { + hookUrl = candidate; + break; + } + } + if (hookUrl === undefined) { + yield* Effect.logWarning( + "Plugin host singleton resolution hook module was not found; plugin host singleton resolution is disabled", + ); + return; + } + const registeredHookUrl = hookUrl; + // No internal catch here: a genuine register() failure must propagate so the + // gate below does NOT latch it, leaving it retryable rather than leaving the + // host permanently unhooked. yield* Effect.sync(() => - nodeModule.register(new URL("./pluginResolveHooks.ts", import.meta.url), { + nodeModule.register(registeredHookUrl, { parentURL: import.meta.url, data: { - pluginsRootUrl: pathToFileURL(config.pluginsDir).href, + pluginsRootUrl: NodeURL.pathToFileURL(config.pluginsDir).href, + // A host module URL used as the resolution anchor inside the loader-hook + // worker: `import.meta.resolve` is unavailable there, so the hook resolves + // shared `effect`/SDK specifiers by delegating to `nextResolve` from this + // host parent (which finds the host's node_modules), keeping plugins on the + // host's singleton instances. + hostAnchorUrl: import.meta.url, }, }), - ).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to register plugin host singleton resolution hook", { - cause, - }), - ), ); }); + const ensureHostSingletonResolution = registrationGate.withPermits(1)( + Effect.suspend(() => + hostResolutionHookRegistered + ? Effect.void + : registerHook.pipe( + // Latch ONLY on definitive completion (the hook registered, or a + // terminal "unavailable/not found" state that returned normally). A + // thrown/failed registration is caught, logged, and NOT latched, so the + // next caller retries. Callers still see a never-failing Effect. + Effect.flatMap(() => + Effect.sync(() => { + hostResolutionHookRegistered = true; + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to register plugin host singleton resolution hook", { + cause, + }), + ), + ), + ), + ); + const loadServerEntry: PluginModuleLoader["Service"]["loadServerEntry"] = ( pluginDir, entryRelPath, @@ -129,7 +192,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/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..30d56358c48 --- /dev/null +++ b/apps/server/src/plugins/PluginRpcDispatcher.ts @@ -0,0 +1,208 @@ +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 pluginId = runtime.manifest.id; + // Fail closed: only the exact "operate"/"read" scope literals map to a plugin + // scope requirement. Descriptors come from dynamically loaded plugin JS where + // the SDK's `"read" | "operate"` type is not runtime-enforced, so an + // unrecognized value (typo/casing like "Operate") must be REJECTED rather than + // silently treated as the weaker read requirement. + const requiredScope = + descriptor.scope === "operate" + ? pluginOperateScope(pluginId) + : descriptor.scope === "read" + ? pluginReadScope(pluginId) + : null; + if (requiredScope === null) { + return Effect.fail( + pluginRpcError( + pluginId, + "unauthorized", + `Plugin method declares an unrecognized scope: ${String(descriptor.scope)}.`, + ), + ); + } + return satisfiesScope(requiredScope, session.scopes) + ? Effect.void + : Effect.fail( + pluginRpcError( + pluginId, + "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; + + // Error-order disclosure, by design: callers holding the transport + // baseline scope can distinguish invalid-method from unauthorized (method + // enumeration). Plugin method names are not secrets — manifests and web + // bundles are user-readable — and the clearer typo diagnostics win. + 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/pluginResolveHooks.ts b/apps/server/src/plugins/pluginResolveHooks.ts index 1bada6a1094..f975d410d85 100644 --- a/apps/server/src/plugins/pluginResolveHooks.ts +++ b/apps/server/src/plugins/pluginResolveHooks.ts @@ -1,11 +1,16 @@ let pluginsRootUrl = ""; +let hostAnchorUrl: string | undefined; export function initialize(data: unknown) { - if (data && typeof data === "object" && "pluginsRootUrl" in data) { + if (data && typeof data === "object") { const value = (data as { readonly pluginsRootUrl?: unknown }).pluginsRootUrl; if (typeof value === "string") { pluginsRootUrl = value.endsWith("/") ? value : `${value}/`; } + const anchor = (data as { readonly hostAnchorUrl?: unknown }).hostAnchorUrl; + if (typeof anchor === "string") { + hostAnchorUrl = anchor; + } } } @@ -25,10 +30,11 @@ export async function resolve( ) => Promise, ) { if (shouldResolveFromHost(specifier, context.parentURL)) { - return { - shortCircuit: true, - url: import.meta.resolve(specifier), - }; + // `import.meta.resolve` is not available inside the ESM loader-hook worker. + // Re-anchor the resolution to a host module so Node's resolver finds the + // host's `effect`/SDK (handles bare packages AND arbitrary subpaths), keeping + // the plugin on the host's singleton instances. + return nextResolve(specifier, { parentURL: hostAnchorUrl ?? context.parentURL }); } return nextResolve(specifier, context); } 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/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index d0fb12f6153..7e28fc2e8d9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -19,9 +19,11 @@ import { AuthRelayWriteScope, AuthReviewWriteScope, AuthStandardClientScopes, + AuthStandardClientMarkerScopes, AuthTerminalOperateScope, type AuthClientSession, type AuthEnvironmentScope, + type AuthScope, type AuthPairingLink, type AdvertisedEndpoint, type DesktopDiscoveredSshHost, @@ -220,10 +222,19 @@ function AccessScopeSummary({ scopes, label, }: { - readonly scopes: ReadonlyArray; + // Accepts full AuthScopes so plugin scopes on a pairing link (e.g. + // `plugin::read`) render alongside environment scopes. Each scope + // is shown verbatim as a monospace code line in the popover. + readonly scopes: ReadonlyArray; readonly label: string; }) { const scopeCountLabel = `${scopes.length} ${scopes.length === 1 ? "scope" : "scopes"}`; + // A full standard client (holding the marker bundle) implicitly satisfies + // every plugin scope plus `plugins:manage` even when those are not listed + // verbatim — surface that so the listed scopes aren't read as the whole story. + const holdsStandardClientMarker = AuthStandardClientMarkerScopes.every((markerScope) => + scopes.includes(markerScope), + ); return ( @@ -255,6 +266,12 @@ function AccessScopeSummary({ ))} + {holdsStandardClientMarker ? ( +

+ Full standard client — implicitly includes plugin access and{" "} + plugins:manage. +

+ ) : null}
); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 1c5426d953e..9f7d253b781 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -3,6 +3,7 @@ import type { AuthClientMetadata, AuthEnvironmentScope, AuthPairingCredentialResult, + AuthScope, ServerAuthSessionMethod, AuthSessionId, AuthSessionState, @@ -120,7 +121,8 @@ const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { readonly id: string; readonly credential: string; - readonly scopes: ReadonlyArray; + // Full scopes (may include plugin scopes) mirroring AuthPairingLink.scopes. + readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; readonly createdAt: string; @@ -130,7 +132,10 @@ export interface ServerPairingLinkRecord { export interface ServerClientSessionRecord { readonly sessionId: AuthSessionId; readonly subject: string; - readonly scopes: ReadonlyArray; + // Full AuthScopes so plugin scopes carried by a downscoped session surface in + // the Clients panel, mirroring the pairing-link record above and the widened + // `AuthClientSession.scopes` contract. + readonly scopes: ReadonlyArray; readonly method: ServerAuthSessionMethod; readonly client: AuthClientMetadata; readonly issuedAt: string; 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..f1389c050d7 --- /dev/null +++ b/packages/contracts/src/auth.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; + +import { + AuthAccessWriteScope, + AuthOrchestrationReadScope, + AuthPairingLink, + AuthPluginsManageScope, + AuthRelayReadScope, + AuthStandardClientMarkerScopes, + 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("AuthPairingLink", () => { + const decode = Schema.decodeUnknownSync(AuthPairingLink); + const encode = Schema.encodeSync(AuthPairingLink); + + it("carries plugin scopes alongside environment scopes", () => { + const link = decode({ + id: "link-1", + credential: "TOKEN12345AB", + scopes: [AuthOrchestrationReadScope, pluginReadScope("acme-notes")], + subject: "one-time-token", + createdAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2026-01-01T01:00:00.000Z"), + }); + + expect(link.scopes).toEqual([AuthOrchestrationReadScope, "plugin:acme-notes:read"]); + // Round-trips through encode without dropping the plugin scope. + expect(encode(link).scopes).toEqual([AuthOrchestrationReadScope, "plugin:acme-notes:read"]); + }); +}); + +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("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"), 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 70b2899757d..2cd65bf0b58 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,44 @@ 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)) { + // 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); +} + +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 +210,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, }); @@ -210,7 +270,11 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ export const AuthPairingLink = Schema.Struct({ id: TrimmedNonEmptyString, credential: TrimmedNonEmptyString, - scopes: AuthEnvironmentScopes, + // Full AuthScopes (not just environment scopes) so that plugin scopes + // granted on a pairing link surface in the active-link list and the + // `pairingLinkUpserted` change event (whose payload is this struct). + // The persisted store row already holds full AuthScopes. + scopes: AuthScopes, subject: TrimmedNonEmptyString, label: Schema.optionalKey(TrimmedNonEmptyString), createdAt: Schema.DateTimeUtc, @@ -231,7 +295,11 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type; export const AuthClientSession = Schema.Struct({ sessionId: AuthSessionId, subject: TrimmedNonEmptyString, - scopes: AuthEnvironmentScopes, + // Full AuthScopes (not just environment scopes) so plugin scopes carried by a + // session (e.g. `plugin::operate` on a downscoped exchange token) surface + // faithfully in the Clients panel and `clientUpserted` change events, matching + // the fidelity `AuthPairingLink.scopes` already provides. + scopes: AuthScopes, method: ServerAuthSessionMethod, client: AuthClientMetadata, issuedAt: Schema.DateTimeUtc, @@ -330,14 +398,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/orchestration.ts b/packages/contracts/src/orchestration.ts index 15cfcffef08..b60311bf628 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -6,6 +6,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; import * as Struct from "effect/Struct"; import { ProviderOptionSelections } from "./model.ts"; import { RepositoryIdentity } from "./environment.ts"; +import { PLUGIN_ID_PATTERN_SOURCE } from "./plugin.ts"; import { ApprovalRequestId, CheckpointRef, @@ -124,13 +125,14 @@ 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-z][a-z0-9-]{1,40}$/; +// A plugin owner is `plugin:`; the id grammar is shared with the +// canonical plugin manifest id so widening it there cannot desync this decode +// boundary. The pattern also bounds length, so no separate max-length check is +// needed. +const THREAD_PLUGIN_OWNER_PATTERN = new RegExp(`^plugin:${PLUGIN_ID_PATTERN_SOURCE}$`); export const ThreadOwner = Schema.Union([ Schema.Literal("user"), - TrimmedNonEmptyString.check( - Schema.isMaxLength(128), - Schema.isPattern(THREAD_PLUGIN_OWNER_PATTERN), - ), + TrimmedNonEmptyString.check(Schema.isPattern(THREAD_PLUGIN_OWNER_PATTERN)), ]); export type ThreadOwner = typeof ThreadOwner.Type; export const DEFAULT_THREAD_OWNER: ThreadOwner = "user"; @@ -520,6 +522,12 @@ const ThreadCreateCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// Client-facing variant of thread.create: clients must NOT set `owner`. Thread +// ownership is server-injected by the agents capability (`plugin:`) so that +// plugin-created threads are hidden from user-facing views. Omitting the field +// from the client dispatch surface prevents a normal client (even one holding +// orchestration:operate) from forging a plugin-owned, hidden thread. Decoded +// commands carry no owner and the decider defaults them to "user". const ClientThreadCreateCommand = Schema.Struct({ type: Schema.Literal("thread.create"), commandId: CommandId, @@ -691,7 +699,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, - ClientThreadCreateCommand, + ThreadCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -805,7 +813,6 @@ const InternalOrchestrationCommand = Schema.Union([ export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; export const OrchestrationCommand = Schema.Union([ - ThreadCreateCommand, DispatchableClientOrchestrationCommand, InternalOrchestrationCommand, ]); diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts index 234604c4cb3..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, 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 cc41cc23265..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, @@ -108,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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a929f4a688..1494b170402 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14552,19 +14552,19 @@ snapshots: '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))': + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))': + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) @@ -14573,7 +14573,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -20246,8 +20246,8 @@ snapshots: dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) '@vitest/pretty-format': 4.1.9 @@ -20260,7 +20260,7 @@ snapshots: oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -20304,7 +20304,7 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) @@ -20328,7 +20328,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) transitivePeerDependencies: - msw From 7535a2389f0338c1ffede0d0745fda3f9cffd5a5 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 21:00:29 -0400 Subject: [PATCH 5/6] Add mechanical plugin capability facades Database, secrets, terminals, environments-read, projections-read, source-control, text-generation, and plugin HTTP route facades gated by per-plugin scopes, plus the projection read-model support they sit on. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .../Layers/ProjectionThreadActivities.ts | 3 +- .../Layers/ProjectionThreadMessages.ts | 3 +- .../src/persistence/Layers/ProjectionTurns.ts | 3 +- .../Services/ProjectionThreadActivities.ts | 4 + .../Services/ProjectionThreadMessages.ts | 5 + .../persistence/Services/ProjectionTurns.ts | 4 + apps/server/src/plugins/PluginHost.test.ts | 208 ++++++++- apps/server/src/plugins/PluginHost.ts | 167 ++++++- apps/server/src/plugins/PluginHttpRegistry.ts | 101 ++++ .../src/plugins/PluginHttpRoutes.test.ts | 315 +++++++++++++ apps/server/src/plugins/PluginHttpRoutes.ts | 230 ++++++++++ .../capabilities/DatabaseCapability.ts | 10 + .../EnvironmentsReadCapability.ts | 37 ++ .../plugins/capabilities/HttpCapability.ts | 8 + .../capabilities/PluginCapabilities.test.ts | 434 ++++++++++++++++++ .../capabilities/ProjectionsReadCapability.ts | 83 ++++ .../plugins/capabilities/SecretsCapability.ts | 68 +++ .../capabilities/SourceControlCapability.ts | 27 ++ .../capabilities/TerminalsCapability.ts | 87 ++++ .../capabilities/TextGenerationCapability.ts | 14 + apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 69 ++- packages/plugin-sdk/src/index.ts | 405 +++++++++++++++- 23 files changed, 2232 insertions(+), 55 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/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 2f4815f9654..4e9839a72c7 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -75,7 +75,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { const listProjectionThreadActivityRows = SqlSchema.findAll({ Request: ListProjectionThreadActivitiesInput, Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => + execute: ({ threadId, limit }) => sql` SELECT activity_id AS "activityId", @@ -94,6 +94,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { sequence ASC, created_at ASC, activity_id ASC + ${limit === undefined ? sql.literal("") : sql`LIMIT ${limit}`} `, }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 71919166886..f2c0d5ef9a6 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -119,7 +119,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { const listProjectionThreadMessageRows = SqlSchema.findAll({ Request: ListProjectionThreadMessagesInput, Result: ProjectionThreadMessageDbRowSchema, - execute: ({ threadId }) => + execute: ({ threadId, limit }) => sql` SELECT message_id AS "messageId", @@ -134,6 +134,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { FROM projection_thread_messages WHERE thread_id = ${threadId} ORDER BY created_at ASC, message_id ASC + ${limit === undefined ? sql.literal("") : sql`LIMIT ${limit}`} `, }); diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index bd57a4eaa30..e38a13ce292 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -172,7 +172,7 @@ const makeProjectionTurnRepository = Effect.gen(function* () { const listProjectionTurnsByThread = SqlSchema.findAll({ Request: ListProjectionTurnsByThreadInput, Result: ProjectionTurnDbRowSchema, - execute: ({ threadId }) => + execute: ({ threadId, limit }) => sql` SELECT thread_id AS "threadId", @@ -199,6 +199,7 @@ const makeProjectionTurnRepository = Effect.gen(function* () { checkpoint_turn_count ASC, requested_at ASC, turn_id ASC + ${limit === undefined ? sql.literal("") : sql`LIMIT ${limit}`} `, }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c47..fbf334edceb 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -35,6 +35,10 @@ export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; export const ListProjectionThreadActivitiesInput = Schema.Struct({ threadId: ThreadId, + // Optional row cap pushed into the SQL query (LIMIT). Omitted for callers that + // need the full timeline; bounded callers (plugin projections.read) pass it so + // a huge thread never fully materializes in memory. + limit: Schema.optional(NonNegativeInt), }); export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff320256..5e65c6afe92 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -9,6 +9,7 @@ import { ChatAttachment, MessageId, + NonNegativeInt, OrchestrationMessageRole, ThreadId, TurnId, @@ -36,6 +37,10 @@ export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; export const ListProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, + // Optional row cap pushed into the SQL query (LIMIT). Omitted for callers that + // need the full message list; bounded callers (plugin projections.read) pass it + // so a huge thread never fully materializes in memory. + limit: Schema.optional(NonNegativeInt), }); export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index f3d5d5e4706..ef54dda1de7 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -80,6 +80,10 @@ export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type; export const ListProjectionTurnsByThreadInput = Schema.Struct({ threadId: ThreadId, + // Optional row cap pushed into the SQL query (LIMIT). Omitted for callers that + // need the full turn list; bounded callers (plugin projections.read) pass it so + // a huge thread never fully materializes in memory. + limit: Schema.optional(NonNegativeInt), }); export type ListProjectionTurnsByThreadInput = typeof ListProjectionTurnsByThreadInput.Type; diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index d969841ed93..c8e2b61590f 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,116 @@ 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 +158,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 +216,7 @@ export default { const installPlugin = (input: { readonly pluginId: PluginId; readonly manifestHostApi?: string; + readonly capabilities?: ReadonlyArray; readonly entrySource?: string; readonly lockEntry?: Partial; }) => @@ -104,7 +234,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 +246,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 +406,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..af3a1c5299e 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,93 @@ 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"]; + }; +}): { 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); + + 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, - 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", terminalsBundle.capability), + 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), + ), + }; + + return { api, teardown }; +}; const upgradeLockfileEntry = ( entry: PluginLockfilePlugin, @@ -210,7 +296,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,9 +367,36 @@ 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 { api: hostApi, teardown: hostApiTeardown } = 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* () { + // 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); @@ -280,6 +405,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..fa40c642027 --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRegistry.ts @@ -0,0 +1,101 @@ +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; + // 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; + } + 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..27b4a10a347 --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRoutes.test.ts @@ -0,0 +1,315 @@ +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" }); + } + }), + ); + + 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) { + 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..1a9a8f7b450 --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRoutes.ts @@ -0,0 +1,230 @@ +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 * 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"; +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; +}; + +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; + 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 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 + .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..e900c763d45 --- /dev/null +++ b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts @@ -0,0 +1,434 @@ +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 Result from "effect/Result"; +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); + + const stored = yield* secrets.get("api-key"); + assert.deepEqual(Array.from(stored ?? []), Array.from(value)); + 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")); + }).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, 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 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 }]); + + // 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/ProjectionsReadCapability.ts b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts new file mode 100644 index 00000000000..a0cfc53c032 --- /dev/null +++ b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts @@ -0,0 +1,83 @@ +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..d466e55ea7e --- /dev/null +++ b/apps/server/src/plugins/capabilities/SecretsCapability.ts @@ -0,0 +1,68 @@ +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"; + +import * as ServerConfig from "../../config.ts"; +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"]; + readonly config: ServerConfig.ServerConfig["Service"]; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; +}): SecretsCapability { + const prefix = keyPrefix(input.pluginId); + const scoped = (name: string): Effect.Effect => + SECRET_NAME_PATTERN.test(name) + ? Effect.succeed(`${prefix}${name}`) + : Effect.fail(new PluginSecretNameError({ name })); + + return { + get: (name) => + scoped(name).pipe( + Effect.flatMap((key) => input.store.get(key)), + Effect.map( + Option.match({ + onNone: () => null, + onSome: (value) => value, + }), + ), + ), + 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 + .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..40dfab6cdf0 --- /dev/null +++ b/apps/server/src/plugins/capabilities/TerminalsCapability.ts @@ -0,0 +1,87 @@ +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 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"]; +}): 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 = + 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, + }); + live.set(terminalId, handle); + 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) => + 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 }; +} 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..eabee5cab7e 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,367 @@ 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< + ReadonlyArray, + 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 +467,41 @@ 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 1c8b40a949e4a0694b756081c5b83f79355e6010 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 21:02:19 -0400 Subject: [PATCH 6/6] Add agents and vcs plugin capability facades Durable agent turn dispatch/observe surface and vcs (git) capability facade for plugins, wired into the plugin host API and server registry. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/plugins/PluginHost.test.ts | 68 ++- apps/server/src/plugins/PluginHost.ts | 38 +- .../capabilities/AgentsCapability.test.ts | 477 ++++++++++++++++ .../plugins/capabilities/AgentsCapability.ts | 513 ++++++++++++++++++ .../capabilities/VcsCapability.test.ts | 224 ++++++++ .../src/plugins/capabilities/VcsCapability.ts | 329 +++++++++++ apps/server/src/server.ts | 3 + packages/plugin-sdk/src/index.ts | 383 ++++++++++++- 8 files changed, 2030 insertions(+), 5 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 c8e2b61590f..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"; @@ -41,7 +46,7 @@ const encodeManifestJson = Schema.encodeEffect(Schema.fromJsonString(PluginManif 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), @@ -62,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, @@ -107,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, @@ -148,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/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..fa87972eabd --- /dev/null +++ b/apps/server/src/plugins/capabilities/AgentsCapability.ts @@ -0,0 +1,513 @@ +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/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 eabee5cab7e..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 { @@ -362,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;