From 5f24c87668e72fd6136a24ed1e39e9b82435fc4b Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 20:48:35 -0400 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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; From 3ec871b4f4f701b7532565ecaab378f0c71d8a4e Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 21:09:28 -0400 Subject: [PATCH 7/9] Add web plugin host: bundle serving, import map, PluginUiHost Serve plugin web bundles + import-map host shims from the server, load and render plugin UI (routes, sidebar sections, settings pages) in the web app via PluginUiHost, and ship the @t3tools/plugin-sdk-web package. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/http.ts | 32 +- apps/server/src/plugins/PluginHost.test.ts | 28 + apps/server/src/plugins/PluginHost.ts | 77 ++- .../src/plugins/PluginWebRoutes.test.ts | 191 ++++++ apps/server/src/plugins/PluginWebRoutes.ts | 224 +++++++ apps/server/src/server.test.ts | 34 ++ apps/server/src/server.ts | 3 + apps/server/src/serverLifecycleEvents.ts | 7 +- apps/web/package.json | 1 + apps/web/src/components/Sidebar.tsx | 2 + .../settings/SettingsSidebarNav.test.ts | 42 ++ .../settings/SettingsSidebarNav.tsx | 29 +- apps/web/src/index.css | 5 + apps/web/src/main.tsx | 3 + .../src/plugins/PluginSidebarSections.test.ts | 36 ++ .../web/src/plugins/PluginSidebarSections.tsx | 54 ++ apps/web/src/plugins/PluginUiHost.test.tsx | 156 +++++ apps/web/src/plugins/PluginUiHost.tsx | 311 ++++++++++ apps/web/src/plugins/hostSingletons.test.ts | 94 +++ apps/web/src/plugins/hostSingletons.ts | 66 ++ apps/web/src/plugins/hostSingletonsReady.ts | 41 ++ apps/web/src/plugins/pluginSdkAtomReact.ts | 20 + apps/web/src/routeTree.gen.ts | 43 ++ apps/web/src/routes/__root.tsx | 2 + .../_chat.$environmentId.p.$pluginId.$.tsx | 62 ++ apps/web/src/routes/settings.$.tsx | 84 +++ apps/web/src/state/plugins.test.ts | 68 +++ apps/web/src/state/plugins.ts | 138 +++++ apps/web/vite.config.ts | 20 + .../client-runtime/src/state/server.test.ts | 34 +- packages/client-runtime/src/state/server.ts | 31 +- packages/contracts/src/preview.ts | 3 +- packages/contracts/src/previewAutomation.ts | 2 +- packages/contracts/src/server.test.ts | 24 +- packages/contracts/src/server.ts | 18 + packages/plugin-sdk-web/README.md | 56 ++ packages/plugin-sdk-web/package.json | 27 + packages/plugin-sdk-web/src/atomAdapter.ts | 17 + packages/plugin-sdk-web/src/externals.ts | 12 + packages/plugin-sdk-web/src/index.test.tsx | 57 ++ packages/plugin-sdk-web/src/index.ts | 161 +++++ packages/plugin-sdk-web/tsconfig.json | 16 + packages/plugin-sdk-web/vite.config.ts | 28 + packages/shared/package.json | 4 + packages/shared/src/pluginHostWeb.test.ts | 44 ++ packages/shared/src/pluginHostWeb.ts | 563 ++++++++++++++++++ pnpm-lock.yaml | 50 +- 47 files changed, 2958 insertions(+), 62 deletions(-) create mode 100644 apps/server/src/plugins/PluginWebRoutes.test.ts create mode 100644 apps/server/src/plugins/PluginWebRoutes.ts create mode 100644 apps/web/src/components/settings/SettingsSidebarNav.test.ts create mode 100644 apps/web/src/plugins/PluginSidebarSections.test.ts create mode 100644 apps/web/src/plugins/PluginSidebarSections.tsx create mode 100644 apps/web/src/plugins/PluginUiHost.test.tsx create mode 100644 apps/web/src/plugins/PluginUiHost.tsx create mode 100644 apps/web/src/plugins/hostSingletons.test.ts create mode 100644 apps/web/src/plugins/hostSingletons.ts create mode 100644 apps/web/src/plugins/hostSingletonsReady.ts create mode 100644 apps/web/src/plugins/pluginSdkAtomReact.ts create mode 100644 apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx create mode 100644 apps/web/src/routes/settings.$.tsx create mode 100644 apps/web/src/state/plugins.test.ts create mode 100644 apps/web/src/state/plugins.ts create mode 100644 packages/plugin-sdk-web/README.md create mode 100644 packages/plugin-sdk-web/package.json create mode 100644 packages/plugin-sdk-web/src/atomAdapter.ts create mode 100644 packages/plugin-sdk-web/src/externals.ts create mode 100644 packages/plugin-sdk-web/src/index.test.tsx create mode 100644 packages/plugin-sdk-web/src/index.ts create mode 100644 packages/plugin-sdk-web/tsconfig.json create mode 100644 packages/plugin-sdk-web/vite.config.ts create mode 100644 packages/shared/src/pluginHostWeb.test.ts create mode 100644 packages/shared/src/pluginHostWeb.ts diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index fc7a9ef13a2..cd36b442887 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -4,6 +4,7 @@ import { AuthOrchestrationReadScope, EnvironmentHttpApi, } from "@t3tools/contracts"; +import { injectPluginHostHeadHtml } from "@t3tools/shared/pluginHostWeb"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -45,6 +46,8 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { @@ -248,6 +251,20 @@ export const staticAndDevRouteLayer = HttpRouter.add( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const staticRoot = path.resolve(staticDir); + const serveIndexHtml = (indexPath: string) => + Effect.gen(function* () { + const indexData = yield* fileSystem + .readFile(indexPath) + .pipe(Effect.orElseSucceed(() => null)); + if (!indexData) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const html = textDecoder.decode(indexData); + return HttpServerResponse.uint8Array(textEncoder.encode(injectPluginHostHeadHtml(html)), { + status: 200, + contentType: "text/html; charset=utf-8", + }); + }); const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); @@ -282,16 +299,11 @@ export const staticAndDevRouteLayer = HttpRouter.add( const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); if (!fileInfo || fileInfo.type !== "File") { const indexPath = path.resolve(staticRoot, "index.html"); - const indexData = yield* fileSystem - .readFile(indexPath) - .pipe(Effect.orElseSucceed(() => null)); - if (!indexData) { - return HttpServerResponse.text("Not Found", { status: 404 }); - } - return HttpServerResponse.uint8Array(indexData, { - status: 200, - contentType: "text/html; charset=utf-8", - }); + return yield* serveIndexHtml(indexPath); + } + + if (path.basename(filePath).toLowerCase() === "index.html") { + return yield* serveIndexHtml(filePath); } const contentType = Mime.getType(filePath) ?? "application/octet-stream"; diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index 7186c391995..ad25a71c581 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts/plugin"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -19,6 +20,7 @@ import * as NodeURL from "node:url"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; @@ -52,6 +54,7 @@ const testLayerBase = PluginHostModule.layer.pipe( Layer.provideMerge(PluginMigrator.layer), Layer.provideMerge(PluginRuntimeRegistryLayer.layer), Layer.provideMerge(PluginHttpRegistry.layer), + Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provideMerge( Layer.mock(ServerSecretStore.ServerSecretStore)({ get: unexpectedCapabilityUse, @@ -432,6 +435,31 @@ layer("PluginHost", (it) => { }), ); + it.effect("publishes plugin state changes on the server lifecycle stream", () => + Effect.gen(function* () { + const pluginId = PluginId.make("lifecycle-plugin"); + const host = yield* PluginHostModule.PluginHost; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + + const eventFiber = yield* lifecycleEvents.stream.pipe( + Stream.filter((event) => event.type === "plugins" && event.payload.pluginId === pluginId), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* installPlugin({ pluginId, entrySource: "throw new Error('lifecycle boom');" }); + yield* host.start; + + const events = Array.from(yield* Fiber.join(eventFiber)); + assert.deepEqual(events[0]?.payload, { + kind: "plugin-state-changed", + pluginId, + state: "failed", + }); + }), + ); + it.effect("marks failed imports without failing host startup", () => Effect.gen(function* () { const pluginId = PluginId.make("failed-plugin"); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 81c6090dd4b..1aec0644fee 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -5,6 +5,7 @@ import { type PluginId, type PluginLockfile, type PluginLockfilePlugin, + type PluginState, } from "@t3tools/contracts/plugin"; import type { PluginDefinition, @@ -34,6 +35,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; @@ -339,6 +341,25 @@ export const make = Effect.fn("PluginHost.make")(function* () { const sourceControlRegistry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const github = yield* GitHubCli.GitHubCli; const terminals = yield* TerminalManager.TerminalManager; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + + const publishPluginStateChanged = (pluginId: PluginId, state: PluginState) => + lifecycleEvents + .publish({ + version: 1, + type: "plugins", + payload: { + kind: "plugin-state-changed", + pluginId, + state, + }, + }) + .pipe(Effect.ignoreCause({ log: true }), Effect.asVoid); + + const markFailure = (pluginId: PluginId, message: string) => + updateFailure(store, pluginId, message).pipe( + Effect.tap(() => publishPluginStateChanged(pluginId, "failed")), + ); const readManifest = (pluginDir: string) => fs @@ -356,9 +377,11 @@ export const make = Effect.fn("PluginHost.make")(function* () { }); } if (!hostApiSatisfies(manifest.hostApi, HOST_API_VERSION)) { - yield* store.updatePlugin(pluginId, ({ current }) => - Effect.succeed(current ? { ...current, state: "disabled-by-host" } : undefined), - ); + yield* store + .updatePlugin(pluginId, ({ current }) => + Effect.succeed(current ? { ...current, state: "disabled-by-host" } : undefined), + ) + .pipe(Effect.tap(() => publishPluginStateChanged(pluginId, "disabled-by-host"))); yield* Effect.logWarning("Plugin disabled by host API version mismatch", { pluginId, requested: manifest.hostApi, @@ -374,7 +397,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { const serverEntry = manifest.entries.server; const serverEntryPath = path.join(pluginDir, serverEntry); if (!(yield* fs.exists(pluginDir)) || !(yield* fs.exists(serverEntryPath))) { - yield* updateFailure(store, pluginId, "plugin directory or server entry is missing"); + yield* markFailure(pluginId, "plugin directory or server entry is missing"); return; } @@ -479,8 +502,10 @@ export const make = Effect.fn("PluginHost.make")(function* () { if (Exit.isFailure(exit)) { yield* Scope.close(scope, exit); const message = Cause.pretty(exit.cause); - yield* updateFailure(store, pluginId, message); + yield* markFailure(pluginId, message); yield* Effect.logWarning("Plugin activation failed", { pluginId, cause: message }); + } else { + yield* publishPluginStateChanged(pluginId, "active"); } }); @@ -493,34 +518,34 @@ export const make = Effect.fn("PluginHost.make")(function* () { } if (entry.state === "pending-upgrade") { if (!entry.staged) { - yield* updateFailure( - store, - pluginId, - "pending upgrade is missing staged plugin metadata", - ); + yield* markFailure(pluginId, "pending upgrade is missing staged plugin metadata"); return false; } const staged = entry.staged; - yield* store.updatePlugin(pluginId, ({ current }) => - Effect.succeed(current ? upgradeLockfileEntry(current, staged) : undefined), - ); + yield* store + .updatePlugin(pluginId, ({ current }) => + Effect.succeed(current ? upgradeLockfileEntry(current, staged) : undefined), + ) + .pipe(Effect.tap(() => publishPluginStateChanged(pluginId, "active"))); return true; } if (entry.activation.activatingSince !== null) { const crashCount = entry.activation.crashCount + 1; if (crashCount >= 2) { - yield* store.updatePlugin(pluginId, ({ current }) => - Effect.succeed( - current - ? { - ...current, - state: "failed", - lastError: "disabled after repeated crashes", - activation: { activatingSince: null, crashCount }, - } - : undefined, - ), - ); + yield* store + .updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + state: "failed", + lastError: "disabled after repeated crashes", + activation: { activatingSince: null, crashCount }, + } + : undefined, + ), + ) + .pipe(Effect.tap(() => publishPluginStateChanged(pluginId, "failed"))); return false; } yield* store.updatePlugin(pluginId, ({ current }) => @@ -571,7 +596,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { if (!currentEntry?.enabled || currentEntry.state !== "active") continue; yield* loadPlugin(pluginId, currentEntry).pipe( Effect.catchCause((cause) => - updateFailure(store, pluginId, Cause.pretty(cause)).pipe( + markFailure(pluginId, Cause.pretty(cause)).pipe( Effect.andThen( Effect.logWarning("Plugin activation failed before scope acquisition", { pluginId, diff --git a/apps/server/src/plugins/PluginWebRoutes.test.ts b/apps/server/src/plugins/PluginWebRoutes.test.ts new file mode 100644 index 00000000000..f4c9194e18d --- /dev/null +++ b/apps/server/src/plugins/PluginWebRoutes.test.ts @@ -0,0 +1,191 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId, type PluginLockfilePlugin } from "@t3tools/contracts/plugin"; +import { + PLUGIN_WEB_BUNDLE_CACHE_CONTROL, + PLUGIN_WEB_SHIM_CACHE_CONTROL, +} from "@t3tools/shared/pluginHostWeb"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { FetchHttpClient, HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import { pluginVersionDir } from "./PluginPaths.ts"; +import { pluginWebRouteLayer } from "./PluginWebRoutes.ts"; + +const pluginId = PluginId.make("web-plugin"); + +const canBindLoopback = async () => { + const NodeNet = await import("node:net"); + return await new Promise((resolve) => { + const server = NodeNet.createServer(); + server.once("error", () => { + resolve(false); + }); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + server.close(() => resolve(true)); + }); + }); +}; + +const loopbackAvailable = await canBindLoopback(); + +const nodeHttpServerLayer = Layer.unwrap( + Effect.promise(() => import("node:http")).pipe( + Effect.map((NodeHttp) => + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + ), +); + +const makePlugin = (overrides: Partial = {}): PluginLockfilePlugin => ({ + version: "1.0.0", + sha256: "sha", + sourceId: "local", + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + ...overrides, +}); + +const makeRouteLayer = () => + HttpRouter.serve(pluginWebRouteLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge(PluginLockfileStoreLayer.layer), + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-plugin-web-routes-" })), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(nodeHttpServerLayer), + Layer.provideMerge(FetchHttpClient.layer), + ); + +const routeUrl = (pathname: string) => + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + assert.fail(`Expected TCP address, got ${String(address)}`); + } + return `http://127.0.0.1:${address.port}${pathname}`; + }); + +const getPath = (pathname: string) => + Effect.gen(function* () { + const url = yield* routeUrl(pathname); + return yield* HttpClient.get(url); + }); + +const installPluginFile = (input: { + readonly plugin?: Partial; + readonly relativePath: string; + readonly contents: string; +}) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const store = yield* PluginLockfileStore; + const plugin = makePlugin(input.plugin); + yield* store.updatePlugin(pluginId, () => Effect.succeed(plugin)); + const versionDir = pluginVersionDir(config.pluginsDir, pluginId, plugin.version, path.join); + const filePath = path.join(versionDir, input.relativePath); + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, input.contents); + return { filePath, versionDir }; + }); + +if (loopbackAvailable) { + it.layer(makeRouteLayer())("plugin web route layer", (it) => { + it.effect("serves installed plugin web bundles with immutable cache headers", () => + Effect.gen(function* () { + yield* installPluginFile({ + relativePath: "web/entry.js", + contents: "export const ok = true;\n", + }); + + const response = yield* getPath("/plugins/web-plugin/1.0.0/web/entry.js"); + + assert.equal(response.status, 200); + assert.match(response.headers["content-type"] ?? "", /^text\/javascript/u); + assert.equal(response.headers["cache-control"], PLUGIN_WEB_BUNDLE_CACHE_CONTROL); + assert.equal(response.headers["x-content-type-options"], "nosniff"); + assert.equal(yield* response.text, "export const ok = true;\n"); + }), + ); + + it.effect("serves installed disabled plugin bundles", () => + Effect.gen(function* () { + yield* installPluginFile({ + plugin: { enabled: false, state: "disabled" }, + relativePath: "assets/panel.css", + contents: ".panel { color: red; }\n", + }); + + const response = yield* getPath("/plugins/web-plugin/1.0.0/assets/panel.css"); + + assert.equal(response.status, 200); + assert.match(response.headers["content-type"] ?? "", /^text\/css/u); + assert.equal(yield* response.text, ".panel { color: red; }\n"); + }), + ); + + it.effect("returns 404 for unknown plugins", () => + Effect.gen(function* () { + const response = yield* getPath("/plugins/missing-plugin/1.0.0/web/entry.js"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + + it.effect("rejects textual traversal and symlink escapes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { versionDir } = yield* installPluginFile({ + relativePath: "web/entry.js", + contents: "export const ok = true;\n", + }); + const outsideFile = path.join(path.dirname(versionDir), "outside.js"); + yield* fileSystem.writeFileString(outsideFile, "export const secret = true;\n"); + yield* fileSystem.symlink(outsideFile, path.join(versionDir, "web", "escape.js")); + + const traversal = yield* getPath("/plugins/web-plugin/1.0.0/web/%2e%2e/outside.js"); + const escape = yield* getPath("/plugins/web-plugin/1.0.0/web/escape.js"); + + assert.equal(traversal.status, 404); + assert.equal(escape.status, 404); + }), + ); + + it.effect("serves host shim modules as JavaScript with short cache headers", () => + Effect.gen(function* () { + const response = yield* getPath("/plugin-host/react.js"); + const source = yield* response.text; + + assert.equal(response.status, 200); + assert.match(response.headers["content-type"] ?? "", /^text\/javascript/u); + assert.equal(response.headers["cache-control"], PLUGIN_WEB_SHIM_CACHE_CONTROL); + assert.include(source, 'globalThis.__T3_PLUGIN_HOST__["react"]'); + assert.include(source, "export const useState = m.useState;"); + }), + ); + }); +} else { + describe.skip("plugin web live route layer", () => { + it("skips live router assertions when local TCP bind is unavailable", () => {}); + }); +} diff --git a/apps/server/src/plugins/PluginWebRoutes.ts b/apps/server/src/plugins/PluginWebRoutes.ts new file mode 100644 index 00000000000..e9b4652e728 --- /dev/null +++ b/apps/server/src/plugins/PluginWebRoutes.ts @@ -0,0 +1,224 @@ +import { PLUGIN_ID_PATTERN_SOURCE, type PluginId } from "@t3tools/contracts/plugin"; +import { + getPluginHostShimSource, + pluginHostModuleFromPath, + PLUGIN_WEB_BUNDLE_CACHE_CONTROL, + PLUGIN_WEB_SHIM_CACHE_CONTROL, +} from "@t3tools/shared/pluginHostWeb"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { pluginVersionDir } from "./PluginPaths.ts"; + +const PLUGIN_WEB_ROUTE_PREFIX = "/plugins/"; +const PLUGIN_HOST_ROUTE_PREFIX = "/plugin-host/"; +const PLUGIN_ID_PATTERN = new RegExp(`^${PLUGIN_ID_PATTERN_SOURCE}$`, "u"); + +const notFound = () => HttpServerResponse.text("Not Found", { status: 404 }); + +function decodeSegment(segment: string): string | null { + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +function hasInvalidPathSegment(segment: string): boolean { + return ( + segment.length === 0 || + segment === "." || + segment === ".." || + segment.includes("/") || + segment.includes("\\") || + segment.includes("\0") + ); +} + +function parsePluginWebPath(pathname: string): { + readonly pluginId: PluginId; + readonly version: string; + readonly relativePath: string; +} | null { + if (!pathname.startsWith(PLUGIN_WEB_ROUTE_PREFIX)) return null; + const rawParts = pathname.slice(PLUGIN_WEB_ROUTE_PREFIX.length).split("/"); + if (rawParts.length < 3) return null; + + const pluginId = decodeSegment(rawParts[0] ?? ""); + const version = decodeSegment(rawParts[1] ?? ""); + if ( + pluginId === null || + version === null || + !PLUGIN_ID_PATTERN.test(pluginId) || + hasInvalidPathSegment(version) + ) { + return null; + } + + const fileParts = rawParts.slice(2).map(decodeSegment); + if (fileParts.some((part) => part === null || hasInvalidPathSegment(part))) { + return null; + } + const safeParts = fileParts as Array; + if (safeParts[0] !== "web" && safeParts[0] !== "assets") { + return null; + } + + return { + pluginId: pluginId as PluginId, + version, + relativePath: safeParts.join("/"), + }; +} + +function isWithinRoot(root: string, candidate: string, separator: string): boolean { + return ( + candidate === root || + candidate.startsWith(root.endsWith(separator) ? root : `${root}${separator}`) + ); +} + +function contentTypeFor(filePath: string, extname: (path: string) => string): string { + switch (extname(filePath).toLowerCase()) { + case ".js": + case ".mjs": + return "text/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + case ".map": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".avif": + return "image/avif"; + case ".ico": + return "image/x-icon"; + case ".wasm": + return "application/wasm"; + default: + return "application/octet-stream"; + } +} + +const pluginBundleRouteLayer = HttpRouter.add( + "GET", + `${PLUGIN_WEB_ROUTE_PREFIX}*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return notFound(); + } + + const parsed = parsePluginWebPath(url.value.pathname); + if (!parsed) { + return notFound(); + } + + const lockfileStore = yield* PluginLockfileStore; + const lockfile = yield* lockfileStore.readLockfile.pipe( + Effect.catch((cause) => + Effect.logWarning("Could not read plugin lockfile for web bundle route", { cause }).pipe( + Effect.as(null), + ), + ), + ); + const lockfileEntry = lockfile?.plugins[parsed.pluginId]; + if (!lockfileEntry || lockfileEntry.version !== parsed.version) { + return notFound(); + } + + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const versionDir = pluginVersionDir( + config.pluginsDir, + parsed.pluginId, + parsed.version, + path.join, + ); + const versionDirRealPath = yield* fileSystem + .realPath(versionDir) + .pipe(Effect.orElseSucceed(() => null)); + if (!versionDirRealPath) { + return notFound(); + } + + const candidatePath = path.resolve(versionDir, parsed.relativePath); + const candidateRealPath = yield* fileSystem + .realPath(candidatePath) + .pipe(Effect.orElseSucceed(() => null)); + if (!candidateRealPath || !isWithinRoot(versionDirRealPath, candidateRealPath, path.sep)) { + return notFound(); + } + + const stat = yield* fileSystem.stat(candidateRealPath).pipe(Effect.orElseSucceed(() => null)); + if (!stat || stat.type !== "File") { + return notFound(); + } + + const data = yield* fileSystem + .readFile(candidateRealPath) + .pipe(Effect.orElseSucceed(() => null)); + if (!data) { + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + } + + return HttpServerResponse.uint8Array(data, { + status: 200, + contentType: contentTypeFor(candidateRealPath, path.extname), + headers: { + "Cache-Control": PLUGIN_WEB_BUNDLE_CACHE_CONTROL, + "X-Content-Type-Options": "nosniff", + }, + }); + }), +); + +const pluginHostShimRouteLayer = HttpRouter.add( + "GET", + `${PLUGIN_HOST_ROUTE_PREFIX}*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return notFound(); + } + const rawPath = url.value.pathname.slice(PLUGIN_HOST_ROUTE_PREFIX.length); + const decodedPath = decodeSegment(rawPath); + if (!decodedPath || decodedPath.includes("\0") || decodedPath.includes("..")) { + return notFound(); + } + const moduleName = pluginHostModuleFromPath(decodedPath); + if (!moduleName) { + return notFound(); + } + + return HttpServerResponse.text(getPluginHostShimSource(moduleName), { + status: 200, + contentType: "text/javascript; charset=utf-8", + headers: { + "Cache-Control": PLUGIN_WEB_SHIM_CACHE_CONTROL, + "X-Content-Type-Options": "nosniff", + }, + }); + }), +); + +export const pluginWebRouteLayer = Layer.mergeAll(pluginBundleRouteLayer, pluginHostShimRouteLayer); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fcf419cb91e..60ee4b4a9af 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3,6 +3,7 @@ import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { PLUGIN_HOST_IMPORT_MAP_MARKER } from "@t3tools/shared/pluginHostWeb"; import { AuthAccessTokenType, @@ -116,6 +117,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; +import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as Data from "effect/Data"; @@ -755,6 +757,7 @@ const buildAppUnderTest = (options?: { ), ), Layer.provideMerge(PluginHttpRegistry.layer), + Layer.provide(PluginLockfileStore.layer), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -1269,6 +1272,37 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("injects plugin host import map into index responses only", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-static-" }); + yield* fileSystem.writeFileString( + path.join(staticDir, "index.html"), + "T3shell", + ); + yield* fileSystem.writeFileString(path.join(staticDir, "app.js"), "console.log('asset');"); + + yield* buildAppUnderTest({ config: { staticDir } }); + + const root = yield* HttpClient.get("/"); + const rootHtml = yield* root.text; + assert.equal(root.status, 200); + assert.include(rootHtml, PLUGIN_HOST_IMPORT_MAP_MARKER); + assert.equal(rootHtml.match(new RegExp(PLUGIN_HOST_IMPORT_MAP_MARKER, "g"))?.length, 1); + + const fallback = yield* HttpClient.get("/deep/link"); + const fallbackHtml = yield* fallback.text; + assert.equal(fallback.status, 200); + assert.include(fallbackHtml, PLUGIN_HOST_IMPORT_MAP_MARKER); + assert.equal(fallbackHtml.match(new RegExp(PLUGIN_HOST_IMPORT_MAP_MARKER, "g"))?.length, 1); + + const asset = yield* HttpClient.get("/app.js"); + assert.equal(asset.status, 200); + assert.equal(yield* asset.text, "console.log('asset');"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("redirects to dev URL when configured", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 582769397c2..d38d73ded58 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,6 +35,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import { pluginHttpRouteLayer } from "./plugins/PluginHttpRoutes.ts"; +import { pluginWebRouteLayer } from "./plugins/PluginWebRoutes.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; import * as PluginMigrator from "./plugins/PluginMigrator.ts"; @@ -339,6 +340,7 @@ const PluginLayerLive = Layer.mergeAll( PluginRpcDispatcherLayerLive, PluginCatalogLayerLive, PluginHttpRegistryLayerLive, + PluginLockfileStoreLayerLive, ); const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( @@ -416,6 +418,7 @@ export const makeRoutesLayer = Layer.mergeAll( otlpTracesProxyRouteLayer, assetRouteLayer, pluginHttpRouteLayer, + pluginWebRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/serverLifecycleEvents.ts b/apps/server/src/serverLifecycleEvents.ts index 855d03490ef..5d6900f1719 100644 --- a/apps/server/src/serverLifecycleEvents.ts +++ b/apps/server/src/serverLifecycleEvents.ts @@ -8,7 +8,8 @@ import * as Stream from "effect/Stream"; type LifecycleEventInput = | Omit, "sequence"> - | Omit, "sequence">; + | Omit, "sequence"> + | Omit, "sequence">; interface SnapshotState { readonly sequence: number; @@ -42,7 +43,9 @@ const make = Effect.gen(function* () { const nextEvents = nextEvent.type === "welcome" ? [nextEvent, ...current.events.filter((entry) => entry.type !== "welcome")] - : [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")]; + : nextEvent.type === "ready" + ? [nextEvent, ...current.events.filter((entry) => entry.type !== "ready")] + : current.events; return [nextEvent, { sequence: nextSequence, events: nextEvents }] as const; }).pipe(Effect.tap((event) => PubSub.publish(pubsub, event))), snapshot: Ref.get(state), diff --git a/apps/web/package.json b/apps/web/package.json index 5a1579a478b..83a23b17215 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk-web": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..9700e14c626 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -126,6 +126,7 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { PluginSidebarSections } from "../plugins/PluginSidebarSections"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, @@ -3742,6 +3743,7 @@ export default function Sidebar() { projectsLength={projects.length} /> + diff --git a/apps/web/src/components/settings/SettingsSidebarNav.test.ts b/apps/web/src/components/settings/SettingsSidebarNav.test.ts new file mode 100644 index 00000000000..5ef5a1f207e --- /dev/null +++ b/apps/web/src/components/settings/SettingsSidebarNav.test.ts @@ -0,0 +1,42 @@ +import { PluginId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT } from "../../plugins/PluginUiHost"; +import { getSettingsNavItems } from "./SettingsSidebarNav"; + +describe("SettingsSidebarNav plugin entries", () => { + it("keeps the core settings navigation unchanged when no plugins register pages", () => { + expect(getSettingsNavItems(EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT).map((item) => item.to)).toEqual([ + "/settings/general", + "/settings/keybindings", + "/settings/providers", + "/settings/source-control", + "/settings/connections", + "/settings/archived", + ]); + }); + + it("adds registered plugin settings pages after core items", () => { + expect( + getSettingsNavItems({ + ...EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT, + settingsPages: [ + { + pluginId: PluginId.make("fixture-plugin"), + id: "general", + title: "Fixture", + component: () => null, + }, + ], + }).map((item) => item.to), + ).toEqual([ + "/settings/general", + "/settings/keybindings", + "/settings/providers", + "/settings/source-control", + "/settings/connections", + "/settings/archived", + "/settings/fixture-plugin/general", + ]); + }); +}); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..357fb16bf6f 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -6,9 +6,11 @@ import { GitBranchIcon, KeyboardIcon, Link2Icon, + PuzzleIcon, Settings2Icon, } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import { SidebarContent, @@ -21,18 +23,20 @@ import { useSidebar, } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; +import { pluginUiRegistryAtom, type PluginUiRegistrySnapshot } from "../../plugins/PluginUiHost"; -export type SettingsSectionPath = +export type CoreSettingsSectionPath = | "/settings/general" | "/settings/keybindings" | "/settings/providers" | "/settings/source-control" | "/settings/connections" | "/settings/archived"; +export type SettingsSectionPath = CoreSettingsSectionPath | `/settings/${string}`; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; - to: SettingsSectionPath; + to: CoreSettingsSectionPath; icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, @@ -43,16 +47,33 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; +export function getSettingsNavItems(snapshot: PluginUiRegistrySnapshot): ReadonlyArray<{ + readonly label: string; + readonly to: SettingsSectionPath; + readonly icon: ComponentType<{ className?: string }>; +}> { + return [ + ...SETTINGS_NAV_ITEMS, + ...snapshot.settingsPages.map((page) => ({ + label: page.title, + to: `/settings/${page.pluginId}/${page.id}` as const, + icon: PuzzleIcon, + })), + ]; +} + export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); + const pluginRegistry = useAtomValue(pluginUiRegistryAtom); + const navItems = getSettingsNavItems(pluginRegistry); const handleSectionClick = useCallback( (to: SettingsSectionPath) => { if (isMobile) { setOpenMobile(false); } - void navigate({ to, replace: true }); + void navigate({ to: to as never, replace: true }); }, [isMobile, navigate, setOpenMobile], ); @@ -72,7 +93,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { - {SETTINGS_NAV_ITEMS.map((item) => { + {navItems.map((item) => { const Icon = item.icon; const isActive = pathname === item.to; return ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3ae42a3e61c..45802e159f2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,3 +1,8 @@ +/* Declare a lowest-priority `plugins` cascade layer BEFORE Tailwind's layers, so + a plugin's injected stylesheet (wrapped in `@layer plugins`) can never override + host styles — host rules always win conflicts, while plugin-unique classes + (which nothing else defines) still apply. */ +@layer plugins, theme, base, components, utilities; @import "tailwindcss"; @custom-variant dark (&:is(.dark, .dark *)); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 453649bfdc5..a52e31aa409 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,6 @@ +// Establish the plugin-host readiness promise before anything else runs; the +// heavy singletons module itself is lazy-loaded by PluginUiHost on first use. +import "./plugins/hostSingletonsReady"; import React from "react"; import ReactDOM from "react-dom/client"; import { ClerkProvider } from "@clerk/react"; diff --git a/apps/web/src/plugins/PluginSidebarSections.test.ts b/apps/web/src/plugins/PluginSidebarSections.test.ts new file mode 100644 index 00000000000..d18af91201c --- /dev/null +++ b/apps/web/src/plugins/PluginSidebarSections.test.ts @@ -0,0 +1,36 @@ +import { PluginId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT } from "./PluginUiHost"; +import { getVisiblePluginSidebarSections } from "./PluginSidebarSections"; + +describe("PluginSidebarSections", () => { + it("renders no sidebar sections for the zero-plugin registry", () => { + expect(getVisiblePluginSidebarSections(EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT)).toEqual([]); + }); + + it("returns registered sidebar sections in registry order", () => { + const pluginId = PluginId.make("fixture-plugin"); + + expect( + getVisiblePluginSidebarSections({ + ...EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT, + sidebarSections: [ + { + pluginId, + id: "main", + title: "Fixture", + render: () => null, + }, + ], + }), + ).toEqual([ + { + pluginId, + id: "main", + title: "Fixture", + render: expect.any(Function), + }, + ]); + }); +}); diff --git a/apps/web/src/plugins/PluginSidebarSections.tsx b/apps/web/src/plugins/PluginSidebarSections.tsx new file mode 100644 index 00000000000..a03d9d315bf --- /dev/null +++ b/apps/web/src/plugins/PluginSidebarSections.tsx @@ -0,0 +1,54 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createElement, type FunctionComponent } from "react"; +import type { PluginSidebarSectionRenderProps } from "@t3tools/plugin-sdk-web"; + +import { useActiveEnvironmentId } from "../state/entities"; +import { + PluginSurfaceErrorBoundary, + pluginUiRegistryAtom, + type PluginUiRegistrySnapshot, +} from "./PluginUiHost"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuItem, +} from "../components/ui/sidebar"; + +export function getVisiblePluginSidebarSections(snapshot: PluginUiRegistrySnapshot) { + return snapshot.sidebarSections; +} + +export function PluginSidebarSections() { + const snapshot = useAtomValue(pluginUiRegistryAtom); + const environmentId = useActiveEnvironmentId(); + const sections = getVisiblePluginSidebarSections(snapshot); + + if (sections.length === 0) { + return null; + } + + return ( + <> + {sections.map((section) => { + const routeBasePath = + environmentId === null ? null : `/${environmentId}/p/${section.pluginId}`; + return ( + + {section.title} + + + + {createElement( + section.render as FunctionComponent, + { pluginId: section.pluginId, environmentId, routeBasePath }, + )} + + + + + ); + })} + + ); +} diff --git a/apps/web/src/plugins/PluginUiHost.test.tsx b/apps/web/src/plugins/PluginUiHost.test.tsx new file mode 100644 index 00000000000..77786339de6 --- /dev/null +++ b/apps/web/src/plugins/PluginUiHost.test.tsx @@ -0,0 +1,156 @@ +import { PluginId, type PluginInfo } from "@t3tools/contracts"; +import { defineWebPlugin } from "@t3tools/plugin-sdk-web"; +import { describe, expect, it } from "vite-plus/test"; +import * as Stream from "effect/Stream"; + +import { + createPluginUiHostState, + getPluginWebEntryUrl, + resolvePluginRouteRegistration, + resolvePluginSettingsPageRegistration, + syncPluginUiHostRegistrations, +} from "./PluginUiHost"; + +const fixturePluginId = PluginId.make("fixture-plugin"); +const failingPluginId = PluginId.make("failing-plugin"); + +function pluginInfo(overrides: Partial = {}): PluginInfo { + return { + id: fixturePluginId, + name: "Fixture", + version: "1.2.3", + state: "active", + capabilities: [], + hasWeb: true, + lastError: null, + ...overrides, + }; +} + +describe("PluginUiHost", () => { + it("awaits host readiness before importing and captures plugin registrations", async () => { + const state = createPluginUiHostState(); + const order: string[] = []; + const rpcCalls: string[] = []; + + const snapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo()], + waitForHost: async () => { + order.push("ready"); + }, + importWebPlugin: async (url) => { + order.push(`import:${url}`); + return { + default: defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ + path: "overview", + component: () => null, + }); + ctx.registerSidebarSection({ + id: "main", + title: "Main", + render: () => null, + }); + ctx.registerSettingsPage({ + id: "general", + title: "General", + component: () => null, + }); + void ctx.rpc.call("ping"); + }, + }), + }; + }, + createRpc: (pluginId) => ({ + call: (method) => { + rpcCalls.push(`${pluginId}:${method}`); + return Promise.resolve(null); + }, + subscribe: (method, payload) => Stream.make({ method, payload }), + }), + }); + + expect(order).toEqual(["ready", "import:/plugins/fixture-plugin/1.2.3/web/index.js"]); + expect(rpcCalls).toEqual(["fixture-plugin:ping"]); + expect(snapshot.routes).toHaveLength(1); + expect(snapshot.sidebarSections).toHaveLength(1); + expect(snapshot.settingsPages).toHaveLength(1); + expect(snapshot.failures).toEqual({}); + }); + + it("contains failing imports and removes surfaces when a plugin leaves active state", async () => { + const state = createPluginUiHostState(); + + const activeSnapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo()], + waitForHost: async () => undefined, + importWebPlugin: async () => ({ + default: defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ path: "overview", component: () => null }); + }, + }), + }), + }); + expect(activeSnapshot.routes).toHaveLength(1); + + const failedSnapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo({ id: failingPluginId, name: "Failing", version: "2.0.0" })], + waitForHost: async () => undefined, + importWebPlugin: async () => { + throw new Error("boom"); + }, + }); + expect(failedSnapshot.routes).toHaveLength(0); + expect(failedSnapshot.failures[failingPluginId]).toContain("boom"); + + const emptySnapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [], + waitForHost: async () => undefined, + importWebPlugin: async () => { + throw new Error("should not import"); + }, + }); + expect(emptySnapshot.routes).toEqual([]); + expect(emptySnapshot.failures).toEqual({}); + }); + + it("resolves registered plugin routes and settings pages without crashing unknown paths", async () => { + const state = createPluginUiHostState(); + const snapshot = await syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo()], + waitForHost: async () => undefined, + importWebPlugin: async () => ({ + default: defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ path: "overview", component: () => null }); + ctx.registerSettingsPage({ + id: "general", + title: "General", + component: () => null, + }); + }, + }), + }), + }); + + expect(resolvePluginRouteRegistration(snapshot, fixturePluginId, "overview")?.path).toBe( + "overview", + ); + expect(resolvePluginRouteRegistration(snapshot, fixturePluginId, "missing")).toBeNull(); + expect(resolvePluginSettingsPageRegistration(snapshot, fixturePluginId, "general")?.id).toBe( + "general", + ); + expect(resolvePluginSettingsPageRegistration(snapshot, fixturePluginId, "missing")).toBeNull(); + }); + + it("uses the conventional same-origin web entry URL", () => { + expect(getPluginWebEntryUrl(pluginInfo())).toBe("/plugins/fixture-plugin/1.2.3/web/index.js"); + }); +}); diff --git a/apps/web/src/plugins/PluginUiHost.tsx b/apps/web/src/plugins/PluginUiHost.tsx new file mode 100644 index 00000000000..352629f3365 --- /dev/null +++ b/apps/web/src/plugins/PluginUiHost.tsx @@ -0,0 +1,311 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { + PluginCommandRegistration, + PluginRouteRegistration, + PluginSettingsPageRegistration, + PluginSidebarSectionRegistration, + PluginUiContext, + PluginWebDefinition, + PluginWebRpc, +} from "@t3tools/plugin-sdk-web"; +import type { PluginId, PluginInfo } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { Component, useEffect, useRef, type ErrorInfo, type ReactNode } from "react"; + +import { pluginListAtom, pluginRpc } from "../state/plugins"; +import { whenPluginHostReady } from "./hostSingletons"; + +export interface RegisteredPluginRoute extends PluginRouteRegistration { + readonly pluginId: PluginId; +} + +export interface RegisteredPluginSidebarSection extends PluginSidebarSectionRegistration { + readonly pluginId: PluginId; +} + +export interface RegisteredPluginSettingsPage extends PluginSettingsPageRegistration { + readonly pluginId: PluginId; +} + +export interface RegisteredPluginCommand extends PluginCommandRegistration { + readonly pluginId: PluginId; +} + +export interface PluginUiRegistrySnapshot { + readonly routes: ReadonlyArray; + readonly sidebarSections: ReadonlyArray; + readonly settingsPages: ReadonlyArray; + readonly commands: ReadonlyArray; + readonly failures: Readonly>; +} + +interface LoadedPlugin { + readonly pluginId: PluginId; + readonly version: string; + readonly routes: ReadonlyArray; + readonly sidebarSections: ReadonlyArray; + readonly settingsPages: ReadonlyArray; + readonly commands: ReadonlyArray; + readonly failure: string | null; +} + +export interface PluginUiHostState { + readonly loaded: Map; +} + +export const EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT: PluginUiRegistrySnapshot = Object.freeze({ + routes: Object.freeze([]), + sidebarSections: Object.freeze([]), + settingsPages: Object.freeze([]), + commands: Object.freeze([]), + failures: Object.freeze({}), +}); + +export const pluginUiRegistryAtom = Atom.make( + EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT, +).pipe(Atom.keepAlive, Atom.withLabel("web-plugins:ui-registry")); + +export function createPluginUiHostState(): PluginUiHostState { + return { loaded: new Map() }; +} + +function normalizePluginPath(path: string): string { + return path + .split("/") + .filter((part) => part.length > 0) + .join("/"); +} + +function formatPluginError(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 ? error.message : String(error); +} + +function snapshotFromState(state: PluginUiHostState): PluginUiRegistrySnapshot { + const routes: Array = []; + const sidebarSections: Array = []; + const settingsPages: Array = []; + const commands: Array = []; + const failures: Record = {}; + + for (const loaded of state.loaded.values()) { + if (loaded.failure !== null) { + failures[loaded.pluginId] = loaded.failure; + continue; + } + routes.push(...loaded.routes); + sidebarSections.push(...loaded.sidebarSections); + settingsPages.push(...loaded.settingsPages); + commands.push(...loaded.commands); + } + + return { routes, sidebarSections, settingsPages, commands, failures }; +} + +export function getPluginWebEntryUrl(plugin: Pick): string { + // Slice 2b-2 uses the bundle convention served by PluginWebRoutes. + return `/plugins/${encodeURIComponent(plugin.id)}/${encodeURIComponent(plugin.version)}/web/index.js`; +} + +function makePluginLogger(pluginId: PluginId): PluginUiContext["logger"] { + const prefix = `[plugin:${pluginId}]`; + return { + debug: (message, data) => console.debug(prefix, message, data), + info: (message, data) => console.info(prefix, message, data), + warn: (message, data) => console.warn(prefix, message, data), + error: (message, data) => console.error(prefix, message, data), + }; +} + +function getDefinition(module: unknown): PluginWebDefinition { + const candidate = + typeof module === "object" && module !== null && "default" in module + ? (module as { readonly default?: unknown }).default + : null; + if ( + typeof candidate !== "object" || + candidate === null || + !("register" in candidate) || + typeof candidate.register !== "function" + ) { + throw new Error("Plugin web entry does not default-export a defineWebPlugin-shaped object."); + } + return candidate as PluginWebDefinition; +} + +async function maybeAwait(value: void | Promise): Promise { + await value; +} + +export interface SyncPluginUiHostRegistrationsInput { + readonly state: PluginUiHostState; + readonly plugins: ReadonlyArray; + readonly waitForHost: () => Promise; + readonly importWebPlugin: (url: string) => Promise; + readonly createRpc?: (pluginId: PluginId) => PluginWebRpc; +} + +export async function syncPluginUiHostRegistrations({ + state, + plugins, + waitForHost, + importWebPlugin, + createRpc = pluginRpc, +}: SyncPluginUiHostRegistrationsInput): Promise { + const activeWebPlugins = plugins.filter((plugin) => plugin.state === "active" && plugin.hasWeb); + const activeKeys = new Set(activeWebPlugins.map((plugin) => `${plugin.id}@${plugin.version}`)); + + for (const [pluginId, loaded] of state.loaded.entries()) { + if (!activeKeys.has(`${pluginId}@${loaded.version}`)) { + state.loaded.delete(pluginId); + } + } + + const pluginsToLoad = activeWebPlugins.filter((plugin) => !state.loaded.has(plugin.id)); + if (pluginsToLoad.length > 0) { + await waitForHost(); + } + + for (const plugin of pluginsToLoad) { + const routes: Array = []; + const sidebarSections: Array = []; + const settingsPages: Array = []; + const commands: Array = []; + + try { + const module = await importWebPlugin(getPluginWebEntryUrl(plugin)); + const definition = getDefinition(module); + const ctx: PluginUiContext = { + pluginId: plugin.id, + rpc: createRpc(plugin.id), + logger: makePluginLogger(plugin.id), + registerRoute: (registration) => { + routes.push({ + ...registration, + pluginId: plugin.id, + path: normalizePluginPath(registration.path), + }); + }, + registerSidebarSection: (registration) => { + sidebarSections.push({ ...registration, pluginId: plugin.id }); + }, + registerSettingsPage: (registration) => { + settingsPages.push({ ...registration, pluginId: plugin.id }); + }, + registerCommand: (registration) => { + commands.push({ ...registration, pluginId: plugin.id }); + }, + }; + await maybeAwait(definition.register(ctx)); + state.loaded.set(plugin.id, { + pluginId: plugin.id, + version: plugin.version, + routes, + sidebarSections, + settingsPages, + commands, + failure: null, + }); + } catch (error) { + const message = formatPluginError(error); + console.error(`[plugin:${plugin.id}] failed to register web UI`, error); + state.loaded.set(plugin.id, { + pluginId: plugin.id, + version: plugin.version, + routes: [], + sidebarSections: [], + settingsPages: [], + commands: [], + failure: message, + }); + } + } + + return snapshotFromState(state); +} + +export function resolvePluginRouteRegistration( + snapshot: PluginUiRegistrySnapshot, + pluginId: PluginId, + path: string | null | undefined, +): RegisteredPluginRoute | null { + const normalizedPath = normalizePluginPath(path ?? ""); + return ( + snapshot.routes.find((route) => route.pluginId === pluginId && route.path === normalizedPath) ?? + null + ); +} + +export function resolvePluginSettingsPageRegistration( + snapshot: PluginUiRegistrySnapshot, + pluginId: PluginId, + pageId: string | null | undefined, +): RegisteredPluginSettingsPage | null { + const normalizedPageId = normalizePluginPath(pageId ?? ""); + return ( + snapshot.settingsPages.find( + (page) => page.pluginId === pluginId && page.id === normalizedPageId, + ) ?? null + ); +} + +export function PluginUiHost() { + const plugins = useAtomValue(pluginListAtom); + const setRegistry = useAtomSet(pluginUiRegistryAtom); + const stateRef = useRef(createPluginUiHostState()); + // Single-flight the sync: syncs mutate the shared loaded Map, and two + // overlapping runs could import + register the same plugin twice. Chain each + // run after the previous so they never interleave; the latest plugins list + // always gets applied last. + const syncChainRef = useRef>(Promise.resolve()); + + useEffect(() => { + let cancelled = false; + const run = syncChainRef.current.then(() => + syncPluginUiHostRegistrations({ + state: stateRef.current, + plugins, + waitForHost: () => whenPluginHostReady, + importWebPlugin: (url) => import(/* @vite-ignore */ url), + }).then((snapshot) => { + if (!cancelled) { + setRegistry(snapshot); + } + }), + ); + syncChainRef.current = run.catch((error) => { + console.error("[plugin-ui-host] registry sync failed", error); + }); + + return () => { + cancelled = true; + }; + }, [plugins, setRegistry]); + + return null; +} + +export class PluginSurfaceErrorBoundary extends Component< + { readonly children: ReactNode; readonly label: string }, + { readonly error: Error | null } +> { + override state = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + override componentDidCatch(error: Error, info: ErrorInfo) { + console.error(`[plugin-ui] ${this.props.label} crashed`, error, info); + } + + override render() { + if (this.state.error) { + return ( +
+ Plugin surface failed to render. +
+ ); + } + return this.props.children; + } +} diff --git a/apps/web/src/plugins/hostSingletons.test.ts b/apps/web/src/plugins/hostSingletons.test.ts new file mode 100644 index 00000000000..152acc334ab --- /dev/null +++ b/apps/web/src/plugins/hostSingletons.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; +import { getPluginHostShimSource, pluginHostShimExportNames } from "@t3tools/shared/pluginHostWeb"; + +import { getPluginHost, whenPluginHostReady } from "./hostSingletons"; + +describe("hostSingletons", () => { + it("publishes the host singleton global with plugin import-map keys", async () => { + const host = getPluginHost(); + await expect(whenPluginHostReady).resolves.toBe(host); + + expect(Object.keys(host).sort()).toEqual([ + "@effect/atom-react", + "@t3tools/plugin-sdk-web", + "effect", + "react", + "react-dom", + "react-dom/client", + "react/jsx-dev-runtime", + "react/jsx-runtime", + ]); + expect(host.react.version).toBe("19.2.6"); + expect(typeof host["@t3tools/plugin-sdk-web"].hostCompat).toBe("object"); + }); + + it("react shim modules re-export the host singleton identities", async () => { + const currentHost = getPluginHost(); + const hostReact = { + default: { marker: "react-default" }, + useState: () => ["state"], + version: "test-react", + }; + globalThis.__T3_PLUGIN_HOST__ = { + ...currentHost, + react: hostReact as unknown as typeof currentHost.react, + }; + + const source = getPluginHostShimSource("react"); + const shim = (await import( + /* @vite-ignore */ `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}#react-shim-test` + )) as typeof import("react") & { readonly default: unknown }; + + expect(shim.default).toBe(hostReact.default); + expect(shim.useState).toBe(hostReact.useState); + expect(shim.version).toBe("test-react"); + + globalThis.__T3_PLUGIN_HOST__ = currentHost; + }); + + // Drift guard: the shim export-name lists are static snapshots of the host + // modules. If a dependency bump adds or removes an export, a shim's + // `export const X = m.X` silently yields undefined. Assert the snapshots + // still match the modules the host actually ships (this app package has the + // real deps, so a bump that drops a listed export fails CI here). + it("shim export names match the host modules the app actually ships", async () => { + const host = getPluginHost(); + for (const [specifier, names] of Object.entries(pluginHostShimExportNames)) { + const module = host[specifier as keyof typeof host] as Record; + const actual = new Set(Object.keys(module)); + const missing = names.filter((name) => !actual.has(name)); + expect(missing, `stale shim exports for ${specifier}`).toEqual([]); + } + }); + + // The generated shim must re-export the SAME identities the host holds for + // every module — not just react — so a plugin gets one effect/atom/sdk-web + // instance shared with the host. + it("every host module's shim re-exports the host's own identity", async () => { + const currentHost = getPluginHost(); + const marker = Symbol("host-identity"); + for (const [specifier, names] of Object.entries(pluginHostShimExportNames)) { + const probeName = names[0]; + if (!probeName) continue; + const realModule = currentHost[specifier as keyof typeof currentHost] as Record< + string, + unknown + >; + const stub = { ...realModule, [probeName]: { [marker]: specifier } }; + globalThis.__T3_PLUGIN_HOST__ = { + ...currentHost, + [specifier]: stub, + } as typeof currentHost; + + const source = getPluginHostShimSource(specifier as keyof typeof pluginHostShimExportNames); + const shim = (await import( + /* @vite-ignore */ `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}#${encodeURIComponent(specifier)}` + )) as Record; + + expect(shim[probeName], `shim identity mismatch for ${specifier}.${probeName}`).toBe( + stub[probeName], + ); + } + globalThis.__T3_PLUGIN_HOST__ = currentHost; + }); +}); diff --git a/apps/web/src/plugins/hostSingletons.ts b/apps/web/src/plugins/hostSingletons.ts new file mode 100644 index 00000000000..e5f525705e6 --- /dev/null +++ b/apps/web/src/plugins/hostSingletons.ts @@ -0,0 +1,66 @@ +import * as atomReact from "@effect/atom-react"; +import * as pluginSdkWeb from "@t3tools/plugin-sdk-web"; +import * as effect from "effect"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import * as ReactDOMClient from "react-dom/client"; +import * as jsxDevRuntime from "react/jsx-dev-runtime"; +import * as jsxRuntime from "react/jsx-runtime"; + +export interface PluginHostSingletons { + readonly react: typeof React; + readonly "react-dom": typeof ReactDOM; + readonly "react-dom/client": typeof ReactDOMClient; + readonly "react/jsx-runtime": typeof jsxRuntime; + readonly "react/jsx-dev-runtime": typeof jsxDevRuntime; + readonly "@effect/atom-react": typeof atomReact; + readonly effect: typeof effect; + readonly "@t3tools/plugin-sdk-web": typeof pluginSdkWeb; +} + +declare global { + // Host ESM shims read this object synchronously after the SPA boot module publishes it. + // eslint-disable-next-line no-var + var __T3_PLUGIN_HOST__: PluginHostSingletons | undefined; + // eslint-disable-next-line no-var + var __T3_PLUGIN_HOST_READY__: Promise | undefined; + // eslint-disable-next-line no-var + var __T3_PLUGIN_HOST_READY_RESOLVE__: ((host: PluginHostSingletons) => void) | undefined; +} + +let resolvePluginHostReady: (host: PluginHostSingletons) => void; + +export const whenPluginHostReady = + globalThis.__T3_PLUGIN_HOST_READY__ ?? + new Promise((resolve) => { + resolvePluginHostReady = resolve; + globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__ = resolve; + }); + +if (!globalThis.__T3_PLUGIN_HOST_READY__) { + globalThis.__T3_PLUGIN_HOST_READY__ = whenPluginHostReady; +} else { + resolvePluginHostReady = globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__ ?? (() => {}); +} + +const pluginHost: PluginHostSingletons = { + react: React, + "react-dom": ReactDOM, + "react-dom/client": ReactDOMClient, + "react/jsx-runtime": jsxRuntime, + "react/jsx-dev-runtime": jsxDevRuntime, + "@effect/atom-react": atomReact, + effect, + "@t3tools/plugin-sdk-web": pluginSdkWeb, +}; + +globalThis.__T3_PLUGIN_HOST__ = pluginHost; +globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__?.(pluginHost); +resolvePluginHostReady!(pluginHost); + +export function getPluginHost(): PluginHostSingletons { + if (!globalThis.__T3_PLUGIN_HOST__) { + throw new Error("T3 plugin host singletons have not been published."); + } + return globalThis.__T3_PLUGIN_HOST__; +} diff --git a/apps/web/src/plugins/hostSingletonsReady.ts b/apps/web/src/plugins/hostSingletonsReady.ts new file mode 100644 index 00000000000..bc6f58ce45c --- /dev/null +++ b/apps/web/src/plugins/hostSingletonsReady.ts @@ -0,0 +1,41 @@ +import type { PluginHostSingletons } from "./hostSingletons"; + +// Lightweight readiness half of the plugin host singletons. This module only +// wires up the shared `__T3_PLUGIN_HOST_READY__` promise (mirroring the head +// bootstrap script in @t3tools/shared/pluginHostWeb) — it must NOT import any +// host runtime modules. The heavy module (./hostSingletons) pulls the full +// `effect` barrel, `@t3tools/contracts`, and the SDK surface, so it stays +// code-split out of the main bundle and is loaded lazily by PluginUiHost when +// the first web plugin needs it. + +declare global { + // Host ESM shims read this object synchronously after the host publishes it. + var __T3_PLUGIN_HOST__: PluginHostSingletons | undefined; + var __T3_PLUGIN_HOST_READY__: Promise | undefined; + var __T3_PLUGIN_HOST_READY_RESOLVE__: ((host: PluginHostSingletons) => void) | undefined; +} + +let resolvePluginHostReady: (host: PluginHostSingletons) => void = () => {}; + +export const whenPluginHostReady = + globalThis.__T3_PLUGIN_HOST_READY__ ?? + new Promise((resolve) => { + resolvePluginHostReady = resolve; + globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__ = resolve; + }); + +if (!globalThis.__T3_PLUGIN_HOST_READY__) { + globalThis.__T3_PLUGIN_HOST_READY__ = whenPluginHostReady; +} else { + resolvePluginHostReady = globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__ ?? (() => {}); +} + +/** + * Publish the host singletons and resolve the shared readiness promise. Called + * exactly once, by ./hostSingletons as its final module-evaluation statement. + */ +export function publishPluginHostSingletons(host: PluginHostSingletons): void { + globalThis.__T3_PLUGIN_HOST__ = host; + globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__?.(host); + resolvePluginHostReady(host); +} diff --git a/apps/web/src/plugins/pluginSdkAtomReact.ts b/apps/web/src/plugins/pluginSdkAtomReact.ts new file mode 100644 index 00000000000..f1f3ccc7e55 --- /dev/null +++ b/apps/web/src/plugins/pluginSdkAtomReact.ts @@ -0,0 +1,20 @@ +export { + HydrationBoundary, + RegistryContext, + RegistryProvider, + TypeId, + make, + scheduleTask, + useAtom, + useAtomInitialValues, + useAtomMount, + useAtomRef, + useAtomRefProp, + useAtomRefPropValue, + useAtomRefresh, + useAtomSet, + useAtomSubscribe, + useAtomSuspense, + useAtomValue, +} from "@effect/atom-react"; +export { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..f5c54de403a 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,8 +20,10 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsSplatRouteImport } from './routes/settings.$' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +import { Route as ChatEnvironmentIdPPluginIdSplatRouteImport } from './routes/_chat.$environmentId.p.$pluginId.$' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -77,6 +79,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const SettingsSplatRoute = SettingsSplatRouteImport.update({ + id: '/$', + path: '/$', + getParentRoute: () => SettingsRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -88,11 +95,18 @@ const ChatEnvironmentIdThreadIdRoute = path: '/$environmentId/$threadId', getParentRoute: () => ChatRoute, } as any) +const ChatEnvironmentIdPPluginIdSplatRoute = + ChatEnvironmentIdPPluginIdSplatRouteImport.update({ + id: '/$environmentId/p/$pluginId/$', + path: '/$environmentId/p/$pluginId/$', + getParentRoute: () => ChatRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/$': typeof SettingsSplatRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -102,10 +116,12 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/$environmentId/p/$pluginId/$': typeof ChatEnvironmentIdPPluginIdSplatRoute } export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/$': typeof SettingsSplatRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -116,12 +132,14 @@ export interface FileRoutesByTo { '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/$environmentId/p/$pluginId/$': typeof ChatEnvironmentIdPPluginIdSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/$': typeof SettingsSplatRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -132,6 +150,7 @@ export interface FileRoutesById { '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute + '/_chat/$environmentId/p/$pluginId/$': typeof ChatEnvironmentIdPPluginIdSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -139,6 +158,7 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -148,10 +168,12 @@ export interface FileRouteTypes { | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/$environmentId/p/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -162,11 +184,13 @@ export interface FileRouteTypes { | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/$environmentId/p/$pluginId/$' id: | '__root__' | '/_chat' | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -177,6 +201,7 @@ export interface FileRouteTypes { | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/_chat/$environmentId/p/$pluginId/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -264,6 +289,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/settings/$': { + id: '/settings/$' + path: '/$' + fullPath: '/settings/$' + preLoaderRoute: typeof SettingsSplatRouteImport + parentRoute: typeof SettingsRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -278,6 +310,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport parentRoute: typeof ChatRoute } + '/_chat/$environmentId/p/$pluginId/$': { + id: '/_chat/$environmentId/p/$pluginId/$' + path: '/$environmentId/p/$pluginId/$' + fullPath: '/$environmentId/p/$pluginId/$' + preLoaderRoute: typeof ChatEnvironmentIdPPluginIdSplatRouteImport + parentRoute: typeof ChatRoute + } } } @@ -285,17 +324,20 @@ interface ChatRouteChildren { ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute + ChatEnvironmentIdPPluginIdSplatRoute: typeof ChatEnvironmentIdPPluginIdSplatRoute } const ChatRouteChildren: ChatRouteChildren = { ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, + ChatEnvironmentIdPPluginIdSplatRoute: ChatEnvironmentIdPPluginIdSplatRoute, } const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { + SettingsSplatRoute: typeof SettingsSplatRoute SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute @@ -306,6 +348,7 @@ interface SettingsRouteChildren { } const SettingsRouteChildren: SettingsRouteChildren = { + SettingsSplatRoute: SettingsSplatRoute, SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 36de3b95706..ddb2891226e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { CommandPalette } from "../components/CommandPalette"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; +import { PluginUiHost } from "../plugins/PluginUiHost"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { Button } from "../components/ui/button"; import { @@ -132,6 +133,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} diff --git a/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx new file mode 100644 index 00000000000..fbf45e6796e --- /dev/null +++ b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx @@ -0,0 +1,62 @@ +import { useAtomValue } from "@effect/atom-react"; +import { PluginId } from "@t3tools/contracts"; +import { createFileRoute } from "@tanstack/react-router"; +import { createElement, type FunctionComponent } from "react"; +import type { PluginRouteComponentProps } from "@t3tools/plugin-sdk-web"; + +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SidebarInset } from "../components/ui/sidebar"; +import { + PluginSurfaceErrorBoundary, + pluginUiRegistryAtom, + resolvePluginRouteRegistration, +} from "../plugins/PluginUiHost"; + +function splatFromParams(params: Record): string { + const value = params._splat ?? params["*"]; + return typeof value === "string" ? value : ""; +} + +function PluginRouteNotFound() { + return ( + + + + Plugin page not found + The plugin route is not registered. + + + + ); +} + +function PluginRouteView() { + const params = Route.useParams(); + const snapshot = useAtomValue(pluginUiRegistryAtom); + const pluginId = PluginId.make(params.pluginId); + const route = resolvePluginRouteRegistration(snapshot, pluginId, splatFromParams(params)); + + if (!route) { + return ; + } + + // Render the plugin component as its OWN React element (createElement), not + // by calling it as a function: a function call would run the plugin's hooks + // on this route's fiber and break the Rules of Hooks when the resolved + // component changes. As an element it gets its own fiber and the error + // boundary actually wraps the mounted component. + return ( + + + {createElement(route.component as FunctionComponent, { + pluginId: route.pluginId, + path: route.path, + })} + + + ); +} + +export const Route = createFileRoute("/_chat/$environmentId/p/$pluginId/$")({ + component: PluginRouteView, +}); diff --git a/apps/web/src/routes/settings.$.tsx b/apps/web/src/routes/settings.$.tsx new file mode 100644 index 00000000000..f7410ae0794 --- /dev/null +++ b/apps/web/src/routes/settings.$.tsx @@ -0,0 +1,84 @@ +import { useAtomValue } from "@effect/atom-react"; +import { PluginId } from "@t3tools/contracts"; +import { createFileRoute } from "@tanstack/react-router"; +import { createElement, type FunctionComponent } from "react"; +import type { PluginSettingsComponentProps } from "@t3tools/plugin-sdk-web"; + +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SettingsPageContainer } from "../components/settings/settingsLayout"; +import { + PluginSurfaceErrorBoundary, + pluginUiRegistryAtom, + resolvePluginSettingsPageRegistration, +} from "../plugins/PluginUiHost"; + +function splatFromParams(params: Record): string { + const value = params._splat ?? params["*"]; + return typeof value === "string" ? value : ""; +} + +function parseSettingsSplat(splat: string): { + readonly pluginId: PluginId; + readonly pageId: string; +} | null { + const parts = splat.split("/"); + const pluginIdIndex = parts.findIndex((part) => part.length > 0); + if (pluginIdIndex < 0) { + return null; + } + const rawPluginId = parts[pluginIdIndex]; + const pageId = parts + .slice(pluginIdIndex + 1) + .filter((part) => part.length > 0) + .join("/"); + if (!rawPluginId || pageId.length === 0) { + return null; + } + return { + pluginId: PluginId.make(rawPluginId), + pageId, + }; +} + +function PluginSettingsNotFound() { + return ( + + + + Plugin settings not found + The plugin settings page is not registered. + + + + ); +} + +function PluginSettingsRouteView() { + const params = Route.useParams(); + const parsed = parseSettingsSplat(splatFromParams(params)); + const snapshot = useAtomValue(pluginUiRegistryAtom); + const page = parsed + ? resolvePluginSettingsPageRegistration(snapshot, parsed.pluginId, parsed.pageId) + : null; + + if (!page) { + return ; + } + + // Render as an element (its own fiber) so plugin hooks work — see the note + // in the plugin route splat. + return ( + + + {createElement(page.component as FunctionComponent, { + pluginId: page.pluginId, + pageId: page.id, + })} + + + ); +} + +export const Route = createFileRoute("/settings/$")({ + component: PluginSettingsRouteView, +}); diff --git a/apps/web/src/state/plugins.test.ts b/apps/web/src/state/plugins.test.ts new file mode 100644 index 00000000000..3eaf842155c --- /dev/null +++ b/apps/web/src/state/plugins.test.ts @@ -0,0 +1,68 @@ +import { PluginId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { makePluginListStream, pluginRpc } from "./plugins"; + +const pluginId = PluginId.make("fixture-plugin"); + +describe("web plugin state", () => { + it.effect("loads plugin list initially and refreshes on plugin lifecycle changes", () => + Effect.gen(function* () { + let calls = 0; + const lists = yield* makePluginListStream( + Stream.make( + { + version: 1 as const, + sequence: 1, + type: "ready" as const, + payload: { at: "2026-07-03T00:00:00.000Z", environment: {} as never }, + }, + { + version: 1 as const, + sequence: 2, + type: "plugins" as const, + payload: { + kind: "plugin-state-changed" as const, + pluginId, + state: "active" as const, + }, + }, + ), + Effect.sync(() => { + calls += 1; + return [ + { + id: pluginId, + name: "Fixture", + version: "1.0.0", + state: "active" as const, + capabilities: [], + hasWeb: true, + lastError: null, + }, + ]; + }), + ).pipe(Stream.runCollect); + + expect(calls).toBe(2); + expect(Array.from(lists)).toHaveLength(2); + }), + ); + + it("binds plugin RPC helpers to one plugin id", () => { + const calls: Array = []; + const rpc = pluginRpc(pluginId, { + call: (id, method, payload) => { + calls.push([id, method, payload] as const); + return Promise.resolve({ ok: true }); + }, + subscribe: (id, method, payload) => Stream.make({ id, method, payload }), + }); + + void rpc.call("echo", { value: 1 }); + + expect(calls).toEqual([[pluginId, "echo", { value: 1 }]]); + }); +}); diff --git a/apps/web/src/state/plugins.ts b/apps/web/src/state/plugins.ts new file mode 100644 index 00000000000..fcd14231f47 --- /dev/null +++ b/apps/web/src/state/plugins.ts @@ -0,0 +1,138 @@ +import { + type PluginId, + type PluginInfo, + type ServerLifecycleStreamEvent, + WS_METHODS, +} from "@t3tools/contracts"; +import { callPlugin, listPlugins, subscribePlugin } from "@t3tools/client-runtime/rpc"; +import { + createEnvironmentRpcSubscriptionAtomFamily, + executeAtomQuery, + runInEnvironment, + runStreamInEnvironment, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { isPluginStateChangedLifecycleEvent } from "@t3tools/client-runtime/state/server"; +import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; + +const EMPTY_PLUGIN_LIST: ReadonlyArray = Object.freeze([]); + +export function makePluginListStream( + lifecycleEvents: Stream.Stream, + loadPlugins: Effect.Effect, E, R>, +): Stream.Stream, E, R> { + const reloads = lifecycleEvents.pipe( + Stream.filter(isPluginStateChangedLifecycleEvent), + Stream.mapEffect(() => loadPlugins), + ); + return Stream.concat(Stream.fromEffect(loadPlugins), reloads); +} + +const environmentPluginListResultAtom = createEnvironmentRpcSubscriptionAtomFamily( + connectionAtomRuntime, + { + label: "web-plugins:list", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => { + const loadPlugins = listPlugins().pipe( + Effect.map((result) => result.plugins), + Effect.catchCause((cause) => + Effect.logWarning("Could not refresh plugin list", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(EMPTY_PLUGIN_LIST)), + ), + ); + return makePluginListStream(stream, loadPlugins); + }, + }, +); + +export const environmentPluginListAtom = Atom.family((environmentId: string) => + Atom.make( + (get): ReadonlyArray => + Option.getOrElse( + AsyncResult.value( + get( + environmentPluginListResultAtom({ + environmentId: environmentId as never, + input: {}, + }), + ), + ), + () => EMPTY_PLUGIN_LIST, + ), + ).pipe(Atom.withLabel(`web-plugins:list:${environmentId}`)), +); + +export const pluginListAtom = Atom.make((get): ReadonlyArray => { + const environmentId = get(primaryEnvironmentIdAtom); + if (environmentId === null) { + return EMPTY_PLUGIN_LIST; + } + return get(environmentPluginListAtom(environmentId)); +}).pipe(Atom.withLabel("web-plugins:list")); + +export interface PluginRpcDependencies { + readonly call?: (pluginId: PluginId, method: string, payload?: unknown) => Promise; + readonly subscribe?: ( + pluginId: PluginId, + method: string, + payload?: unknown, + ) => Stream.Stream; +} + +async function defaultPluginCall( + pluginId: PluginId, + method: string, + payload?: unknown, +): Promise { + const environmentId = appAtomRegistry.get(primaryEnvironmentIdAtom); + if (environmentId === null) { + throw new Error("Plugin RPC is unavailable before the primary environment is connected."); + } + + const atom = connectionAtomRuntime + .atom(runInEnvironment(environmentId, callPlugin(pluginId, method, payload))) + .pipe(Atom.withLabel(`web-plugins:rpc:${pluginId}:${method}`)); + const result = await executeAtomQuery(appAtomRegistry, atom, { + reportDefect: false, + reportFailure: false, + }); + if (result._tag === "Success") { + return result.value; + } + throw squashAtomCommandFailure(result); +} + +function defaultPluginSubscribe( + pluginId: PluginId, + method: string, + payload?: unknown, +): Stream.Stream { + const environmentId = appAtomRegistry.get(primaryEnvironmentIdAtom); + if (environmentId === null) { + return Stream.fail( + new Error("Plugin RPC is unavailable before the primary environment is connected."), + ); + } + return runStreamInEnvironment(environmentId, subscribePlugin(pluginId, method, payload)); +} + +export function pluginRpc(pluginId: PluginId, dependencies: PluginRpcDependencies = {}) { + const call = dependencies.call ?? defaultPluginCall; + const subscribe = dependencies.subscribe ?? defaultPluginSubscribe; + return { + call: (method: string, payload?: unknown) => call(pluginId, method, payload), + subscribe: (method: string, payload?: unknown) => subscribe(pluginId, method, payload), + }; +} + +export { WS_METHODS }; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index bb6347bf1ac..e897039312e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,7 @@ import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; +import { injectPluginHostHeadHtml } from "@t3tools/shared/pluginHostWeb"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config"; import "vite-plus/test/config"; @@ -87,9 +88,19 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); +function pluginHostIndexHtmlPlugin() { + return { + name: "t3-plugin-host-index-html", + transformIndexHtml(html: string) { + return injectPluginHostHeadHtml(html); + }, + }; +} + export default defineConfig(() => { return { plugins: [ + pluginHostIndexHtmlPlugin(), tanstackRouter(), react(), babel({ @@ -156,6 +167,15 @@ export default defineConfig(() => { target: devProxyTarget, changeOrigin: true, }, + // Dev uses the backend as the single source for plugin shims and same-origin bundles. + "/plugin-host": { + target: devProxyTarget, + changeOrigin: true, + }, + "/plugins": { + target: devProxyTarget, + changeOrigin: true, + }, }, } : {}), diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 4b9564e031c..961899efb4b 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,8 +1,16 @@ -import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { + PluginId, + type ServerConfig, + type ServerLifecycleWelcomePayload, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { applyServerConfigProjection, projectServerWelcome } from "./server.ts"; +import { + applyServerConfigProjection, + isPluginStateChangedLifecycleEvent, + projectServerWelcome, +} from "./server.ts"; const CONFIG = { availableEditors: [], @@ -45,10 +53,30 @@ describe("server state projection", () => { }); const [afterReady, emitted] = projectServerWelcome(afterWelcome, { type: "ready", - payload: {}, + payload: {} as never, }); expect(Option.getOrThrow(afterReady)).toBe(welcome); expect(emitted).toEqual([]); }); + + it("detects plugin state change lifecycle events without disturbing welcome projection", () => { + const pluginId = PluginId.make("fixture-plugin"); + const event = { + version: 1 as const, + sequence: 1, + type: "plugins" as const, + payload: { + kind: "plugin-state-changed" as const, + pluginId, + state: "active" as const, + }, + }; + + const [next, emitted] = projectServerWelcome(Option.none(), event); + + expect(isPluginStateChangedLifecycleEvent(event)).toBe(true); + expect(Option.isNone(next)).toBe(true); + expect(emitted).toEqual([]); + }); }); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index eb784183793..0e11af9a355 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -2,6 +2,8 @@ import { type EnvironmentId, type ServerConfig, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, + type ServerLifecyclePluginStateChangedPayload, type ServerLifecycleWelcomePayload, WS_METHODS, } from "@t3tools/contracts"; @@ -70,10 +72,7 @@ export function projectServerConfig( export function projectServerWelcome( current: Option.Option, - event: { - readonly type: "welcome" | "ready"; - readonly payload: unknown; - }, + event: Pick, ): readonly [ Option.Option, ReadonlyArray, @@ -85,6 +84,21 @@ export function projectServerWelcome( return [Option.some(welcome), [welcome]]; } +export function isPluginStateChangedLifecycleEvent( + event: Pick, +): event is { + readonly type: "plugins"; + readonly payload: ServerLifecyclePluginStateChangedPayload; +} { + return ( + event.type === "plugins" && + typeof event.payload === "object" && + event.payload !== null && + "kind" in event.payload && + event.payload.kind === "plugin-state-changed" + ); +} + export function createServerEnvironmentAtoms( runtime: Atom.AtomRuntime, options: { @@ -154,6 +168,15 @@ export function createServerEnvironmentAtoms( Stream.mapAccum(Option.none, projectServerWelcome), ), }), + pluginStateChanges: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:plugin-state-changes", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => + stream.pipe( + Stream.filter(isPluginStateChangedLifecycleEvent), + Stream.map((event) => event.payload), + ), + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index c00f878bcbe..b3ad6d2ee4f 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -8,7 +8,8 @@ * * @module Preview */ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; + import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 6431dd0dcfd..2d09af79518 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index c906f86f4dc..0b4d4401691 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,9 +1,10 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { ServerProvider } from "./server.ts"; +import { ServerLifecycleStreamEvent, ServerProvider } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); +const decodeServerLifecycleEvent = Schema.decodeUnknownSync(ServerLifecycleStreamEvent); describe("ServerProvider", () => { it("defaults capability arrays when decoding provider snapshots", () => { @@ -72,3 +73,24 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); }); + +describe("ServerLifecycleStreamEvent", () => { + it("decodes plugin state change events", () => { + const parsed = decodeServerLifecycleEvent({ + version: 1, + sequence: 3, + type: "plugins", + payload: { + kind: "plugin-state-changed", + pluginId: "fixture-plugin", + state: "active", + }, + }); + + expect(parsed.type).toBe("plugins"); + if (parsed.type === "plugins") { + expect(parsed.payload.pluginId).toBe("fixture-plugin"); + expect(parsed.payload.state).toBe("active"); + } + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..1bf7bc6235f 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -20,6 +20,7 @@ import { EditorId } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; +import { PluginId, PluginState } from "./plugin.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -540,9 +541,26 @@ export const ServerLifecycleStreamReadyEvent = Schema.Struct({ }); export type ServerLifecycleStreamReadyEvent = typeof ServerLifecycleStreamReadyEvent.Type; +export const ServerLifecyclePluginStateChangedPayload = Schema.Struct({ + kind: Schema.Literal("plugin-state-changed"), + pluginId: PluginId, + state: PluginState, +}); +export type ServerLifecyclePluginStateChangedPayload = + typeof ServerLifecyclePluginStateChangedPayload.Type; + +export const ServerLifecycleStreamPluginsEvent = Schema.Struct({ + version: Schema.Literal(1), + sequence: NonNegativeInt, + type: Schema.Literal("plugins"), + payload: ServerLifecyclePluginStateChangedPayload, +}); +export type ServerLifecycleStreamPluginsEvent = typeof ServerLifecycleStreamPluginsEvent.Type; + export const ServerLifecycleStreamEvent = Schema.Union([ ServerLifecycleStreamWelcomeEvent, ServerLifecycleStreamReadyEvent, + ServerLifecycleStreamPluginsEvent, ]); export type ServerLifecycleStreamEvent = typeof ServerLifecycleStreamEvent.Type; diff --git a/packages/plugin-sdk-web/README.md b/packages/plugin-sdk-web/README.md new file mode 100644 index 00000000000..09c17e9d8e3 --- /dev/null +++ b/packages/plugin-sdk-web/README.md @@ -0,0 +1,56 @@ +# @t3tools/plugin-sdk-web + +> **Import `effect` from the barrel, not subpaths.** `import { Effect, Schema } from "effect"` ✅ — +> `import * as Effect from "effect/Effect"` ❌ does not resolve in a plugin web bundle. The host +> import map enumerates bare specifiers only. See [Importing `effect`](#importing-effect). + +Thin host-surface barrel for plugin web bundles. Plugin builds should treat these runtime modules +as externals and let the host import map resolve them at runtime: + +- `react` +- `react-dom` +- `react-dom/client` +- `react/jsx-runtime` +- `react/jsx-dev-runtime` +- `@effect/atom-react` +- `effect` +- `@t3tools/contracts` +- `@t3tools/plugin-sdk-web` + +The `pluginSdkWebExternalDependencies` / `isPluginSdkWebExternal` exports cover exactly this list +(subpaths included) and can be passed straight to Rollup's `external` in a plugin build. + +## Package shape: a compile-time surface, not a standalone build + +This package is deliberately **virtual**: `exports` points straight at `src/index.ts`, and the +barrel re-exports live host modules from `apps/web/src` by relative path. It is never built or +published on its own — it typechecks and tests through the workspace and resolves through the host +app's Vite module graph, so `@t3tools/plugin-sdk-web` _is_ the host's own component/state modules +(the same instances the import map serves to plugins as `/plugin-host/*.js` singletons at +runtime). That is also why plugin builds must externalize it: bundling it would inline host app +internals and duplicate React/atom/contracts state that has to stay singleton. + +## Importing `effect` + +The host import map maps the **bare `effect` specifier only** — not its subpaths. Import effect +modules from the barrel: + +```ts +import { Effect, Stream, Option } from "effect"; // ✅ resolves via the host import map +``` + +Do **not** import effect subpaths in a plugin web bundle: + +```ts +import * as Effect from "effect/Effect"; // ❌ not in the import map — fails to resolve in the browser +``` + +(This differs from server plugins, where a Node resolve hook handles subpaths. Web plugins rely on +the native browser import map, which enumerates the bare specifier.) + +## Tailwind + +Tailwind v4 utilities are emitted by scanning the host build. A separately-built plugin cannot +assume arbitrary Tailwind utility classes will exist in the host CSS. Use host CSS variables such +as `--background`, `--color-*`, and `.dark`, use the exported host design-system components, or +ship compiled plugin CSS for plugin-local classes. diff --git a/packages/plugin-sdk-web/package.json b/packages/plugin-sdk-web/package.json new file mode 100644 index 00000000000..d5ea5d0a938 --- /dev/null +++ b/packages/plugin-sdk-web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@t3tools/plugin-sdk-web", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "vp build", + "typecheck": "tsgo --noEmit", + "test": "vp test run" + }, + "dependencies": { + "@t3tools/client-runtime": "workspace:*", + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*" + }, + "peerDependencies": { + "@effect/atom-react": "4.0.0-beta.78", + "effect": "4.0.0-beta.78", + "react": "19.2.6", + "react-dom": "19.2.6" + } +} diff --git a/packages/plugin-sdk-web/src/atomAdapter.ts b/packages/plugin-sdk-web/src/atomAdapter.ts new file mode 100644 index 00000000000..641e61681df --- /dev/null +++ b/packages/plugin-sdk-web/src/atomAdapter.ts @@ -0,0 +1,17 @@ +import { appAtomRegistry } from "../../../apps/web/src/rpc/atomRegistry.ts"; +import { connectionAtomRuntime } from "../../../apps/web/src/connection/runtime.ts"; + +export function getAppAtomRegistry() { + return appAtomRegistry; +} + +export function getConnectionAtomRuntime() { + return connectionAtomRuntime; +} + +export function createPluginAtoms() { + return { + registry: appAtomRegistry, + runtime: connectionAtomRuntime, + }; +} diff --git a/packages/plugin-sdk-web/src/externals.ts b/packages/plugin-sdk-web/src/externals.ts new file mode 100644 index 00000000000..8985ce2e593 --- /dev/null +++ b/packages/plugin-sdk-web/src/externals.ts @@ -0,0 +1,12 @@ +export const pluginSdkWebExternalDependencies = [ + "@effect/atom-react", + "effect", + "react", + "react-dom", +] as const; + +export function isPluginSdkWebExternal(id: string): boolean { + return pluginSdkWebExternalDependencies.some((dependency) => { + return id === dependency || id.startsWith(`${dependency}/`); + }); +} diff --git a/packages/plugin-sdk-web/src/index.test.tsx b/packages/plugin-sdk-web/src/index.test.tsx new file mode 100644 index 00000000000..1fd55ea6a80 --- /dev/null +++ b/packages/plugin-sdk-web/src/index.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + Button, + ChatMarkdown, + ProviderModelPicker, + TraitsPicker, + createPluginAtoms, + defineWebPlugin, + hostCompat, + pluginSdkWebExternalDependencies, +} from "./index"; + +describe("plugin-sdk-web", () => { + it("re-exports the host web surface", () => { + expect(typeof Button).toBe("function"); + expect(typeof ChatMarkdown).toBe("object"); + expect(typeof ProviderModelPicker).toBe("object"); + expect(typeof TraitsPicker).toBe("object"); + expect(typeof createPluginAtoms).toBe("function"); + expect(hostCompat.hostApiVersion).toBe("1.0.0"); + }); + + it("keeps host singleton dependencies external for plugin builds", () => { + expect(pluginSdkWebExternalDependencies).toEqual( + expect.arrayContaining(["@effect/atom-react", "effect", "react", "react-dom"]), + ); + }); + + it("returns defineWebPlugin definitions unchanged", () => { + const definition = defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ + path: "overview", + component: () => null, + }); + ctx.registerSidebarSection({ + id: "main", + title: "Main", + render: () => null, + }); + ctx.registerSettingsPage({ + id: "settings", + title: "Settings", + component: () => null, + }); + ctx.registerCommand({ + id: "refresh", + title: "Refresh", + run: () => undefined, + }); + }, + }); + + expect(typeof definition.register).toBe("function"); + }); +}); diff --git a/packages/plugin-sdk-web/src/index.ts b/packages/plugin-sdk-web/src/index.ts new file mode 100644 index 00000000000..b33150a66e2 --- /dev/null +++ b/packages/plugin-sdk-web/src/index.ts @@ -0,0 +1,161 @@ +import type { PluginId } from "@t3tools/contracts/plugin"; +import { HOST_API_VERSION } from "@t3tools/contracts/plugin"; +import type * as Stream from "effect/Stream"; +import { pluginSdkWebExternalDependencies } from "./externals"; + +export { pluginSdkWebExternalDependencies, isPluginSdkWebExternal } from "./externals"; +export { createPluginAtoms, getAppAtomRegistry, getConnectionAtomRuntime } from "./atomAdapter"; + +export { + HydrationBoundary, + RegistryContext, + RegistryProvider, + TypeId, + make, + scheduleTask, + useAtom, + useAtomInitialValues, + useAtomMount, + useAtomRef, + useAtomRefProp, + useAtomRefPropValue, + useAtomRefresh, + useAtomSet, + useAtomSubscribe, + useAtomSuspense, + useAtomValue, +} from "../../../apps/web/src/plugins/pluginSdkAtomReact.ts"; +export { + AsyncResult, + Atom, + AtomRegistry, +} from "../../../apps/web/src/plugins/pluginSdkAtomReact.ts"; + +export * from "../../../apps/web/src/components/ui/alert.tsx"; +export * from "../../../apps/web/src/components/ui/alert-dialog.tsx"; +export * from "../../../apps/web/src/components/ui/badge.tsx"; +export * from "../../../apps/web/src/components/ui/button.tsx"; +export * from "../../../apps/web/src/components/ui/card.tsx"; +export * from "../../../apps/web/src/components/ui/checkbox.tsx"; +export * from "../../../apps/web/src/components/ui/command.tsx"; +export * from "../../../apps/web/src/components/ui/dialog.tsx"; +export * from "../../../apps/web/src/components/ui/empty.tsx"; +export * from "../../../apps/web/src/components/ui/field.tsx"; +export * from "../../../apps/web/src/components/ui/input.tsx"; +export * from "../../../apps/web/src/components/ui/label.tsx"; +export * from "../../../apps/web/src/components/ui/menu.tsx"; +export * from "../../../apps/web/src/components/ui/popover.tsx"; +export * from "../../../apps/web/src/components/ui/scroll-area.tsx"; +export * from "../../../apps/web/src/components/ui/select.tsx"; +export * from "../../../apps/web/src/components/ui/separator.tsx"; +export * from "../../../apps/web/src/components/ui/sheet.tsx"; +export * from "../../../apps/web/src/components/ui/sidebar.tsx"; +export * from "../../../apps/web/src/components/ui/spinner.tsx"; +export * from "../../../apps/web/src/components/ui/switch.tsx"; +export * from "../../../apps/web/src/components/ui/textarea.tsx"; +export * from "../../../apps/web/src/components/ui/toast.tsx"; +export * from "../../../apps/web/src/components/ui/tooltip.tsx"; +export { default as ChatMarkdown } from "../../../apps/web/src/components/ChatMarkdown.tsx"; +export { ProviderModelPicker } from "../../../apps/web/src/components/chat/ProviderModelPicker.tsx"; +export { + TraitsMenuContent, + TraitsPicker, + shouldRenderTraitsControls, +} from "../../../apps/web/src/components/chat/TraitsPicker.tsx"; +export { useAtomCommand } from "../../../apps/web/src/state/use-atom-command.ts"; +export { useAtomQueryRunner } from "../../../apps/web/src/state/use-atom-query-runner.ts"; + +export const hostCompat = { + hostApiVersion: HOST_API_VERSION, + importMapExternals: pluginSdkWebExternalDependencies, +} as const; + +export interface PluginUiContext { + readonly pluginId: PluginId; + readonly rpc: PluginWebRpc; + readonly logger: PluginWebLogger; + readonly registerRoute: (registration: PluginRouteRegistration) => void; + readonly registerSidebarSection: (registration: PluginSidebarSectionRegistration) => void; + readonly registerSettingsPage: (registration: PluginSettingsPageRegistration) => void; + readonly registerCommand: (registration: PluginCommandRegistration) => void; +} + +export interface PluginWebLogger { + readonly debug: (message: string, data?: unknown) => void; + readonly info: (message: string, data?: unknown) => void; + readonly warn: (message: string, data?: unknown) => void; + readonly error: (message: string, data?: unknown) => void; +} + +export interface PluginWebRpc { + readonly call: (method: string, payload?: unknown) => Promise; + readonly subscribe: ( + method: string, + payload?: unknown, + ) => Stream.Stream; +} + +export type PluginComponent> = (props: Props) => unknown; + +export interface PluginRouteComponentProps { + readonly pluginId: PluginId; + readonly path: string; +} + +export interface PluginRouteRegistration { + readonly path: string; + readonly component: PluginComponent; +} + +export interface PluginSidebarSectionRenderProps { + readonly pluginId: PluginId; + readonly environmentId: string | null; + readonly routeBasePath: string | null; +} + +export interface PluginSidebarSectionRegistration { + readonly id: string; + readonly title: string; + readonly render: (props: PluginSidebarSectionRenderProps) => unknown; +} + +export interface PluginSettingsComponentProps { + readonly pluginId: PluginId; + readonly pageId: string; +} + +export interface PluginSettingsPageRegistration { + readonly id: string; + readonly title: string; + readonly component: PluginComponent; +} + +export interface PluginCommandRegistration { + readonly id: string; + readonly title: string; + readonly description?: string; + readonly run: (context: PluginUiContext) => void | Promise; +} + +export interface PluginWebDefinition { + readonly register: (context: PluginUiContext) => void | Promise; +} + +export function defineWebPlugin( + definition: Definition, +): Definition { + return definition; +} + +/** + * Tailwind v4 caveat: host builds emit utilities by scanning host source. + * Separately-built plugins should use host CSS variables and these exported + * host components, or ship their own compiled CSS for plugin-local classes. + */ +export interface PluginWebRegistration { + readonly routes?: ReadonlyArray; + readonly sidebarSections?: ReadonlyArray; + readonly settingsPages?: ReadonlyArray; + readonly commands?: ReadonlyArray; + readonly providers?: (context: PluginUiContext) => unknown; +} diff --git a/packages/plugin-sdk-web/tsconfig.json b/packages/plugin-sdk-web/tsconfig.json new file mode 100644 index 00000000000..54ab0faf6cb --- /dev/null +++ b/packages/plugin-sdk-web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../apps/web/tsconfig.json", + "compilerOptions": { + "composite": false, + "module": "Preserve", + "moduleResolution": "Bundler", + "erasableSyntaxOnly": false, + "verbatimModuleSyntax": false, + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "paths": { + "~/*": ["../../apps/web/src/*"] + } + }, + "include": ["src", "vite.config.ts", "../../apps/web/src/*.d.ts"] +} diff --git a/packages/plugin-sdk-web/vite.config.ts b/packages/plugin-sdk-web/vite.config.ts new file mode 100644 index 00000000000..e7c3e654e1d --- /dev/null +++ b/packages/plugin-sdk-web/vite.config.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; + +import { isPluginSdkWebExternal } from "./src/externals"; + +const webSrc = fileURLToPath(new URL("../../apps/web/src", import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "~": webSrc, + }, + dedupe: ["react", "react-dom"], + }, + build: { + lib: { + entry: "src/index.ts", + formats: ["es"], + fileName: "index", + }, + rollupOptions: { + external: isPluginSdkWebExternal, + }, + }, + test: { + include: ["src/**/*.test.{ts,tsx}"], + }, +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..7d58ff53f84 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -182,6 +182,10 @@ "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" + }, + "./pluginHostWeb": { + "types": "./src/pluginHostWeb.ts", + "import": "./src/pluginHostWeb.ts" } }, "scripts": { diff --git a/packages/shared/src/pluginHostWeb.test.ts b/packages/shared/src/pluginHostWeb.test.ts new file mode 100644 index 00000000000..2d26976f024 --- /dev/null +++ b/packages/shared/src/pluginHostWeb.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + PLUGIN_HOST_IMPORT_MAP_MARKER, + getPluginHostShimSource, + injectPluginHostHeadHtml, + pluginHostImportMap, +} from "./pluginHostWeb.ts"; + +describe("pluginHostWeb", () => { + it("injects the import map and bootstrap before head close once", () => { + const html = "T3"; + + const injected = injectPluginHostHeadHtml(html); + const reinjected = injectPluginHostHeadHtml(injected); + + expect(injected).toContain(PLUGIN_HOST_IMPORT_MAP_MARKER); + expect(injected.indexOf(PLUGIN_HOST_IMPORT_MAP_MARKER)).toBeLessThan( + injected.indexOf(""), + ); + expect(reinjected.match(new RegExp(PLUGIN_HOST_IMPORT_MAP_MARKER, "g"))?.length).toBe(1); + }); + + it("maps host singleton specifiers to same-origin shim modules", () => { + expect(pluginHostImportMap.imports).toMatchObject({ + "@effect/atom-react": "/plugin-host/@effect/atom-react.js", + "@t3tools/plugin-sdk-web": "/plugin-host/@t3tools/plugin-sdk-web.js", + effect: "/plugin-host/effect.js", + react: "/plugin-host/react.js", + "react-dom": "/plugin-host/react-dom.js", + "react-dom/client": "/plugin-host/react-dom/client.js", + "react/jsx-dev-runtime": "/plugin-host/react/jsx-dev-runtime.js", + "react/jsx-runtime": "/plugin-host/react/jsx-runtime.js", + }); + }); + + it("generates static named exports for shim modules", () => { + const source = getPluginHostShimSource("react"); + + expect(source).toContain('const m = globalThis.__T3_PLUGIN_HOST__["react"];'); + expect(source).toContain("export default m.default ?? m;"); + expect(source).toContain("export const useState = m.useState;"); + }); +}); diff --git a/packages/shared/src/pluginHostWeb.ts b/packages/shared/src/pluginHostWeb.ts new file mode 100644 index 00000000000..f8982135812 --- /dev/null +++ b/packages/shared/src/pluginHostWeb.ts @@ -0,0 +1,563 @@ +export const PLUGIN_HOST_IMPORT_MAP_MARKER = "data-t3-plugin-host-importmap"; +export const PLUGIN_HOST_BOOTSTRAP_MARKER = "data-t3-plugin-host-bootstrap"; +export const PLUGIN_WEB_BUNDLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; +export const PLUGIN_WEB_SHIM_CACHE_CONTROL = "public, max-age=60"; + +/** + * The runtime import map handed to plugin web bundles. It maps ONLY the bare + * module specifiers below — NOT effect subpaths. + * + * Plugin web bundles must import effect from the barrel + * (`import { Effect, Schema } from "effect"`), never a subpath + * (`import * as Effect from "effect/Effect"` does not resolve in the browser). + * This is a deliberate v1 constraint: web plugins resolve shared modules + * through the native browser import map, which enumerates bare specifiers + * only. (Server plugins differ — a Node resolve hook handles subpaths there.) + * If per-subpath support is ever needed, add `effect/` entries here AND + * generate a shim per subpath that forwards that subpath's own members. + */ +export const pluginHostImportMap = { + imports: { + react: "/plugin-host/react.js", + "react-dom": "/plugin-host/react-dom.js", + "react-dom/client": "/plugin-host/react-dom/client.js", + "react/jsx-runtime": "/plugin-host/react/jsx-runtime.js", + "react/jsx-dev-runtime": "/plugin-host/react/jsx-dev-runtime.js", + "@effect/atom-react": "/plugin-host/@effect/atom-react.js", + effect: "/plugin-host/effect.js", + "@t3tools/plugin-sdk-web": "/plugin-host/@t3tools/plugin-sdk-web.js", + }, +} as const; + +export type PluginHostModuleSpecifier = keyof typeof pluginHostImportMap.imports; + +export const pluginHostModuleSpecifiers = Object.keys( + pluginHostImportMap.imports, +) as ReadonlyArray; + +const reactExports = [ + "Activity", + "Children", + "Component", + "Fragment", + "Profiler", + "PureComponent", + "StrictMode", + "Suspense", + "__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE", + "__COMPILER_RUNTIME", + "act", + "cache", + "cacheSignal", + "captureOwnerStack", + "cloneElement", + "createContext", + "createElement", + "createRef", + "forwardRef", + "isValidElement", + "lazy", + "memo", + "startTransition", + "unstable_useCacheRefresh", + "use", + "useActionState", + "useCallback", + "useContext", + "useDebugValue", + "useDeferredValue", + "useEffect", + "useEffectEvent", + "useId", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useMemo", + "useOptimistic", + "useReducer", + "useRef", + "useState", + "useSyncExternalStore", + "useTransition", + "version", +] as const; + +const reactDomExports = [ + "__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE", + "createPortal", + "flushSync", + "preconnect", + "prefetchDNS", + "preinit", + "preinitModule", + "preload", + "preloadModule", + "requestFormReset", + "unstable_batchedUpdates", + "useFormState", + "useFormStatus", + "version", +] as const; + +const reactDomClientExports = ["createRoot", "hydrateRoot", "version"] as const; +const jsxRuntimeExports = ["Fragment", "jsx", "jsxs"] as const; +const jsxDevRuntimeExports = ["Fragment", "jsxDEV"] as const; + +const atomReactExports = [ + "HydrationBoundary", + "RegistryContext", + "RegistryProvider", + "TypeId", + "make", + "scheduleTask", + "useAtom", + "useAtomInitialValues", + "useAtomMount", + "useAtomRef", + "useAtomRefProp", + "useAtomRefPropValue", + "useAtomRefresh", + "useAtomSet", + "useAtomSubscribe", + "useAtomSuspense", + "useAtomValue", +] as const; + +const effectExports = [ + "Array", + "BigDecimal", + "BigInt", + "Boolean", + "Brand", + "Cache", + "Cause", + "Channel", + "ChannelSchema", + "Chunk", + "Clock", + "Combiner", + "Config", + "ConfigProvider", + "Console", + "Context", + "Cron", + "Crypto", + "Data", + "DateTime", + "Deferred", + "Differ", + "Duration", + "Effect", + "Effectable", + "Encoding", + "Equal", + "Equivalence", + "ErrorReporter", + "ExecutionPlan", + "Exit", + "Fiber", + "FiberHandle", + "FiberMap", + "FiberSet", + "FileSystem", + "Filter", + "Formatter", + "Function", + "Graph", + "HKT", + "Hash", + "HashMap", + "HashRing", + "HashSet", + "Inspectable", + "Iterable", + "JsonPatch", + "JsonPointer", + "JsonSchema", + "Latch", + "Layer", + "LayerMap", + "LogLevel", + "Logger", + "ManagedRuntime", + "Match", + "Metric", + "MutableHashMap", + "MutableHashSet", + "MutableList", + "MutableRef", + "Newtype", + "NonEmptyIterable", + "Number", + "Optic", + "Option", + "Order", + "Ordering", + "PartitionedSemaphore", + "Path", + "Pipeable", + "PlatformError", + "Pool", + "Predicate", + "PrimaryKey", + "PubSub", + "Pull", + "Queue", + "Random", + "RcMap", + "RcRef", + "Record", + "Redactable", + "Redacted", + "Reducer", + "Ref", + "References", + "RegExp", + "Request", + "RequestResolver", + "Resource", + "Result", + "Runtime", + "Schedule", + "Scheduler", + "Schema", + "SchemaAST", + "SchemaGetter", + "SchemaIssue", + "SchemaParser", + "SchemaRepresentation", + "SchemaTransformation", + "SchemaUtils", + "Scope", + "ScopedCache", + "ScopedRef", + "Semaphore", + "Sink", + "Stdio", + "Stream", + "String", + "Struct", + "SubscriptionRef", + "Symbol", + "SynchronizedRef", + "Take", + "Terminal", + "Tracer", + "Trie", + "Tuple", + "TxChunk", + "TxDeferred", + "TxHashMap", + "TxHashSet", + "TxPriorityQueue", + "TxPubSub", + "TxQueue", + "TxReentrantLock", + "TxRef", + "TxSemaphore", + "TxSubscriptionRef", + "Types", + "UndefinedOr", + "Unify", + "Utils", + "absurd", + "cast", + "flow", + "hole", + "identity", + "pipe", +] as const; + +const pluginSdkWebExports = [ + "Alert", + "AlertAction", + "AlertDescription", + "AlertDialog", + "AlertDialogBackdrop", + "AlertDialogClose", + "AlertDialogContent", + "AlertDialogCreateHandle", + "AlertDialogDescription", + "AlertDialogFooter", + "AlertDialogHeader", + "AlertDialogOverlay", + "AlertDialogPopup", + "AlertDialogPortal", + "AlertDialogTitle", + "AlertDialogTrigger", + "AlertDialogViewport", + "AlertTitle", + "AnchoredToastProvider", + "Badge", + "Button", + "Card", + "CardAction", + "CardContent", + "CardDescription", + "CardFooter", + "CardFrame", + "CardFrameDescription", + "CardFrameFooter", + "CardFrameHeader", + "CardFrameTitle", + "CardHeader", + "CardPanel", + "CardTitle", + "ChatMarkdown", + "Checkbox", + "Command", + "CommandCollection", + "CommandDialog", + "CommandDialogPopup", + "CommandDialogTrigger", + "CommandEmpty", + "CommandFooter", + "CommandGroup", + "CommandGroupLabel", + "CommandInput", + "CommandItem", + "CommandList", + "CommandPanel", + "CommandSeparator", + "CommandShortcut", + "Dialog", + "DialogBackdrop", + "DialogClose", + "DialogContent", + "DialogCreateHandle", + "DialogDescription", + "DialogFooter", + "DialogHeader", + "DialogOverlay", + "DialogPanel", + "DialogPopup", + "DialogPortal", + "DialogTitle", + "DialogTrigger", + "DialogViewport", + "DropdownMenu", + "DropdownMenuCheckboxItem", + "DropdownMenuContent", + "DropdownMenuCreateHandle", + "DropdownMenuGroup", + "DropdownMenuItem", + "DropdownMenuLabel", + "DropdownMenuPortal", + "DropdownMenuRadioGroup", + "DropdownMenuRadioItem", + "DropdownMenuSeparator", + "DropdownMenuShortcut", + "DropdownMenuSub", + "DropdownMenuSubContent", + "DropdownMenuSubTrigger", + "DropdownMenuTrigger", + "Empty", + "EmptyContent", + "EmptyDescription", + "EmptyHeader", + "EmptyMedia", + "EmptyTitle", + "Field", + "FieldControl", + "FieldDescription", + "FieldError", + "FieldItem", + "FieldLabel", + "FieldValidity", + "HydrationBoundary", + "Input", + "Label", + "Menu", + "MenuCheckboxItem", + "MenuCreateHandle", + "MenuGroup", + "MenuGroupLabel", + "MenuItem", + "MenuPortal", + "MenuPopup", + "MenuRadioGroup", + "MenuRadioItem", + "MenuSeparator", + "MenuShortcut", + "MenuSub", + "MenuSubPopup", + "MenuSubTrigger", + "MenuTrigger", + "Popover", + "PopoverClose", + "PopoverContent", + "PopoverCreateHandle", + "PopoverDescription", + "PopoverPopup", + "PopoverTitle", + "PopoverTrigger", + "ProviderModelPicker", + "RegistryContext", + "RegistryProvider", + "ScrollArea", + "ScrollBar", + "Select", + "SelectButton", + "SelectContent", + "SelectGroup", + "SelectGroupLabel", + "SelectItem", + "SelectPopup", + "SelectSeparator", + "SelectTrigger", + "SelectValue", + "Separator", + "Sheet", + "SheetBackdrop", + "SheetClose", + "SheetContent", + "SheetDescription", + "SheetFooter", + "SheetHeader", + "SheetOverlay", + "SheetPanel", + "SheetPopup", + "SheetPortal", + "SheetTitle", + "SheetTrigger", + "Sidebar", + "SidebarContent", + "SidebarFooter", + "SidebarGroup", + "SidebarGroupAction", + "SidebarGroupContent", + "SidebarGroupLabel", + "SidebarHeader", + "SidebarInput", + "SidebarInset", + "SidebarMenu", + "SidebarMenuAction", + "SidebarMenuBadge", + "SidebarMenuButton", + "SidebarMenuItem", + "SidebarMenuSkeleton", + "SidebarMenuSub", + "SidebarMenuSubButton", + "SidebarMenuSubItem", + "SidebarProvider", + "SidebarRail", + "SidebarSeparator", + "SidebarTrigger", + "Spinner", + "Switch", + "Textarea", + "ToastProvider", + "Tooltip", + "TooltipCreateHandle", + "TooltipPopup", + "TooltipProvider", + "TooltipTrigger", + "TraitsMenuContent", + "TraitsPicker", + "TypeId", + "anchoredToastManager", + "AsyncResult", + "Atom", + "AtomRegistry", + "badgeVariants", + "buttonVariants", + "createPluginAtoms", + "defineWebPlugin", + "getAppAtomRegistry", + "getConnectionAtomRuntime", + "hostCompat", + "isPluginSdkWebExternal", + "make", + "pluginSdkWebExternalDependencies", + "scheduleTask", + "selectTriggerVariants", + "shouldRenderTraitsControls", + "stackedThreadToast", + "toastManager", + "useAtom", + "useAtomCommand", + "useAtomInitialValues", + "useAtomMount", + "useAtomQueryRunner", + "useAtomRef", + "useAtomRefProp", + "useAtomRefPropValue", + "useAtomRefresh", + "useAtomSet", + "useAtomSubscribe", + "useAtomSuspense", + "useAtomValue", + "useSidebar", + "useSidebarVisibility", +] as const; + +export const pluginHostShimExportNames = { + react: reactExports, + "react-dom": reactDomExports, + "react-dom/client": reactDomClientExports, + "react/jsx-runtime": jsxRuntimeExports, + "react/jsx-dev-runtime": jsxDevRuntimeExports, + "@effect/atom-react": atomReactExports, + effect: effectExports, + "@t3tools/plugin-sdk-web": pluginSdkWebExports, +} satisfies Record>; + +export function pluginHostModulePath(moduleName: PluginHostModuleSpecifier): string { + return pluginHostImportMap.imports[moduleName].slice("/plugin-host/".length); +} + +export function pluginHostModuleFromPath(pathname: string): PluginHostModuleSpecifier | null { + if (!pathname.endsWith(".js")) return null; + const moduleName = pathname.slice(0, -".js".length); + return pluginHostModuleSpecifiers.includes(moduleName as PluginHostModuleSpecifier) + ? (moduleName as PluginHostModuleSpecifier) + : null; +} + +export function getPluginHostShimSource(moduleName: PluginHostModuleSpecifier): string { + const exportLines = pluginHostShimExportNames[moduleName] + .map((name) => `export const ${name} = m.${name};`) + .join("\n"); + return [ + `const m = globalThis.__T3_PLUGIN_HOST__[${JSON.stringify(moduleName)}];`, + `if (!m) throw new Error(${JSON.stringify(`T3 plugin host module ${moduleName} is not ready.`)});`, + "export default m.default ?? m;", + exportLines, + "", + ].join("\n"); +} + +export const pluginHostBootstrapSource = ` +const readyKey = "__T3_PLUGIN_HOST_READY__"; +const resolveKey = "__T3_PLUGIN_HOST_READY_RESOLVE__"; +if (!globalThis[readyKey]) { + globalThis[readyKey] = new Promise((resolve) => { + globalThis[resolveKey] = resolve; + }); +} +if (globalThis.__T3_PLUGIN_HOST__) { + globalThis[resolveKey]?.(globalThis.__T3_PLUGIN_HOST__); +} +`.trim(); + +export function buildPluginHostHeadInjection(): string { + const importMapJson = JSON.stringify(pluginHostImportMap); + return [ + "", + ``, + ``, + "", + ].join("\n"); +} + +export function injectPluginHostHeadHtml(html: string): string { + if (html.includes(PLUGIN_HOST_IMPORT_MAP_MARKER)) { + return html; + } + const injection = `\n${buildPluginHostHeadInjection()}\n`; + const headCloseMatch = /<\/head\s*>/iu.exec(html); + if (!headCloseMatch || headCloseMatch.index === undefined) { + return `${injection}${html}`; + } + return `${html.slice(0, headCloseMatch.index)}${injection}${html.slice(headCloseMatch.index)}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1494b170402..ed266e9594d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -556,6 +556,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/plugin-sdk-web': + specifier: workspace:* + version: link:../../packages/plugin-sdk-web '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -813,6 +816,33 @@ importers: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + packages/plugin-sdk-web: + dependencies: + '@effect/atom-react': + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0) + '@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/client-runtime': + specifier: workspace:* + version: link:../client-runtime + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + react: + specifier: 19.2.6 + version: 19.2.6 + react-dom: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + packages/shared: dependencies: '@noble/curves': @@ -14552,19 +14582,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)': + '@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)))': 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) - 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(@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)) 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)': + '@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)))': 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 +14603,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))(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)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -20246,8 +20276,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) - '@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/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/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 +20290,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))(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)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -20304,7 +20334,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))(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)): 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 +20358,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) + '@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))) transitivePeerDependencies: - msw From be7b6adc593da9caee0e29b38ccb9cdcd1849072 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 21:11:55 -0400 Subject: [PATCH 8/9] Add plugin marketplace and install lifecycle Marketplace catalog/source management, staged install/upgrade/uninstall flows over ws RPC, the plugins settings UI, the hello-board fixture plugin, and plugin authoring docs. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- .../HelloBoardFixture.integration.test.ts | 409 +++++++ apps/server/src/plugins/PluginHost.ts | 70 +- .../src/plugins/PluginInstaller.test.ts | 558 +++++++++ apps/server/src/plugins/PluginInstaller.ts | 911 +++++++++++++++ .../PluginManagementRpcHandlers.test.ts | 107 ++ .../plugins/PluginManagementRpcHandlers.ts | 165 +++ .../src/plugins/PluginMarketplace.test.ts | 174 +++ apps/server/src/plugins/PluginMarketplace.ts | 305 +++++ apps/server/src/plugins/PluginMigrator.ts | 12 +- .../plugins/readHttpResponseBytesCapped.ts | 37 + apps/server/src/server.test.ts | 22 + apps/server/src/server.ts | 18 + apps/server/src/ws.test.ts | 138 +++ apps/server/src/ws.ts | 239 +++- .../settings/SettingsSidebarNav.test.ts | 2 + .../settings/SettingsSidebarNav.tsx | 2 + .../plugins/PluginsSettings.logic.test.tsx | 151 +++ .../settings/plugins/PluginsSettings.logic.ts | 188 +++ .../settings/plugins/PluginsSettings.tsx | 1027 +++++++++++++++++ apps/web/src/plugins/PluginUiHost.tsx | 20 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.plugins.tsx | 11 + apps/web/src/state/plugins.ts | 121 +- docs/plugins.md | 157 +++ fixtures/hello-board/.gitignore | 2 + fixtures/hello-board/manifest.json | 15 + fixtures/hello-board/package.json | 21 + fixtures/hello-board/scripts/build.mjs | 158 +++ fixtures/hello-board/server/index.ts | 96 ++ fixtures/hello-board/tsconfig.json | 24 + fixtures/hello-board/web/index.tsx | 179 +++ .../client-runtime/src/rpc/client.test.ts | 114 ++ packages/client-runtime/src/rpc/client.ts | 77 ++ packages/contracts/src/plugin.test.ts | 52 +- packages/contracts/src/plugin.ts | 166 +++ packages/contracts/src/rpc.ts | 113 ++ pnpm-lock.yaml | 28 +- pnpm-workspace.yaml | 1 + 38 files changed, 5846 insertions(+), 65 deletions(-) create mode 100644 apps/server/src/plugins/HelloBoardFixture.integration.test.ts create mode 100644 apps/server/src/plugins/PluginInstaller.test.ts create mode 100644 apps/server/src/plugins/PluginInstaller.ts create mode 100644 apps/server/src/plugins/PluginManagementRpcHandlers.test.ts create mode 100644 apps/server/src/plugins/PluginManagementRpcHandlers.ts create mode 100644 apps/server/src/plugins/PluginMarketplace.test.ts create mode 100644 apps/server/src/plugins/PluginMarketplace.ts create mode 100644 apps/server/src/plugins/readHttpResponseBytesCapped.ts create mode 100644 apps/server/src/ws.test.ts create mode 100644 apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx create mode 100644 apps/web/src/components/settings/plugins/PluginsSettings.logic.ts create mode 100644 apps/web/src/components/settings/plugins/PluginsSettings.tsx create mode 100644 apps/web/src/routes/settings.plugins.tsx create mode 100644 docs/plugins.md create mode 100644 fixtures/hello-board/.gitignore create mode 100644 fixtures/hello-board/manifest.json create mode 100644 fixtures/hello-board/package.json create mode 100644 fixtures/hello-board/scripts/build.mjs create mode 100644 fixtures/hello-board/server/index.ts create mode 100644 fixtures/hello-board/tsconfig.json create mode 100644 fixtures/hello-board/web/index.tsx diff --git a/apps/server/src/plugins/HelloBoardFixture.integration.test.ts b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts new file mode 100644 index 00000000000..0eea089dbdf --- /dev/null +++ b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts @@ -0,0 +1,409 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { AuthStandardClientScopes, PluginId, type AuthScope } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { runMigrations } from "../persistence/Migrations.ts"; +import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import * as ProjectionThreadActivities from "../persistence/Services/ProjectionThreadActivities.ts"; +import * as ProjectionThreadMessages from "../persistence/Services/ProjectionThreadMessages.ts"; +import * as ProjectionTurns from "../persistence/Services/ProjectionTurns.ts"; +import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; +import * as PluginCatalogModule from "./PluginCatalog.ts"; +import * as PluginHostModule from "./PluginHost.ts"; +import * as PluginHttpRegistry from "./PluginHttpRegistry.ts"; +import * as PluginInstallerModule from "./PluginInstaller.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import * as PluginManagementRpcHandlersModule from "./PluginManagementRpcHandlers.ts"; +import * as PluginMarketplaceModule from "./PluginMarketplace.ts"; +import * as PluginMigrator from "./PluginMigrator.ts"; +import * as PluginModuleLoaderLayer from "./PluginModuleLoader.ts"; +import * as PluginRpcDispatcherModule from "./PluginRpcDispatcher.ts"; +import * as PluginRuntimeRegistryLayer from "./PluginRuntimeRegistry.ts"; + +const pluginId = PluginId.make("hello-board"); +const fixtureRoot = decodeURIComponent( + new URL("../../../../fixtures/hello-board", import.meta.url).pathname, +); + +class HelloBoardFixtureBuildError extends Data.TaggedError("HelloBoardFixtureBuildError")<{ + readonly stdout: string; + readonly stderr: string; +}> {} + +const unexpectedCapabilityUse = () => + Effect.die(new Error("unexpected capability use in hello-board fixture test")); + +const TestHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + +const PluginRuntimeRegistryLayerLive = PluginRuntimeRegistryLayer.layer; +const PluginHttpRegistryLayerLive = PluginHttpRegistry.layer; +const PluginLockfileStoreLayerLive = PluginLockfileStoreLayer.layer; +const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: unexpectedCapabilityUse, + set: unexpectedCapabilityUse, + create: unexpectedCapabilityUse, + getOrCreateRandom: unexpectedCapabilityUse, + remove: unexpectedCapabilityUse, + }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: unexpectedCapabilityUse(), + getDescriptor: unexpectedCapabilityUse(), + }), + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: unexpectedCapabilityUse, + streamDomainEvents: Stream.empty, + }), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getCommandReadModel: unexpectedCapabilityUse, + getSnapshot: unexpectedCapabilityUse, + getShellSnapshot: unexpectedCapabilityUse, + getArchivedShellSnapshot: unexpectedCapabilityUse, + getSnapshotSequence: unexpectedCapabilityUse, + getCounts: unexpectedCapabilityUse, + getActiveProjectByWorkspaceRoot: unexpectedCapabilityUse, + getProjectShellById: unexpectedCapabilityUse, + getFirstActiveThreadIdByProjectId: unexpectedCapabilityUse, + getThreadOwnerById: unexpectedCapabilityUse, + getThreadCheckpointContext: unexpectedCapabilityUse, + getFullThreadDiffContext: unexpectedCapabilityUse, + getThreadShellById: unexpectedCapabilityUse, + getThreadDetailById: unexpectedCapabilityUse, + }), + Layer.mock(ProjectionTurns.ProjectionTurnRepository)({ + upsertByTurnId: unexpectedCapabilityUse, + replacePendingTurnStart: unexpectedCapabilityUse, + getPendingTurnStartByThreadId: unexpectedCapabilityUse, + deletePendingTurnStartByThreadId: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + getByTurnId: unexpectedCapabilityUse, + clearCheckpointTurnConflict: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + Layer.mock(ProjectionThreadMessages.ProjectionThreadMessageRepository)({ + upsert: unexpectedCapabilityUse, + getByMessageId: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + Layer.mock(ProjectionThreadActivities.ProjectionThreadActivityRepository)({ + upsert: unexpectedCapabilityUse, + listByThreadId: unexpectedCapabilityUse, + deleteByThreadId: unexpectedCapabilityUse, + }), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: unexpectedCapabilityUse, + listInstances: unexpectedCapabilityUse(), + listUnavailable: unexpectedCapabilityUse(), + streamChanges: Stream.empty, + subscribeChanges: unexpectedCapabilityUse(), + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: unexpectedCapabilityUse, + status: unexpectedCapabilityUse, + statusDetails: unexpectedCapabilityUse, + statusDetailsLocal: unexpectedCapabilityUse, + statusDetailsRemote: unexpectedCapabilityUse, + prepareCommitContext: unexpectedCapabilityUse, + commit: unexpectedCapabilityUse, + pushCurrentBranch: unexpectedCapabilityUse, + readRangeContext: unexpectedCapabilityUse, + getReviewDiffPreview: unexpectedCapabilityUse, + readConfigValue: unexpectedCapabilityUse, + listRefs: unexpectedCapabilityUse, + pullCurrentBranch: unexpectedCapabilityUse, + createWorktree: unexpectedCapabilityUse, + fetchPullRequestBranch: unexpectedCapabilityUse, + ensureRemote: unexpectedCapabilityUse, + resolvePrimaryRemoteName: unexpectedCapabilityUse, + fetchRemote: unexpectedCapabilityUse, + resolveRemoteTrackingCommit: unexpectedCapabilityUse, + fetchRemoteBranch: unexpectedCapabilityUse, + fetchRemoteTrackingBranch: unexpectedCapabilityUse, + setBranchUpstream: unexpectedCapabilityUse, + removeWorktree: unexpectedCapabilityUse, + renameBranch: unexpectedCapabilityUse, + createRef: unexpectedCapabilityUse, + switchRef: unexpectedCapabilityUse, + initRepo: unexpectedCapabilityUse, + listLocalBranchNames: unexpectedCapabilityUse, + }), + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: unexpectedCapabilityUse, + captureCheckpoint: unexpectedCapabilityUse, + hasCheckpointRef: unexpectedCapabilityUse, + restoreCheckpoint: unexpectedCapabilityUse, + diffCheckpoints: unexpectedCapabilityUse, + deleteCheckpointRefs: unexpectedCapabilityUse, + }), + Layer.mock(TextGeneration.TextGeneration)({ + generateCommitMessage: unexpectedCapabilityUse, + generatePrContent: unexpectedCapabilityUse, + generateBranchName: unexpectedCapabilityUse, + generateThreadTitle: unexpectedCapabilityUse, + }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + get: unexpectedCapabilityUse, + resolveHandle: unexpectedCapabilityUse, + resolve: unexpectedCapabilityUse, + discover: unexpectedCapabilityUse(), + }), + Layer.mock(GitHubCli.GitHubCli)({ + execute: unexpectedCapabilityUse, + listOpenPullRequests: unexpectedCapabilityUse, + getPullRequest: unexpectedCapabilityUse, + getRepositoryCloneUrls: unexpectedCapabilityUse, + createRepository: unexpectedCapabilityUse, + createPullRequest: unexpectedCapabilityUse, + getDefaultBranch: unexpectedCapabilityUse, + checkoutPullRequest: unexpectedCapabilityUse, + }), + Layer.mock(TerminalManager.TerminalManager)({ + open: unexpectedCapabilityUse, + attachStream: unexpectedCapabilityUse, + write: unexpectedCapabilityUse, + resize: unexpectedCapabilityUse, + clear: unexpectedCapabilityUse, + restart: unexpectedCapabilityUse, + close: unexpectedCapabilityUse, + subscribe: unexpectedCapabilityUse, + subscribeMetadata: unexpectedCapabilityUse, + }), +); + +const PluginHostLayerLive = PluginHostModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginModuleLoaderLayer.layer), + Layer.provideMerge(PluginMigrator.layer), + Layer.provideMerge(PluginRuntimeRegistryLayerLive), + Layer.provideMerge(PluginHttpRegistryLayerLive), + Layer.provideMerge(ServerLifecycleEvents.layer), + Layer.provideMerge(PluginHostCapabilityDepsLayerLive), +); +const PluginRpcDispatcherLayerLive = PluginRpcDispatcherModule.layer.pipe( + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginCatalogLayerLive = PluginCatalogModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginRuntimeRegistryLayerLive), +); +const PluginMarketplaceLayerLive = PluginMarketplaceModule.layer; +const PluginInstallerLayerLive = PluginInstallerModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginMarketplaceLayerLive), + Layer.provideMerge(PluginHostLayerLive), + Layer.provideMerge(PluginCatalogLayerLive), +); +const PluginManagementRpcHandlersLayerLive = PluginManagementRpcHandlersModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginMarketplaceLayerLive), + Layer.provideMerge(PluginInstallerLayerLive), +); +const PluginLayerLive = Layer.mergeAll( + PluginHostLayerLive, + PluginRpcDispatcherLayerLive, + PluginCatalogLayerLive, + PluginMarketplaceLayerLive, + PluginInstallerLayerLive, + PluginManagementRpcHandlersLayerLive, + PluginHttpRegistryLayerLive, + PluginLockfileStoreLayerLive, +); + +const testLayer = PluginLayerLive.pipe( + Layer.provideMerge(NodeSqliteClient.layerMemory()), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-hello-board-fixture-" })), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(NodeServices.layer), +); + +const layer = it.layer(testLayer); + +const session = (scopes: ReadonlyArray) => ({ scopes }); + +const collectText = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +function buildFixture(outDir: string) { + return Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("pnpm", ["--dir", fixtureRoot, "run", "build", "--", "--out-dir", outDir], { + cwd: fixtureRoot, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectText(child.stdout), + collectText(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) { + return yield* new HelloBoardFixtureBuildError({ stdout, stderr }); + } + }).pipe(Effect.scoped); +} + +function linkHostPluginExternals(pluginsDir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nodeModules = path.join(pluginsDir, "node_modules"); + yield* fs.makeDirectory(path.join(nodeModules, "@t3tools"), { recursive: true }); + const links = [ + { + from: path.resolve(import.meta.dirname, "../../../../packages/plugin-sdk"), + to: path.join(nodeModules, "@t3tools/plugin-sdk"), + }, + { + from: path.resolve(import.meta.dirname, "../../node_modules/effect"), + to: path.join(nodeModules, "effect"), + }, + ]; + for (const link of links) { + yield* fs.remove(link.to, { force: true, recursive: true }); + yield* fs.symlink(link.from, link.to); + } + }); +} + +const withPluginDev = (effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => ({ + pluginDev: process.env.T3_PLUGIN_DEV, + healthyDelay: process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS, + })), + () => + Effect.sync(() => { + process.env.T3_PLUGIN_DEV = "1"; + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + }).pipe(Effect.andThen(effect)), + (previous) => + Effect.sync(() => { + if (previous.pluginDev === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous.pluginDev; + } + if (previous.healthyDelay === undefined) { + delete process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + } else { + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = previous.healthyDelay; + } + }), + ); + +layer("hello-board fixture plugin", (it) => { + it.effect("installs, activates, runs migrations, and round-trips plugin RPC", () => + withPluginDev( + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const handlers = yield* PluginManagementRpcHandlersModule.PluginManagementRpcHandlers; + const catalog = yield* PluginCatalogModule.PluginCatalog; + const dispatcher = yield* PluginRpcDispatcherModule.PluginRpcDispatcher; + const outDir = yield* fs.makeTempDirectoryScoped({ prefix: "hello-board-fixture-" }); + const config = yield* ServerConfig.ServerConfig; + + yield* buildFixture(outDir); + yield* linkHostPluginExternals(config.pluginsDir); + yield* runMigrations({ toMigrationInclusive: 34 }); + + const marketplaceUrl = new URL(`file://${path.join(outDir, "marketplace.json")}`).href; + const source = yield* handlers.addSource({ url: marketplaceUrl }); + const catalogResult = yield* handlers.catalog({ sourceId: source.source.id }); + assert.equal(catalogResult.entries[0]?.id, pluginId); + + const staged = yield* handlers.beginInstall({ + sourceId: source.source.id, + pluginId, + version: "1.0.0", + }); + assert.equal(staged.manifest.id, pluginId); + assert.property(staged.capabilityDescriptions, "database"); + + const confirmed = yield* handlers.confirmInstall(staged.stageToken); + assert.equal(confirmed.plugin.id, pluginId); + + const installed = yield* catalog.list; + assert.deepInclude( + installed.map((plugin) => ({ + id: plugin.id, + state: plugin.state, + hasWeb: plugin.hasWeb, + capabilities: plugin.capabilities, + lastError: plugin.lastError, + })), + { + id: pluginId, + state: "active", + hasWeb: true, + capabilities: ["database"], + lastError: null, + }, + ); + + const added = (yield* dispatcher.call( + pluginId, + "addNote", + { body: "hello from fixture" }, + session(AuthStandardClientScopes), + )) as { readonly body?: unknown }; + assert.equal(added.body, "hello from fixture"); + + const notes = (yield* dispatcher.call( + pluginId, + "listNotes", + {}, + session(AuthStandardClientScopes), + )) as ReadonlyArray<{ readonly body?: unknown }>; + assert.equal(notes[0]?.body, "hello from fixture"); + + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'p_hello_board_notes' + `; + assert.deepEqual(tables, [{ name: "p_hello_board_notes" }]); + }), + ), + ), + ); +}); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 1aec0644fee..57884b3956c 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; @@ -66,6 +67,7 @@ import { pluginDataDir, pluginManifestPath, pluginVersionDir } from "./PluginPat import { PluginRuntimeRegistry } from "./PluginRuntimeRegistry.ts"; const APP_VERSION = packageJson.version; +const PRESERVE_DATA_MARKER = ".preserve-data-on-remove"; const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); const healthyActivationDelay = () => { @@ -97,6 +99,8 @@ export class PluginHost extends Context.Service< PluginHost, { readonly start: Effect.Effect; + readonly activatePlugin: (pluginId: PluginId) => Effect.Effect; + readonly deactivatePlugin: (pluginId: PluginId) => Effect.Effect; } >()("t3/plugins/PluginHost") {} @@ -509,10 +513,72 @@ export const make = Effect.fn("PluginHost.make")(function* () { } }); + const activatePlugin: PluginHost["Service"]["activatePlugin"] = (pluginId) => + Effect.gen(function* () { + if (process.env.T3_NO_PLUGINS === "1") { + yield* Effect.logInfo("Plugin host disabled by T3_NO_PLUGINS", { pluginId }); + return; + } + const active = yield* registry.get(pluginId); + if (Option.isSome(active)) return; + const lockfile = yield* store.readLockfile.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Plugin hot activation could not read lockfile", { + pluginId, + cause: Cause.pretty(cause), + }).pipe(Effect.as({ plugins: {}, sources: [] })), + ), + ); + const entry = getLockfilePlugin(lockfile, pluginId); + if (!entry?.enabled || entry.state !== "active") return; + yield* loader.ensureHostSingletonResolution; + yield* loadPlugin(pluginId, entry).pipe( + Effect.catchCause((cause) => + markFailure(pluginId, Cause.pretty(cause)).pipe( + Effect.andThen( + Effect.logWarning("Plugin hot activation failed", { + pluginId, + cause: Cause.pretty(cause), + }), + ), + Effect.ignore, + ), + ), + ); + }); + + const deactivatePlugin: PluginHost["Service"]["deactivatePlugin"] = (pluginId) => + Effect.gen(function* () { + const runtime = yield* registry.get(pluginId); + if (Option.isNone(runtime)) return; + yield* Scope.close(runtime.value.scope, Exit.void).pipe(Effect.ignore); + yield* registry.remove(pluginId); + yield* httpRegistry.remove(pluginId).pipe(Effect.ignore); + yield* publishPluginStateChanged(pluginId, "disabled"); + }); + const reconcilePendingState = (pluginId: PluginId, entry: PluginLockfilePlugin) => Effect.gen(function* () { if (entry.state === "pending-remove") { - yield* fs.remove(path.join(config.pluginsDir, pluginId), { recursive: true, force: true }); + const pluginRoot = path.join(config.pluginsDir, pluginId); + const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const markerPath = path.join(pluginRoot, PRESERVE_DATA_MARKER); + const preserveData = yield* fs.exists(markerPath).pipe(Effect.orElseSucceed(() => false)); + const preservedDataDir = path.join( + config.pluginsDir, + `.preserved-${pluginId}-${yield* clock.currentTimeMillis}`, + ); + if (preserveData && (yield* fs.exists(dataDir).pipe(Effect.orElseSucceed(() => false)))) { + yield* fs.rename(dataDir, preservedDataDir); + } + yield* fs.remove(pluginRoot, { recursive: true, force: true }); + if ( + preserveData && + (yield* fs.exists(preservedDataDir).pipe(Effect.orElseSucceed(() => false))) + ) { + yield* fs.makeDirectory(pluginRoot, { recursive: true }); + yield* fs.rename(preservedDataDir, dataDir); + } yield* store.removePlugin(pluginId); return false; } @@ -610,7 +676,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { } }).pipe(Effect.ignoreCause({ log: true })); - return PluginHost.of({ start }); + return PluginHost.of({ start, activatePlugin, deactivatePlugin }); }); export const layer = Layer.effect(PluginHost, make()); diff --git a/apps/server/src/plugins/PluginInstaller.test.ts b/apps/server/src/plugins/PluginInstaller.test.ts new file mode 100644 index 00000000000..16d86b1c647 --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.test.ts @@ -0,0 +1,558 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + PluginId, + PluginManifest, + type PluginManifest as PluginManifestType, +} from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeZlib from "node:zlib"; + +import * as ServerConfig from "../config.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import { PluginHost } from "./PluginHost.ts"; +import { PluginInstaller } from "./PluginInstaller.ts"; +import * as PluginInstallerModule from "./PluginInstaller.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import { MarketplaceIndex } from "./PluginMarketplace.ts"; +import * as PluginMarketplaceModule from "./PluginMarketplace.ts"; + +const pluginId = PluginId.make("test-plugin"); +const sourceId = "src-test"; +const tarballUrl = "https://market.test/test-plugin-1.0.0.tgz"; + +const encodeManifestJson = Schema.encodeSync(Schema.fromJsonString(PluginManifest)); +const encodeMarketplaceJson = Schema.encodeSync(Schema.fromJsonString(MarketplaceIndex)); + +const manifest = (overrides: Partial = {}): PluginManifestType => ({ + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + hostApi: "^1.0.0", + capabilities: ["agents"], + entries: { server: "server/index.js" }, + ...overrides, +}); + +const textEncoder = new TextEncoder(); + +function checksum(header: Uint8Array): number { + let sum = 0; + for (const byte of header) sum += byte; + return sum; +} + +function writeAscii(target: Uint8Array, offset: number, length: number, value: string) { + target.set(textEncoder.encode(value).slice(0, length), offset); +} + +function writeOctal(target: Uint8Array, offset: number, length: number, value: number) { + writeAscii(target, offset, length, value.toString(8).padStart(length - 1, "0")); +} + +function tarEntry(input: { + readonly name: string; + readonly body?: Uint8Array; + readonly type?: "0" | "2" | "5"; +}) { + const body = input.body ?? new Uint8Array(); + const header = new Uint8Array(512); + writeAscii(header, 0, 100, input.name); + writeOctal(header, 100, 8, input.type === "5" ? 0o755 : 0o644); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 124, 12, body.byteLength); + writeOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + writeAscii(header, 156, 1, input.type ?? "0"); + writeAscii(header, 257, 6, "ustar"); + writeAscii(header, 263, 2, "00"); + writeOctal(header, 148, 8, checksum(header)); + const paddedSize = Math.ceil(body.byteLength / 512) * 512; + const padded = new Uint8Array(512 + paddedSize); + padded.set(header, 0); + padded.set(body, 512); + return padded; +} + +function tar(entries: ReadonlyArray[0]>): Uint8Array { + const chunks = [...entries.map(tarEntry), new Uint8Array(1024)]; + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +const sha256 = (bytes: Uint8Array) => NodeCrypto.createHash("sha256").update(bytes).digest("hex"); + +const tarballForManifest = (pluginManifest = manifest()) => + tarballForManifestJson(encodeManifestJson(pluginManifest)); + +const tarballForManifestJson = ( + manifestJson: string, + extraEntries: ReadonlyArray[0]> = [], +) => + tar([ + { + name: "manifest.json", + body: textEncoder.encode(manifestJson), + }, + { + name: "server/index.js", + body: textEncoder.encode("export default { register() { return {}; } };"), + }, + ...extraEntries, + ]); + +const marketplaceJson = (sha: string, version = "1.0.0") => ({ + plugins: [ + { + id: pluginId, + name: "Test Plugin", + description: "Adds tests.", + capabilities: ["agents" as const], + versions: [ + { + version, + tarball: tarballUrl, + sha256: sha, + hostApi: "^1.0.0", + publishedAt: "2026-07-03T00:00:00.000Z", + }, + ], + }, + ], +}); + +function installerLayer(input: { + readonly tarball: Uint8Array; + readonly marketplaceSha?: string; + readonly activated?: Array; +}) { + const platform = NodeServices.layer; + const config = ServerConfig.layerTest(process.cwd(), { prefix: "t3-installer-" }).pipe( + Layer.provide(platform), + ); + const marketplace = marketplaceJson(input.marketplaceSha ?? sha256(input.tarball)); + const marketplaceBody = encodeMarketplaceJson(marketplace); + const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = request.url.toString(); + if (url === "https://market.test/marketplace.json") { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(marketplaceBody, { headers: { "content-type": "application/json" } }), + ), + ); + } + if (url === tarballUrl) { + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(input.tarball))); + } + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 404 }))); + }), + ); + const host = Layer.succeed( + PluginHost, + PluginHost.of({ + start: Effect.void, + activatePlugin: (id) => + Effect.sync(() => { + input.activated?.push(id); + }), + deactivatePlugin: () => Effect.void, + }), + ); + const catalog = Layer.succeed( + PluginCatalog, + PluginCatalog.of({ + list: Effect.succeed([ + { + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + state: "active" as const, + capabilities: ["agents" as const], + hasWeb: false, + lastError: null, + }, + ]), + }), + ); + return PluginInstallerModule.layer.pipe( + Layer.provideMerge(PluginMarketplaceModule.layer), + Layer.provideMerge(PluginLockfileStoreLayer.layer), + Layer.provideMerge(host), + Layer.provideMerge(catalog), + Layer.provideMerge(http), + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(config), + Layer.provide(platform), + ); +} + +const seedSource = Effect.gen(function* () { + const store = yield* PluginLockfileStore; + yield* store.updateSources(() => + Effect.succeed([ + { + id: sourceId, + url: "https://market.test/marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }, + ]), + ); +}); + +it.effect("PluginInstaller rejects sha mismatches without staging", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const result = yield* Effect.result( + installer.beginInstall({ + sourceId, + pluginId, + version: "1.0.0", + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "checksum-mismatch"); + }).pipe( + Effect.provide( + installerLayer({ tarball: tarballForManifest(), marketplaceSha: "b".repeat(64) }), + ), + ), + ), +); + +it.effect("PluginInstaller rejects unsafe tar entries", () => { + const traversal = tar([ + { name: "manifest.json", body: textEncoder.encode(encodeManifestJson(manifest())) }, + { name: "../escape.js", body: textEncoder.encode("x") }, + ]); + return Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const result = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + + assert.isTrue(Result.isFailure(result)); + }).pipe(Effect.provide(installerLayer({ tarball: traversal }))), + ); +}); + +it.effect("PluginInstaller rejects symlinks", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const symlinkResult = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(symlinkResult)); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tar([ + { name: "manifest.json", body: textEncoder.encode(encodeManifestJson(manifest())) }, + { name: "server/link", type: "2" }, + ]), + }), + ), + ), + ), +); + +it.effect("PluginInstaller rejects oversize files and gzip compression bombs", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const oversizeResult = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(oversizeResult)); + if (Result.isFailure(oversizeResult)) + assert.equal(oversizeResult.failure.code, "extract-failed"); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifestJson(encodeManifestJson(manifest()), [ + { name: "assets/large.bin", body: new Uint8Array(16 * 1024 * 1024 + 1) }, + ]), + }), + ), + ), + ).pipe( + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const bombResult = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(bombResult)); + if (Result.isFailure(bombResult)) assert.equal(bombResult.failure.code, "extract-failed"); + }).pipe( + Effect.provide( + installerLayer({ + tarball: NodeZlib.gzipSync( + tarballForManifestJson(encodeManifestJson(manifest()), [ + { name: "assets/repeated.bin", body: new Uint8Array(1024 * 1024) }, + ]), + ), + }), + ), + ), + ), + ), + ), +); + +it.effect("PluginInstaller rejects invalid manifests before staging can be confirmed", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const idMismatch = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(idMismatch)); + if (Result.isFailure(idMismatch)) assert.equal(idMismatch.failure.code, "manifest-invalid"); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifest(manifest({ id: PluginId.make("other-plugin") })), + }), + ), + ), + ).pipe( + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const hostApiMismatch = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(hostApiMismatch)); + if (Result.isFailure(hostApiMismatch)) { + assert.equal(hostApiMismatch.failure.code, "manifest-invalid"); + } + }).pipe( + Effect.provide( + installerLayer({ tarball: tarballForManifest(manifest({ hostApi: "2.0.0" })) }), + ), + ), + ), + ), + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const unknownCapability = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(unknownCapability)); + if (Result.isFailure(unknownCapability)) { + assert.equal(unknownCapability.failure.code, "manifest-invalid"); + } + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifestJson( + JSON.stringify({ + ...manifest(), + capabilities: ["unknown"], + }), + ), + }), + ), + ), + ), + ), + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const webOnlyCapability = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(webOnlyCapability)); + if (Result.isFailure(webOnlyCapability)) { + assert.equal(webOnlyCapability.failure.code, "manifest-invalid"); + } + }).pipe( + Effect.provide( + installerLayer({ + tarball: tar([ + { + name: "manifest.json", + body: textEncoder.encode( + JSON.stringify({ + ...manifest(), + capabilities: ["agents"], + entries: { web: "web/index.js" }, + }), + ), + }, + { name: "web/index.js", body: textEncoder.encode("export default {};") }, + ]), + }), + ), + ), + ), + ), + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + yield* store.updatePlugin(PluginId.make("test"), () => + Effect.succeed({ + version: "1.0.0", + sha256: "old", + sourceId, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const prefixCollision = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(prefixCollision)); + if (Result.isFailure(prefixCollision)) { + assert.equal(prefixCollision.failure.code, "manifest-invalid"); + } + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest() }))), + ), + ), + ), +); + +it("plugin id collision follows the DB table prefix, not the raw id", () => { + // Same prefix / one a prefix of the other → collide. + assert.isTrue(PluginInstallerModule.pluginTablePrefixesCollide("test", "test")); + assert.isTrue(PluginInstallerModule.pluginTablePrefixesCollide("test", "test-plugin")); + assert.isTrue(PluginInstallerModule.pluginTablePrefixesCollide("a", "a-b")); + // Distinct ids whose prefixes are NOT prefixes of each other → no collision. + assert.isFalse(PluginInstallerModule.pluginTablePrefixesCollide("chat", "chatbot")); + assert.isFalse(PluginInstallerModule.pluginTablePrefixesCollide("board", "boards")); + assert.isFalse(PluginInstallerModule.pluginTablePrefixesCollide("a", "b")); +}); + +it.effect("PluginInstaller begin-confirm updates the lockfile and hot-activates", () => { + const activated: Array = []; + return Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + assert.equal(staged.capabilityDescriptions.agents, "Run AI agents"); + const result = yield* installer.confirmInstall(staged.stageToken); + const lockfile = yield* store.readLockfile; + + assert.equal(result.plugin.id, pluginId); + assert.equal(lockfile.plugins[pluginId]?.state, "active"); + assert.deepEqual(activated, [pluginId]); + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest(), activated }))), + ); +}); + +it.effect("PluginInstaller abort and expired tokens clean staging", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + yield* installer.abortInstall(staged.stageToken); + assert.isFalse(yield* fs.exists(path.join(config.pluginsDir, ".staging", staged.stageToken))); + + const expired = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + yield* TestClock.adjust("16 minutes"); + const result = yield* Effect.result(installer.confirmInstall(expired.stageToken)); + + assert.isTrue(Result.isFailure(result)); + assert.isFalse( + yield* fs.exists(path.join(config.pluginsDir, ".staging", expired.stageToken)), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(installerLayer({ tarball: tarballForManifest() }), NodeServices.layer), + ), + ), + ), +); + +it.effect("PluginInstaller stages upgrades and uninstall marks pending remove", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + yield* store.updatePlugin(pluginId, () => + Effect.succeed({ + version: "0.9.0", + sha256: "old", + sourceId, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const staged = yield* installer.beginUpgrade({ pluginId, version: "1.0.0" }); + yield* installer.confirmUpgrade(staged.stageToken); + let lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.state, "pending-upgrade"); + assert.equal(lockfile.plugins[pluginId]?.staged?.version, "1.0.0"); + + yield* installer.uninstall({ pluginId, removeData: false }); + lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.state, "pending-remove"); + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest() }))), + ), +); diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts new file mode 100644 index 00000000000..4f1f303962f --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -0,0 +1,911 @@ +import { + HOST_API_VERSION, + PluginCapability, + PluginId, + PluginManagementError, + PluginManifest, + hostApiSatisfies, + type MarketplaceVersion, + type PluginId as PluginIdType, + type PluginInfo, + type PluginInstallStaged, + type PluginLockfilePlugin, +} from "@t3tools/contracts/plugin"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeURL from "node:url"; +import * as NodeZlib from "node:zlib"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import { PluginHost } from "./PluginHost.ts"; +import { pluginSqlPrefix } from "./PluginMigrator.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { PluginMarketplace } from "./PluginMarketplace.ts"; +import { pluginManifestPath, pluginVersionDir } from "./PluginPaths.ts"; +import { readHttpResponseBytesCapped } from "./readHttpResponseBytesCapped.ts"; + +const DOWNLOAD_MAX_BYTES = 64 * 1024 * 1024; +const EXTRACT_TOTAL_MAX_BYTES = 128 * 1024 * 1024; +const EXTRACT_FILE_MAX_BYTES = 16 * 1024 * 1024; +const DECOMPRESSION_RATIO_MAX = 100; +const STAGE_TOKEN_TTL_MS = 15 * 60 * 1000; +const PRESERVE_DATA_MARKER = ".preserve-data-on-remove"; + +// Streaming gunzip with a HARD output cap: a bomb that expands past the cap is +// aborted mid-inflate, so peak memory stays ~cap + one chunk instead of the +// full (multi-GB) decompressed size. A one-shot gunzip would allocate the +// whole output before any size check could run. +const gunzipCapped = (bytes: Uint8Array, maxBytes: number): Promise => + new Promise((resolve, reject) => { + const stream = NodeZlib.createGunzip(); + const chunks: Array = []; + let total = 0; + stream.on("data", (chunk: Buffer) => { + total += chunk.byteLength; + if (total > maxBytes) { + stream.destroy(); + reject( + managementError("extract-failed", "Plugin archive expands beyond the size limit.", { + limit: maxBytes, + }), + ); + return; + } + chunks.push(chunk); + }); + stream.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks)))); + stream.on("error", (cause) => reject(cause)); + stream.end(Buffer.from(bytes)); + }); +const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); +const isPluginManagementError = Schema.is(PluginManagementError); + +export const PLUGIN_CAPABILITY_DESCRIPTIONS = { + agents: "Run AI agents", + vcs: "Read version control state", + terminals: "Create and manage terminals", + database: "Use plugin database tables", + "projections.read": "Read projected workspace data", + "environments.read": "Read environment metadata", + secrets: "Store plugin secrets", + http: "Serve plugin HTTP routes", + sourceControl: "Use source control integrations", + textGeneration: "Request text generation", +} satisfies Record; + +const managementError = (code: PluginManagementError["code"], message: string, data?: unknown) => + new PluginManagementError({ + code, + message, + ...(data === undefined ? {} : { data }), + }); + +export class PluginInstaller extends Context.Service< + PluginInstaller, + { + readonly beginInstall: (input: { + readonly sourceId: string; + readonly pluginId: PluginIdType; + readonly version: string; + }) => Effect.Effect; + readonly confirmInstall: ( + stageToken: string, + ) => Effect.Effect<{ readonly plugin: PluginInfo }, PluginManagementError>; + readonly abortInstall: (stageToken: string) => Effect.Effect; + readonly setEnabled: (input: { + readonly pluginId: PluginIdType; + readonly enabled: boolean; + }) => Effect.Effect; + readonly uninstall: (input: { + readonly pluginId: PluginIdType; + readonly removeData: boolean; + }) => Effect.Effect; + readonly beginUpgrade: (input: { + readonly pluginId: PluginIdType; + readonly version: string; + }) => Effect.Effect; + readonly confirmUpgrade: ( + stageToken: string, + ) => Effect.Effect<{ readonly plugin: PluginInfo }, PluginManagementError>; + readonly checkUpdates: Effect.Effect< + { + readonly updates: ReadonlyArray<{ + readonly pluginId: PluginIdType; + readonly currentVersion: string; + readonly latestVersion: string; + }>; + }, + PluginManagementError + >; + } +>()("t3/plugins/PluginInstaller") {} + +interface StageRecord { + readonly operation: "install" | "upgrade"; + readonly stageToken: string; + readonly sourceId: string; + readonly pluginId: PluginIdType; + readonly version: string; + readonly sha256: string; + readonly stagingDir: string; + readonly expiresAtMs: number; + readonly manifest: PluginManifest; +} + +const sha256Hex = (bytes: Uint8Array) => + NodeCrypto.createHash("sha256").update(bytes).digest("hex"); + +const isGzip = (bytes: Uint8Array) => bytes[0] === 0x1f && bytes[1] === 0x8b; + +const isZeroBlock = (block: Uint8Array) => block.every((byte) => byte === 0); + +const cString = (bytes: Uint8Array) => { + const end = bytes.indexOf(0); + const slice = end === -1 ? bytes : bytes.slice(0, end); + return new TextDecoder().decode(slice).trim(); +}; + +const octal = (bytes: Uint8Array) => { + const raw = cString(bytes).split("\u0000").join("").trim(); + return raw.length === 0 ? 0 : Number.parseInt(raw, 8); +}; + +const isAllowedArchivePath = (entryPath: string) => + entryPath === "manifest.json" || + entryPath === "server" || + entryPath.startsWith("server/") || + entryPath === "web" || + entryPath.startsWith("web/") || + entryPath === "assets" || + entryPath.startsWith("assets/"); + +const validateRelativeArchivePath = (entryPath: string) => { + if ( + entryPath.length === 0 || + entryPath.includes("\0") || + entryPath.includes("\\") || + entryPath.startsWith("/") || + entryPath.split("/").some((segment) => segment === "." || segment === "..") + ) { + return managementError("extract-failed", "Plugin archive contains an unsafe path.", { + entryPath, + }); + } + if (!isAllowedArchivePath(entryPath)) { + return managementError("extract-failed", "Plugin archive contains an unsupported path.", { + entryPath, + }); + } + return null; +}; + +const ensureSemver = (value: string) => value.split(/[+-]/u)[0]?.split(".").map(Number) ?? []; + +const compareSemver = (left: string, right: string) => { + const leftParts = ensureSemver(left); + const rightParts = ensureSemver(right); + for (let index = 0; index < 3; index++) { + const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (diff !== 0) return diff; + } + return left.localeCompare(right); +}; + +const installedEntry = (entry: PluginLockfilePlugin | undefined): PluginLockfilePlugin => { + if (!entry) { + throw managementError("plugin-not-found", "Plugin is not installed."); + } + return entry; +}; + +const lockfileError = (cause: unknown) => + managementError( + "lockfile", + cause instanceof Error ? cause.message : "Plugin lockfile update failed.", + { + cause, + }, + ); + +/** + * Two plugin ids collide iff one's DB table prefix (`p__`) + * is a prefix of the other's — the real namespacing invariant the migrator + * enforces. A raw-id startsWith both falsely rejects distinct ids + * ("chat"/"chatbot": `p_chat_` is NOT a prefix of `p_chatbot_`) and misses + * hyphen aliasing ("test"/"test-plugin": `p_test_` IS a prefix of + * `p_test_plugin_`, so they DO collide). + */ +export function pluginTablePrefixesCollide(idA: string, idB: string): boolean { + const prefixA = pluginSqlPrefix(idA); + const prefixB = pluginSqlPrefix(idB); + return prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA); +} + +function assertNoPluginIdCollision( + pluginId: PluginIdType, + installedIds: ReadonlyArray, + allowSameId: boolean, +) { + for (const installedId of installedIds) { + if (installedId === pluginId) { + if (allowSameId) continue; + throw managementError("manifest-invalid", "Plugin is already installed.", { pluginId }); + } + if (pluginTablePrefixesCollide(pluginId, installedId)) { + throw managementError("manifest-invalid", "Plugin id collides with an installed plugin id.", { + pluginId, + installedId, + }); + } + } +} + +const assertHostCompatibility = (version: MarketplaceVersion, manifest: PluginManifest) => { + if (!hostApiSatisfies(version.hostApi, HOST_API_VERSION)) { + throw managementError( + "manifest-invalid", + "Marketplace version is not compatible with this host API.", + { + requested: version.hostApi, + hostApiVersion: HOST_API_VERSION, + }, + ); + } + if (!hostApiSatisfies(manifest.hostApi, HOST_API_VERSION)) { + throw managementError( + "manifest-invalid", + "Plugin manifest is not compatible with this host API.", + { + requested: manifest.hostApi, + hostApiVersion: HOST_API_VERSION, + }, + ); + } + if (version.minAppVersion && compareSemver(packageJson.version, version.minAppVersion) < 0) { + throw managementError("manifest-invalid", "Plugin version requires a newer app version.", { + minAppVersion: version.minAppVersion, + appVersion: packageJson.version, + }); + } + if (manifest.minAppVersion && compareSemver(packageJson.version, manifest.minAppVersion) < 0) { + throw managementError("manifest-invalid", "Plugin manifest requires a newer app version.", { + minAppVersion: manifest.minAppVersion, + appVersion: packageJson.version, + }); + } +}; + +const decompressTarball = (bytes: Uint8Array) => + Effect.tryPromise({ + try: async () => { + if (!isGzip(bytes)) return bytes; + // Cap the streamed output so a bomb is aborted before it exhausts memory. + // Also bound by the ratio relative to the (already capped) input. + const ratioCap = bytes.byteLength * DECOMPRESSION_RATIO_MAX; + const cap = Math.min(EXTRACT_TOTAL_MAX_BYTES, Math.max(ratioCap, 512)); + return await gunzipCapped(bytes, cap); + }, + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("extract-failed", "Failed to decompress plugin archive.", { cause }), + }); + +const extractTar = (input: { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly tarBytes: Uint8Array; + readonly outputDir: string; +}) => + Effect.gen(function* () { + let offset = 0; + let totalSize = 0; + while (offset + 512 <= input.tarBytes.byteLength) { + const header = input.tarBytes.slice(offset, offset + 512); + offset += 512; + if (isZeroBlock(header)) break; + + const name = cString(header.slice(0, 100)); + const prefix = cString(header.slice(345, 500)); + const entryPath = prefix.length > 0 ? `${prefix}/${name}` : name; + const size = octal(header.slice(124, 136)); + const typeFlag = String.fromCharCode(header[156] ?? 0); + if (!Number.isFinite(size) || size < 0) { + return yield* managementError( + "extract-failed", + "Plugin archive contains an invalid size.", + { + entryPath, + }, + ); + } + if (offset + size > input.tarBytes.byteLength) { + return yield* managementError("extract-failed", "Plugin archive is truncated.", { + entryPath, + }); + } + + const pathError = validateRelativeArchivePath(entryPath); + if (pathError) return yield* pathError; + if (typeFlag === "2" || typeFlag === "1") { + return yield* managementError("extract-failed", "Plugin archive may not contain links.", { + entryPath, + }); + } + if (typeFlag !== "\0" && typeFlag !== "0" && typeFlag !== "5") { + return yield* managementError( + "extract-failed", + "Plugin archive contains an unsupported entry.", + { + entryPath, + typeFlag, + }, + ); + } + + if (typeFlag === "5") { + yield* input.fs.makeDirectory(input.path.join(input.outputDir, entryPath), { + recursive: true, + }); + } else { + if (size > EXTRACT_FILE_MAX_BYTES) { + return yield* managementError("extract-failed", "Plugin archive file is too large.", { + entryPath, + limit: EXTRACT_FILE_MAX_BYTES, + actual: size, + }); + } + totalSize += size; + if (totalSize > EXTRACT_TOTAL_MAX_BYTES) { + return yield* managementError( + "extract-failed", + "Plugin archive extracted size is too large.", + { + limit: EXTRACT_TOTAL_MAX_BYTES, + actual: totalSize, + }, + ); + } + const outputPath = input.path.join(input.outputDir, entryPath); + yield* input.fs.makeDirectory(input.path.dirname(outputPath), { recursive: true }); + yield* input.fs.writeFile(outputPath, input.tarBytes.slice(offset, offset + size)); + } + + offset += Math.ceil(size / 512) * 512; + } + }).pipe( + Effect.mapError((cause) => + isPluginManagementError(cause) + ? cause + : managementError("extract-failed", "Failed to extract plugin archive.", { cause }), + ), + ); + +export const make = Effect.fn("PluginInstaller.make")(function* () { + const config = yield* ServerConfig.ServerConfig; + const httpClient = yield* HttpClient.HttpClient; + const clock = yield* Clock.Clock; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const store = yield* PluginLockfileStore; + const marketplace = yield* PluginMarketplace; + const host = yield* PluginHost; + const catalog = yield* PluginCatalog; + const stages = yield* Ref.make(new Map()); + const stagingRoot = path.join(config.pluginsDir, ".staging"); + + const removePath = (target: string) => + fs + .remove(target, { recursive: true, force: true }) + .pipe( + Effect.mapError((cause) => + managementError("filesystem", "Failed to remove plugin path.", { target, cause }), + ), + ); + + const cleanupExpired = Effect.gen(function* () { + const now = yield* clock.currentTimeMillis; + const expired = yield* Ref.modify(stages, (current) => { + const next = new Map(current); + const removed: Array = []; + for (const stage of current.values()) { + if (stage.expiresAtMs <= now) { + next.delete(stage.stageToken); + removed.push(stage); + } + } + return [removed, next]; + }); + yield* Effect.forEach(expired, (stage) => removePath(stage.stagingDir), { + concurrency: 4, + discard: true, + }); + }); + + const getStage = (stageToken: string, operation: StageRecord["operation"]) => + Effect.gen(function* () { + yield* cleanupExpired; + const stage = (yield* Ref.get(stages)).get(stageToken); + if (!stage || stage.operation !== operation) { + return yield* managementError("stage-not-found", "Plugin staging token was not found.", { + stageToken, + }); + } + return stage; + }); + + const dropStage = (stageToken: string) => + Ref.modify(stages, (current) => { + const stage = current.get(stageToken); + const next = new Map(current); + next.delete(stageToken); + return [stage, next] as const; + }); + + const readSources = Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + return lockfile.sources; + }); + + const sourceById = (sourceId: string) => + Effect.gen(function* () { + const source = (yield* readSources).find((candidate) => candidate.id === sourceId); + if (!source) { + return yield* managementError("source-not-found", "Plugin source was not found.", { + sourceId, + }); + } + return source; + }); + + const downloadBytes = (url: string) => { + if (url.startsWith("file:")) { + return fs.readFile(NodeURL.fileURLToPath(url)).pipe( + Effect.mapError((cause) => + managementError("download-failed", "Failed to read plugin tarball file.", { + url, + cause, + }), + ), + ); + } + return httpClient.execute(HttpClientRequest.get(url)).pipe( + Effect.mapError((cause) => + managementError("download-failed", "Failed to download plugin tarball.", { url, cause }), + ), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError((cause) => + managementError("download-failed", "Plugin tarball returned a non-OK response.", { + url, + cause, + }), + ), + Effect.flatMap((response) => + readHttpResponseBytesCapped({ + response, + maxBytes: DOWNLOAD_MAX_BYTES, + tooLarge: (actual) => + managementError("download-failed", "Plugin tarball is too large.", { + url, + limit: DOWNLOAD_MAX_BYTES, + actual, + }), + readFailed: (cause) => + managementError("download-failed", "Failed to read plugin tarball body.", { + url, + cause, + }), + }), + ), + ); + }; + + const readManifest = (stagingDir: string) => + fs.readFileString(pluginManifestPath(stagingDir, path.join)).pipe( + Effect.mapError((cause) => + managementError("manifest-invalid", "Plugin archive is missing manifest.json.", { cause }), + ), + Effect.flatMap((raw) => + decodeManifestJson(raw).pipe( + Effect.mapError((cause) => + managementError("manifest-invalid", "Plugin manifest is invalid.", { cause }), + ), + ), + ), + ); + + const validateManifest = (input: { + readonly operation: StageRecord["operation"]; + readonly requestedPluginId: PluginIdType; + readonly requestedVersion: string; + readonly manifest: PluginManifest; + readonly marketplaceVersion: MarketplaceVersion; + }) => + Effect.gen(function* () { + if (input.manifest.id !== input.requestedPluginId) { + return yield* managementError( + "manifest-invalid", + "Plugin manifest id does not match the request.", + { + expected: input.requestedPluginId, + actual: input.manifest.id, + }, + ); + } + if (input.manifest.version !== input.requestedVersion) { + return yield* managementError( + "manifest-invalid", + "Plugin manifest version does not match the request.", + { + expected: input.requestedVersion, + actual: input.manifest.version, + }, + ); + } + yield* Effect.try({ + try: () => assertHostCompatibility(input.marketplaceVersion, input.manifest), + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("manifest-invalid", "Plugin manifest compatibility check failed.", { + cause, + }), + }); + + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + yield* Effect.try({ + try: () => + assertNoPluginIdCollision( + input.requestedPluginId, + Object.keys(lockfile.plugins), + input.operation === "upgrade", + ), + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("manifest-invalid", "Plugin id collision check failed.", { cause }), + }); + }); + + const stageTarball = (input: { + readonly operation: StageRecord["operation"]; + readonly sourceId: string; + readonly pluginId: PluginIdType; + readonly version: string; + readonly tarballUrl: string; + readonly marketplaceVersion: MarketplaceVersion; + }) => + Effect.gen(function* () { + yield* cleanupExpired; + const downloaded = yield* downloadBytes(input.tarballUrl); + if (downloaded.byteLength > DOWNLOAD_MAX_BYTES) { + return yield* managementError("download-failed", "Plugin tarball is too large.", { + limit: DOWNLOAD_MAX_BYTES, + actual: downloaded.byteLength, + }); + } + const actualSha = sha256Hex(downloaded); + if (actualSha.toLowerCase() !== input.marketplaceVersion.sha256.toLowerCase()) { + return yield* managementError( + "checksum-mismatch", + "Plugin tarball checksum did not match.", + { + expected: input.marketplaceVersion.sha256, + actual: actualSha, + }, + ); + } + + const stageToken = NodeCrypto.randomUUID(); + const stagingDir = path.join(stagingRoot, stageToken); + yield* fs.remove(stagingDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(stagingDir, { recursive: true })), + Effect.mapError((cause) => + managementError("filesystem", "Failed to create plugin staging directory.", { + stagingDir, + cause, + }), + ), + ); + + const tarBytes = yield* decompressTarball(downloaded); + yield* extractTar({ fs, path, tarBytes, outputDir: stagingDir }).pipe( + Effect.catch((error) => removePath(stagingDir).pipe(Effect.andThen(Effect.fail(error)))), + ); + const manifest = yield* readManifest(stagingDir).pipe( + Effect.catch((error) => removePath(stagingDir).pipe(Effect.andThen(Effect.fail(error)))), + ); + yield* validateManifest({ + operation: input.operation, + requestedPluginId: input.pluginId, + requestedVersion: input.version, + manifest, + marketplaceVersion: input.marketplaceVersion, + }).pipe( + Effect.catch((error) => removePath(stagingDir).pipe(Effect.andThen(Effect.fail(error)))), + ); + + const now = yield* clock.currentTimeMillis; + yield* Ref.update(stages, (current) => { + const next = new Map(current); + next.set(stageToken, { + operation: input.operation, + stageToken, + sourceId: input.sourceId, + pluginId: input.pluginId, + version: input.version, + sha256: input.marketplaceVersion.sha256, + stagingDir, + expiresAtMs: now + STAGE_TOKEN_TTL_MS, + manifest, + }); + return next; + }); + + return { + stageToken, + manifest, + capabilityDescriptions: Object.fromEntries( + manifest.capabilities.map((capability) => [ + capability, + PLUGIN_CAPABILITY_DESCRIPTIONS[capability], + ]), + ) as Record, + }; + }); + + const pluginInfo = (pluginId: PluginIdType) => + catalog.list.pipe( + Effect.map((plugins) => plugins.find((plugin) => plugin.id === pluginId)), + Effect.flatMap((plugin) => + plugin + ? Effect.succeed(plugin) + : Effect.fail( + managementError("plugin-not-found", "Installed plugin metadata was not found.", { + pluginId, + }), + ), + ), + ); + + const moveStagingToVersionDir = (stage: StageRecord) => + Effect.gen(function* () { + const destination = pluginVersionDir( + config.pluginsDir, + stage.pluginId, + stage.version, + path.join, + ); + yield* fs.makeDirectory(path.dirname(destination), { recursive: true }); + // Remove any pre-existing version dir (reinstall / interrupted prior + // move) so rename cannot fail with ENOTEMPTY on an occupied destination. + yield* fs.remove(destination, { recursive: true, force: true }); + yield* fs.rename(stage.stagingDir, destination); + }).pipe( + Effect.mapError((cause) => + managementError("filesystem", "Failed to move staged plugin into place.", { + pluginId: stage.pluginId, + version: stage.version, + cause, + }), + ), + ); + + // Drop the stage token and best-effort remove its staging dir. Run on any + // confirm failure so a failed confirm never leaves a dangling record or dir. + const cleanupStage = (stageToken: string) => + dropStage(stageToken).pipe( + Effect.flatMap((stage) => + stage ? removePath(stage.stagingDir).pipe(Effect.ignore) : Effect.void, + ), + ); + + const beginInstall: PluginInstaller["Service"]["beginInstall"] = (input) => + Effect.gen(function* () { + const source = yield* sourceById(input.sourceId); + const found = yield* marketplace.findVersion({ + source, + pluginId: input.pluginId, + version: input.version, + }); + return yield* stageTarball({ + operation: "install", + sourceId: input.sourceId, + pluginId: input.pluginId, + version: input.version, + tarballUrl: found.tarballUrl, + marketplaceVersion: found.version, + }); + }); + + const confirmInstall: PluginInstaller["Service"]["confirmInstall"] = (stageToken) => + Effect.gen(function* () { + const stage = yield* getStage(stageToken, "install"); + yield* moveStagingToVersionDir(stage); + const installedAt = DateTime.formatIso(yield* DateTime.now); + yield* store + .updatePlugin(stage.pluginId, () => + Effect.succeed({ + version: stage.version, + sha256: stage.sha256, + sourceId: stage.sourceId, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt, + lastError: null, + }), + ) + .pipe(Effect.mapError(lockfileError)); + yield* dropStage(stageToken); + yield* host + .activatePlugin(stage.pluginId) + .pipe( + Effect.mapError((cause) => + managementError("activation-failed", "Plugin activation failed.", { cause }), + ), + ); + return { plugin: yield* pluginInfo(stage.pluginId) }; + }).pipe(Effect.tapError(() => cleanupStage(stageToken))); + + const abortInstall: PluginInstaller["Service"]["abortInstall"] = (stageToken) => + Effect.gen(function* () { + const stage = yield* dropStage(stageToken); + if (stage) { + yield* removePath(stage.stagingDir); + } + }); + + const setEnabled: PluginInstaller["Service"]["setEnabled"] = (input) => + Effect.gen(function* () { + yield* store + .updatePlugin(input.pluginId, ({ current }) => + Effect.succeed({ + ...installedEntry(current), + enabled: input.enabled, + state: input.enabled ? "active" : "disabled", + lastError: input.enabled ? null : (current?.lastError ?? null), + activation: input.enabled + ? { activatingSince: null, crashCount: 0 } + : (current?.activation ?? { activatingSince: null, crashCount: 0 }), + }), + ) + .pipe(Effect.mapError(lockfileError)); + if (input.enabled) { + yield* host.activatePlugin(input.pluginId); + } else { + yield* host.deactivatePlugin(input.pluginId); + } + }); + + const uninstall: PluginInstaller["Service"]["uninstall"] = (input) => + Effect.gen(function* () { + if (!input.removeData) { + const markerPath = path.join(config.pluginsDir, input.pluginId, PRESERVE_DATA_MARKER); + yield* fs.makeDirectory(path.dirname(markerPath), { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(markerPath, "")), + Effect.mapError((cause) => + managementError("filesystem", "Failed to record plugin data preservation intent.", { + pluginId: input.pluginId, + cause, + }), + ), + ); + } + yield* store + .updatePlugin(input.pluginId, ({ current }) => + Effect.succeed({ + ...installedEntry(current), + state: "pending-remove", + enabled: false, + }), + ) + .pipe(Effect.mapError(lockfileError)); + }); + + const beginUpgrade: PluginInstaller["Service"]["beginUpgrade"] = (input) => + Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + const current = installedEntry(lockfile.plugins[input.pluginId]); + const source = lockfile.sources.find((candidate) => candidate.id === current.sourceId); + if (!source) { + return yield* managementError( + "source-not-found", + "Installed plugin source was not found.", + { + sourceId: current.sourceId, + }, + ); + } + const found = yield* marketplace.findVersion({ + source, + pluginId: input.pluginId, + version: input.version, + }); + return yield* stageTarball({ + operation: "upgrade", + sourceId: current.sourceId, + pluginId: input.pluginId, + version: input.version, + tarballUrl: found.tarballUrl, + marketplaceVersion: found.version, + }); + }); + + const confirmUpgrade: PluginInstaller["Service"]["confirmUpgrade"] = (stageToken) => + Effect.gen(function* () { + const stage = yield* getStage(stageToken, "upgrade"); + yield* moveStagingToVersionDir(stage); + const stagedAt = DateTime.formatIso(yield* DateTime.now); + yield* store + .updatePlugin(stage.pluginId, ({ current }) => + Effect.succeed({ + ...installedEntry(current), + state: "pending-upgrade", + staged: { + version: stage.version, + sha256: stage.sha256, + stagedAt, + }, + }), + ) + .pipe(Effect.mapError(lockfileError)); + yield* dropStage(stageToken); + return { plugin: yield* pluginInfo(stage.pluginId) }; + }).pipe(Effect.tapError(() => cleanupStage(stageToken))); + + const checkUpdates = Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + const updates = yield* Effect.forEach( + Object.entries(lockfile.plugins), + ([rawPluginId, entry]) => + Effect.gen(function* () { + const pluginId = PluginId.make(rawPluginId); + const source = lockfile.sources.find((candidate) => candidate.id === entry.sourceId); + if (!source) return null; + const index = yield* marketplace + .fetchSource(source) + .pipe(Effect.orElseSucceed(() => null)); + if (index === null) return null; + const marketplaceEntry = index.plugins.find((candidate) => candidate.id === pluginId); + if (!marketplaceEntry) return null; + const latest = marketplaceEntry.versions.toSorted((left, right) => + compareSemver(right.version, left.version), + )[0]; + if (!latest || compareSemver(latest.version, entry.version) <= 0) return null; + return { + pluginId, + currentVersion: entry.version, + latestVersion: latest.version, + }; + }), + { concurrency: 4 }, + ); + return { updates: updates.filter((update) => update !== null) }; + }); + + return PluginInstaller.of({ + beginInstall, + confirmInstall, + abortInstall, + setEnabled, + uninstall, + beginUpgrade, + confirmUpgrade, + checkUpdates, + }); +}); + +export { PRESERVE_DATA_MARKER }; +export const layer = Layer.effect(PluginInstaller, make()); diff --git a/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts b/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts new file mode 100644 index 00000000000..0e54f1de93b --- /dev/null +++ b/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts @@ -0,0 +1,107 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import { PluginInstaller } from "./PluginInstaller.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; +import { PluginManagementRpcHandlers } from "./PluginManagementRpcHandlers.ts"; +import * as PluginManagementRpcHandlersModule from "./PluginManagementRpcHandlers.ts"; +import * as PluginMarketplace from "./PluginMarketplace.ts"; + +const pluginId = PluginId.make("test-plugin"); + +const TestHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + +const InstallerMockLive = Layer.succeed( + PluginInstaller, + PluginInstaller.of({ + beginInstall: () => Effect.die("not used"), + confirmInstall: () => Effect.die("not used"), + abortInstall: () => Effect.void, + setEnabled: () => Effect.void, + uninstall: () => Effect.void, + beginUpgrade: () => Effect.die("not used"), + confirmUpgrade: () => Effect.die("not used"), + checkUpdates: Effect.succeed({ updates: [] }), + }), +); + +const managementTest = it.layer( + PluginManagementRpcHandlersModule.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayer.layer), + Layer.provideMerge(PluginMarketplace.layer), + Layer.provideMerge(InstallerMockLive), + Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-management-" })), + Layer.provideMerge(NodeServices.layer), + ), +); + +managementTest("PluginManagementRpcHandlers", (it) => { + it.effect("dedupes added sources by normalized HTTPS URL", () => + Effect.gen(function* () { + const handlers = yield* PluginManagementRpcHandlers; + + const first = yield* handlers.addSource({ + url: "https://example.test/marketplace.json#ignored", + }); + const second = yield* handlers.addSource({ + url: "https://example.test/marketplace.json", + }); + const listed = yield* handlers.listSources; + + assert.equal(first.source.id, second.source.id); + assert.equal(listed.sources.length, 1); + assert.equal(listed.sources[0]?.url, "https://example.test/marketplace.json"); + }), + ); + + it.effect("rejects non-HTTPS sources", () => + Effect.gen(function* () { + const handlers = yield* PluginManagementRpcHandlers; + + const result = yield* Effect.result(handlers.addSource({ url: "http://example.test" })); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "invalid-source"); + }), + ); + + it.effect("prevents removing a source used by an installed plugin", () => + Effect.gen(function* () { + const handlers = yield* PluginManagementRpcHandlers; + const store = yield* PluginLockfileStore; + const source = yield* handlers.addSource({ url: "https://example.test/marketplace.json" }); + yield* store.updatePlugin(pluginId, () => + Effect.succeed({ + version: "1.0.0", + sha256: "sha", + sourceId: source.source.id, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const result = yield* Effect.result(handlers.removeSource({ sourceId: source.source.id })); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "invalid-source"); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginManagementRpcHandlers.ts b/apps/server/src/plugins/PluginManagementRpcHandlers.ts new file mode 100644 index 00000000000..52f7de01ffe --- /dev/null +++ b/apps/server/src/plugins/PluginManagementRpcHandlers.ts @@ -0,0 +1,165 @@ +import { + PluginManagementError, + type PluginCatalogResult, + type PluginCheckUpdatesResult, + type PluginInstallBeginInput, + type PluginInstallConfirmResult, + type PluginInstallStaged, + type PluginSetEnabledInput, + type PluginSource, + type PluginSourcesAddInput, + type PluginSourcesAddResult, + type PluginSourcesListResult, + type PluginSourcesRemoveInput, + type PluginUninstallInput, + type PluginUpgradeBeginInput, + type PluginUpgradeConfirmResult, +} from "@t3tools/contracts/plugin"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { PluginInstaller } from "./PluginInstaller.ts"; +import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { PluginMarketplace, sourceIdForUrl } from "./PluginMarketplace.ts"; + +const managementError = (code: PluginManagementError["code"], message: string, data?: unknown) => + new PluginManagementError({ + code, + message, + ...(data === undefined ? {} : { data }), + }); + +const lockfileError = (cause: unknown) => + managementError( + "lockfile", + cause instanceof Error ? cause.message : "Plugin lockfile update failed.", + { + cause, + }, + ); +const isPluginManagementError = Schema.is(PluginManagementError); +const toManagementError = (cause: unknown) => + isPluginManagementError(cause) ? cause : lockfileError(cause); + +export class PluginManagementRpcHandlers extends Context.Service< + PluginManagementRpcHandlers, + { + readonly listSources: Effect.Effect; + readonly addSource: ( + input: PluginSourcesAddInput, + ) => Effect.Effect; + readonly removeSource: ( + input: PluginSourcesRemoveInput, + ) => Effect.Effect; + readonly catalog: (input: { + readonly sourceId?: string; + }) => Effect.Effect; + readonly beginInstall: ( + input: PluginInstallBeginInput, + ) => Effect.Effect; + readonly confirmInstall: ( + stageToken: string, + ) => Effect.Effect; + readonly abortInstall: (stageToken: string) => Effect.Effect; + readonly setEnabled: ( + input: PluginSetEnabledInput, + ) => Effect.Effect; + readonly uninstall: (input: PluginUninstallInput) => Effect.Effect; + readonly beginUpgrade: ( + input: PluginUpgradeBeginInput, + ) => Effect.Effect; + readonly confirmUpgrade: ( + stageToken: string, + ) => Effect.Effect; + readonly checkUpdates: Effect.Effect; + } +>()("t3/plugins/PluginManagementRpcHandlers") {} + +export const make = Effect.fn("PluginManagementRpcHandlers.make")(function* () { + const store = yield* PluginLockfileStore; + const marketplace = yield* PluginMarketplace; + const installer = yield* PluginInstaller; + + const listSources = store.readLockfile.pipe( + Effect.map((lockfile) => ({ sources: Array.from(lockfile.sources) })), + Effect.mapError(lockfileError), + ); + + const addSource: PluginManagementRpcHandlers["Service"]["addSource"] = (input) => + Effect.gen(function* () { + const normalized = yield* marketplace.normalizeSourceUrl(input.url); + const id = sourceIdForUrl(normalized); + const now = DateTime.formatIso(yield* DateTime.now); + const source = yield* store + .updateSources((sources) => { + const existing = sources.find((candidate) => candidate.url === normalized); + if (existing) return Effect.succeed(sources); + return Effect.succeed([...sources, { id, url: normalized, addedAt: now }]); + }) + .pipe(Effect.mapError(toManagementError)); + const entry = source.sources.find((candidate) => candidate.url === normalized); + if (!entry) { + return yield* managementError("invalid-source", "Failed to add plugin source.", { + url: normalized, + }); + } + return { source: entry }; + }); + + const removeSource: PluginManagementRpcHandlers["Service"]["removeSource"] = (input) => + Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + if (!lockfile.sources.some((source) => source.id === input.sourceId)) { + return yield* managementError("source-not-found", "Plugin source was not found.", { + sourceId: input.sourceId, + }); + } + const usedBy = Object.entries(lockfile.plugins).find( + ([, plugin]) => plugin.sourceId === input.sourceId, + )?.[0]; + if (usedBy) { + return yield* managementError( + "invalid-source", + "Plugin source is still used by an installed plugin.", + { + sourceId: input.sourceId, + pluginId: usedBy, + }, + ); + } + yield* store + .updateSources((sources) => + Effect.succeed(sources.filter((source) => source.id !== input.sourceId)), + ) + .pipe(Effect.asVoid, Effect.mapError(toManagementError)); + }); + + const catalog: PluginManagementRpcHandlers["Service"]["catalog"] = (input) => + Effect.gen(function* () { + const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); + return yield* marketplace.catalog( + lockfile.sources as ReadonlyArray, + input.sourceId, + ); + }); + + return PluginManagementRpcHandlers.of({ + listSources, + addSource, + removeSource, + catalog, + beginInstall: installer.beginInstall, + confirmInstall: installer.confirmInstall, + abortInstall: installer.abortInstall, + setEnabled: installer.setEnabled, + uninstall: installer.uninstall, + beginUpgrade: installer.beginUpgrade, + confirmUpgrade: installer.confirmUpgrade, + checkUpdates: installer.checkUpdates, + }); +}); + +export const layer = Layer.effect(PluginManagementRpcHandlers, make()); diff --git a/apps/server/src/plugins/PluginMarketplace.test.ts b/apps/server/src/plugins/PluginMarketplace.test.ts new file mode 100644 index 00000000000..b023427e26f --- /dev/null +++ b/apps/server/src/plugins/PluginMarketplace.test.ts @@ -0,0 +1,174 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PluginId, type PluginSource } from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as NodeURL from "node:url"; + +import { + MarketplaceIndex, + PluginMarketplace, + resolveMarketplaceUrl, + sourceIdForUrl, + layer as PluginMarketplaceLayer, +} from "./PluginMarketplace.ts"; + +const encodeMarketplaceJson = Schema.encodeSync(Schema.fromJsonString(MarketplaceIndex)); + +const validMarketplace = { + plugins: [ + { + id: PluginId.make("test-plugin"), + name: "Test Plugin", + description: "Adds tests.", + capabilities: ["agents" as const], + versions: [ + { + version: "1.0.0", + tarball: "https://example.test/test-plugin-1.0.0.tgz", + sha256: "a".repeat(64), + hostApi: "^1.0.0", + publishedAt: "2026-07-03T00:00:00.000Z", + }, + ], + }, + ], +}; + +const TestHttpClientLive = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(validMarketplace))), + ), +); + +const marketplaceTest = it.layer( + PluginMarketplaceLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(TestClock.layer()), + Layer.provideMerge(TestHttpClientLive), + ), +); + +const withPluginDev = (effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => process.env.T3_PLUGIN_DEV), + () => + Effect.sync(() => { + process.env.T3_PLUGIN_DEV = "1"; + }).pipe(Effect.andThen(effect)), + (previous) => + Effect.sync(() => { + if (previous === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous; + } + }), + ); + +marketplaceTest("PluginMarketplace", (it) => { + it.effect("resolves HTTPS and owner/repo sources and rejects unsafe protocols", () => + Effect.sync(() => { + assert.equal( + resolveMarketplaceUrl("https://example.test/marketplace.json#ignored"), + "https://example.test/marketplace.json", + ); + assert.equal( + resolveMarketplaceUrl("owner/repo"), + "https://raw.githubusercontent.com/owner/repo/HEAD/marketplace.json", + ); + assert.throws(() => resolveMarketplaceUrl("http://example.test/marketplace.json")); + assert.throws(() => resolveMarketplaceUrl("file:///tmp/marketplace.json")); + }), + ); + + it.effect("allows file sources only in plugin dev mode", () => + withPluginDev( + Effect.sync(() => { + assert.equal( + resolveMarketplaceUrl("file:///tmp/marketplace.json"), + "file:///tmp/marketplace.json", + ); + }), + ), + ); + + it.effect("decodes marketplace json from a source", () => + withPluginDev( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const marketplace = yield* PluginMarketplace; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-marketplace-" }); + const filePath = path.join(dir, "marketplace.json"); + yield* fs.writeFileString(filePath, encodeMarketplaceJson(validMarketplace)); + const url = NodeURL.pathToFileURL(filePath).toString(); + const source: PluginSource = { + id: sourceIdForUrl(url), + url, + addedAt: "2026-07-03T00:00:00.000Z", + }; + + const index = yield* marketplace.fetchSource(source); + + assert.equal(index.plugins[0]?.id, PluginId.make("test-plugin")); + }), + ), + ); + + it.effect("isolates bad source errors in aggregate catalog calls", () => + withPluginDev( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const marketplace = yield* PluginMarketplace; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-marketplace-" }); + const goodPath = path.join(dir, "good.json"); + const badPath = path.join(dir, "bad.json"); + yield* fs.writeFileString(goodPath, encodeMarketplaceJson(validMarketplace)); + yield* fs.writeFileString(badPath, "{not-json"); + const goodUrl = NodeURL.pathToFileURL(goodPath).toString(); + const badUrl = NodeURL.pathToFileURL(badPath).toString(); + + const result = yield* marketplace.catalog([ + { id: "good", url: goodUrl, addedAt: "2026-07-03T00:00:00.000Z" }, + { id: "bad", url: badUrl, addedAt: "2026-07-03T00:00:00.000Z" }, + ]); + + assert.equal(result.entries.length, 1); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0]?.sourceId, "bad"); + }), + ), + ); + + it.effect("rejects marketplace responses over the byte cap", () => + withPluginDev( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const marketplace = yield* PluginMarketplace; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-marketplace-" }); + const filePath = path.join(dir, "huge.json"); + yield* fs.writeFileString(filePath, "x".repeat(2 * 1024 * 1024 + 1)); + const url = NodeURL.pathToFileURL(filePath).toString(); + const result = yield* Effect.result( + marketplace.fetchSource({ + id: "huge", + url, + addedAt: "2026-07-03T00:00:00.000Z", + }), + ); + + assert.isTrue(Result.isFailure(result)); + }), + ), + ); +}); diff --git a/apps/server/src/plugins/PluginMarketplace.ts b/apps/server/src/plugins/PluginMarketplace.ts new file mode 100644 index 00000000000..b1c7f1f880d --- /dev/null +++ b/apps/server/src/plugins/PluginMarketplace.ts @@ -0,0 +1,305 @@ +import { + MarketplaceEntry, + PluginManagementError, + type MarketplaceVersion, + type PluginCatalogResult, + type PluginId, + type PluginSource, +} from "@t3tools/contracts/plugin"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeURL from "node:url"; + +import { readHttpResponseBytesCapped } from "./readHttpResponseBytesCapped.ts"; + +const MARKETPLACE_RESPONSE_MAX_BYTES = 2 * 1024 * 1024; +const CATALOG_CACHE_TTL_MS = 30_000; +const OWNER_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; + +export const MarketplaceIndex = Schema.Struct({ + plugins: Schema.Array(MarketplaceEntry), +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type MarketplaceIndex = typeof MarketplaceIndex.Type; + +const decodeMarketplaceIndexJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(MarketplaceIndex), +); +const isPluginManagementError = Schema.is(PluginManagementError); + +const managementError = (code: PluginManagementError["code"], message: string, data?: unknown) => + new PluginManagementError({ + code, + message, + ...(data === undefined ? {} : { data }), + }); + +export function sourceIdForUrl(url: string): string { + return `src-${NodeCrypto.createHash("sha256").update(url).digest("hex").slice(0, 16)}`; +} + +function canonicalHttpsUrl(input: string): string | null { + try { + const url = new URL(input); + if (url.protocol !== "https:") return null; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + +export function resolveMarketplaceUrl(input: string): string { + const trimmed = input.trim(); + if (OWNER_REPO_PATTERN.test(trimmed)) { + return `https://raw.githubusercontent.com/${trimmed}/HEAD/marketplace.json`; + } + + const https = canonicalHttpsUrl(trimmed); + if (https !== null) return https; + + const url = new URL(trimmed); + if (url.protocol === "file:" && process.env.T3_PLUGIN_DEV === "1") { + return url.toString(); + } + throw managementError( + "invalid-source", + "Plugin sources must be HTTPS URLs or owner/repo shorthand.", + { url: input }, + ); +} + +export function resolveTarballUrl(input: { + readonly tarball: string; + readonly marketplaceUrl: string; +}): string { + const url = new URL(input.tarball, input.marketplaceUrl); + if (url.protocol === "https:") { + url.hash = ""; + return url.toString(); + } + if (url.protocol === "file:" && process.env.T3_PLUGIN_DEV === "1") { + return url.toString(); + } + throw managementError("invalid-source", "Plugin tarballs must resolve to HTTPS URLs.", { + tarball: input.tarball, + }); +} + +export class PluginMarketplace extends Context.Service< + PluginMarketplace, + { + readonly normalizeSourceUrl: (url: string) => Effect.Effect; + readonly fetchSource: ( + source: PluginSource, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; + readonly catalog: ( + sources: ReadonlyArray, + sourceId?: string, + ) => Effect.Effect; + readonly findVersion: (input: { + readonly source: PluginSource; + readonly pluginId: PluginId; + readonly version: string; + }) => Effect.Effect< + { + readonly entry: MarketplaceEntry; + readonly version: MarketplaceVersion; + readonly marketplaceUrl: string; + readonly tarballUrl: string; + }, + PluginManagementError + >; + } +>()("t3/plugins/PluginMarketplace") {} + +interface CachedIndex { + readonly expiresAtMs: number; + readonly index: MarketplaceIndex; +} + +export const make = Effect.fn("PluginMarketplace.make")(function* () { + const httpClient = yield* HttpClient.HttpClient; + const clock = yield* Clock.Clock; + const fs = yield* FileSystem.FileSystem; + const cache = yield* Ref.make(new Map()); + + const normalizeSourceUrl = (url: string) => + Effect.try({ + try: () => resolveMarketplaceUrl(url), + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("invalid-source", "Plugin source URL is invalid.", { cause }), + }); + + const readFileUrl = (url: string) => + fs.readFile(NodeURL.fileURLToPath(url)).pipe( + Effect.mapError((cause) => + managementError("catalog-fetch-failed", "Failed to read plugin marketplace file.", { + url, + cause, + }), + ), + ); + + const readHttpUrl = (url: string) => + httpClient.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson)).pipe( + Effect.mapError((cause) => + managementError("catalog-fetch-failed", "Failed to fetch plugin marketplace.", { + url, + cause, + }), + ), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError((cause) => + managementError("catalog-fetch-failed", "Plugin marketplace returned a non-OK response.", { + url, + cause, + }), + ), + Effect.flatMap((response) => + readHttpResponseBytesCapped({ + response, + maxBytes: MARKETPLACE_RESPONSE_MAX_BYTES, + tooLarge: (actual) => + managementError("catalog-fetch-failed", "Plugin marketplace is too large.", { + url, + limit: MARKETPLACE_RESPONSE_MAX_BYTES, + actual, + }), + readFailed: (cause) => + managementError("catalog-fetch-failed", "Failed to read plugin marketplace body.", { + url, + cause, + }), + }), + ), + ); + + const readBytes = (url: string) => + url.startsWith("file:") ? readFileUrl(url) : readHttpUrl(url); + + const fetchSource: PluginMarketplace["Service"]["fetchSource"] = (source, options) => + Effect.gen(function* () { + const marketplaceUrl = yield* normalizeSourceUrl(source.url); + const now = yield* clock.currentTimeMillis; + if (options?.refresh !== true) { + const cached = (yield* Ref.get(cache)).get(marketplaceUrl); + if (cached && cached.expiresAtMs > now) return cached.index; + } + + const bytes = yield* readBytes(marketplaceUrl); + if (bytes.byteLength > MARKETPLACE_RESPONSE_MAX_BYTES) { + return yield* managementError("catalog-fetch-failed", "Plugin marketplace is too large.", { + sourceId: source.id, + limit: MARKETPLACE_RESPONSE_MAX_BYTES, + actual: bytes.byteLength, + }); + } + + const index = yield* decodeMarketplaceIndexJson(new TextDecoder().decode(bytes)).pipe( + Effect.mapError((cause) => + managementError("catalog-fetch-failed", "Plugin marketplace JSON is invalid.", { + sourceId: source.id, + cause, + }), + ), + ); + yield* Ref.update(cache, (current) => { + const next = new Map(current); + next.set(marketplaceUrl, { index, expiresAtMs: now + CATALOG_CACHE_TTL_MS }); + return next; + }); + return index; + }); + + const catalog: PluginMarketplace["Service"]["catalog"] = (sources, sourceId) => + Effect.gen(function* () { + const selected = + sourceId === undefined ? sources : sources.filter((source) => source.id === sourceId); + if (sourceId !== undefined && selected.length === 0) { + return yield* managementError("source-not-found", "Plugin source was not found.", { + sourceId, + }); + } + const results = yield* Effect.forEach( + selected, + (source) => + fetchSource(source).pipe( + Effect.match({ + onFailure: (error) => ({ + source, + error, + index: null, + }), + onSuccess: (index) => ({ + source, + error: null, + index, + }), + }), + ), + { concurrency: 4 }, + ); + return { + entries: results.flatMap((result) => + result.index === null ? [] : Array.from(result.index.plugins), + ), + errors: results.flatMap((result) => + result.error === null + ? [] + : [ + { + sourceId: result.source.id, + url: result.source.url, + message: result.error.message, + }, + ], + ), + }; + }); + + const findVersion: PluginMarketplace["Service"]["findVersion"] = (input) => + Effect.gen(function* () { + const marketplaceUrl = yield* normalizeSourceUrl(input.source.url); + const index = yield* fetchSource(input.source, { refresh: true }); + const entry = index.plugins.find((candidate) => candidate.id === input.pluginId); + if (entry === undefined) { + return yield* managementError("plugin-not-found", "Plugin was not found in source.", { + pluginId: input.pluginId, + sourceId: input.source.id, + }); + } + const version = entry.versions.find((candidate) => candidate.version === input.version); + if (version === undefined) { + return yield* managementError("version-not-found", "Plugin version was not found.", { + pluginId: input.pluginId, + version: input.version, + sourceId: input.source.id, + }); + } + return { + entry, + version, + marketplaceUrl, + tarballUrl: resolveTarballUrl({ tarball: version.tarball, marketplaceUrl }), + }; + }); + + return PluginMarketplace.of({ + normalizeSourceUrl, + fetchSource, + catalog, + findVersion, + }); +}); + +export const layer = Layer.effect(PluginMarketplace, make()); diff --git a/apps/server/src/plugins/PluginMigrator.ts b/apps/server/src/plugins/PluginMigrator.ts index 5ef03ccbe5e..c68ff4bea15 100644 --- a/apps/server/src/plugins/PluginMigrator.ts +++ b/apps/server/src/plugins/PluginMigrator.ts @@ -80,7 +80,9 @@ export class PluginMigrator extends Context.Service< } >()("t3/plugins/PluginMigrator") {} -const pluginSqlPrefix = (pluginId: string) => `p_${pluginId.replaceAll("-", "_")}_`; +// The DB namespace a plugin's migrations are confined to. Exported so the +// installer's id-collision guard checks the SAME prefix the gate enforces. +export const pluginSqlPrefix = (pluginId: string) => `p_${pluginId.replaceAll("-", "_")}_`; const sqliteMasterSnapshot = (sql: SqlClient.SqlClient) => sql` @@ -109,6 +111,14 @@ const removedObjects = ( return before.filter((entry) => !afterKeys.has(objectKey(entry))); }; +// This gate is SCHEMA-OBJECT confinement, not a data-mutation sandbox. It only +// diffs sqlite_master, so it constrains which tables/indexes/triggers/views a +// migration may create, alter, or drop (to the plugin's `p__*` namespace) — +// it deliberately does NOT restrict INSERT/UPDATE/DELETE against core tables. +// Plugins run in-process under a full-trust model (they can reach the SqlClient +// and much else directly), so the prefix rule is a well-behaved-plugin +// convention that keeps schemas from colliding, not a security boundary against +// a malicious plugin. Do not mistake it for one. const validateMigrationObjects = (input: { readonly pluginId: PluginId; readonly version: number; diff --git a/apps/server/src/plugins/readHttpResponseBytesCapped.ts b/apps/server/src/plugins/readHttpResponseBytesCapped.ts new file mode 100644 index 00000000000..55e1d0e4fd5 --- /dev/null +++ b/apps/server/src/plugins/readHttpResponseBytesCapped.ts @@ -0,0 +1,37 @@ +import { PluginManagementError } from "@t3tools/contracts/plugin"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type { HttpClientResponse } from "effect/unstable/http"; + +const isPluginManagementError = Schema.is(PluginManagementError); + +export const readHttpResponseBytesCapped = (input: { + readonly response: HttpClientResponse.HttpClientResponse; + readonly maxBytes: number; + readonly tooLarge: (observedBytes: number) => PluginManagementError; + readonly readFailed: (cause: unknown) => PluginManagementError; +}) => + input.response.stream.pipe( + Stream.runFoldEffect( + () => ({ chunks: [] as Array, total: 0 }), + (acc, chunk) => { + const total = acc.total + chunk.byteLength; + if (total > input.maxBytes) { + return Effect.fail(input.tooLarge(total)); + } + acc.chunks.push(chunk); + return Effect.succeed({ chunks: acc.chunks, total }); + }, + ), + Effect.map(({ chunks, total }) => { + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + }), + Effect.mapError((cause) => (isPluginManagementError(cause) ? cause : input.readFailed(cause))), + ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 60ee4b4a9af..aadf11cb2cc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -118,6 +118,7 @@ import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; +import * as PluginManagementRpcHandlers from "./plugins/PluginManagementRpcHandlers.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as Data from "effect/Data"; @@ -756,6 +757,27 @@ const buildAppUnderTest = (options?: { }), ), ), + Layer.provide( + Layer.succeed( + PluginManagementRpcHandlers.PluginManagementRpcHandlers, + PluginManagementRpcHandlers.PluginManagementRpcHandlers.of({ + listSources: Effect.succeed({ sources: [] }), + addSource: () => Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + removeSource: () => Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + catalog: () => Effect.succeed({ entries: [], errors: [] }), + beginInstall: () => Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + confirmInstall: () => + Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + abortInstall: () => Effect.void, + setEnabled: () => Effect.void, + uninstall: () => Effect.void, + beginUpgrade: () => Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + confirmUpgrade: () => + Effect.die("PluginManagementRpcHandlers not stubbed in this test"), + checkUpdates: Effect.succeed({ updates: [] }), + }), + ), + ), Layer.provideMerge(PluginHttpRegistry.layer), Layer.provide(PluginLockfileStore.layer), Layer.provide( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d38d73ded58..69d720ef7fd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -37,7 +37,10 @@ import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import { pluginHttpRouteLayer } from "./plugins/PluginHttpRoutes.ts"; import { pluginWebRouteLayer } from "./plugins/PluginWebRoutes.ts"; import * as PluginCatalog from "./plugins/PluginCatalog.ts"; +import * as PluginInstaller from "./plugins/PluginInstaller.ts"; import * as PluginLockfileStore from "./plugins/PluginLockfileStore.ts"; +import * as PluginManagementRpcHandlers from "./plugins/PluginManagementRpcHandlers.ts"; +import * as PluginMarketplace from "./plugins/PluginMarketplace.ts"; import * as PluginMigrator from "./plugins/PluginMigrator.ts"; import * as PluginModuleLoader from "./plugins/PluginModuleLoader.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; @@ -335,10 +338,25 @@ const PluginCatalogLayerLive = PluginCatalog.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayerLive), Layer.provideMerge(PluginRuntimeRegistryLayerLive), ); +const PluginMarketplaceLayerLive = PluginMarketplace.layer; +const PluginInstallerLayerLive = PluginInstaller.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginMarketplaceLayerLive), + Layer.provideMerge(PluginHostLayerLive), + Layer.provideMerge(PluginCatalogLayerLive), +); +const PluginManagementRpcHandlersLayerLive = PluginManagementRpcHandlers.layer.pipe( + Layer.provideMerge(PluginLockfileStoreLayerLive), + Layer.provideMerge(PluginMarketplaceLayerLive), + Layer.provideMerge(PluginInstallerLayerLive), +); const PluginLayerLive = Layer.mergeAll( PluginHostLayerLive, PluginRpcDispatcherLayerLive, PluginCatalogLayerLive, + PluginMarketplaceLayerLive, + PluginInstallerLayerLive, + PluginManagementRpcHandlersLayerLive, PluginHttpRegistryLayerLive, PluginLockfileStoreLayerLive, ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts new file mode 100644 index 00000000000..4af676b1429 --- /dev/null +++ b/apps/server/src/ws.test.ts @@ -0,0 +1,138 @@ +import type { + OrchestrationThreadShell, + ProjectId, + ThreadId, + ThreadOwner, + TurnId, +} from "@t3tools/contracts"; +import { + AuthOrchestrationReadScope, + PLUGINS_WS_METHODS, + ProviderInstanceId, + WS_METHODS, + pluginReadScope, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type { ProjectionSnapshotQueryShape } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { isMethodAuthorized, toThreadUpsertedShellStreamEvent } from "./ws.ts"; + +const now = "2026-05-25T00:00:00.000Z"; +const threadId = "thread-1" as ThreadId; +const projectId = "project-1" as ProjectId; + +const threadShell = { + id: threadId, + projectId, + title: "Run remote agent", + 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; + +const makeSnapshotQuery = (owner: Option.Option) => { + let shellLookups = 0; + const stub = { + getThreadOwnerById: () => Effect.succeed(owner), + getThreadShellById: () => { + shellLookups += 1; + return Effect.succeed(Option.some(threadShell)); + }, + } as unknown as ProjectionSnapshotQueryShape; + return { stub, shellLookups: () => shellLookups }; +}; + +describe("toThreadUpsertedShellStreamEvent", () => { + it.effect("drops the event for a plugin-owned thread and skips the shell lookup", () => + Effect.gen(function* () { + const { stub, shellLookups } = makeSnapshotQuery(Option.some("plugin:test" as ThreadOwner)); + + const result = yield* toThreadUpsertedShellStreamEvent(stub, threadId, 7); + + expect(Option.isNone(result)).toBe(true); + // The plugin thread must never be materialized into a shell payload that + // could leak to connected clients. + expect(shellLookups()).toBe(0); + }), + ); + + it.effect("emits a thread-upserted event for a user-owned thread", () => + Effect.gen(function* () { + const { stub } = makeSnapshotQuery(Option.some("user")); + + const result = yield* toThreadUpsertedShellStreamEvent(stub, threadId, 7); + + expect(Option.isSome(result)).toBe(true); + if (Option.isSome(result)) { + expect(result.value.kind).toBe("thread-upserted"); + expect(result.value.sequence).toBe(7); + if (result.value.kind === "thread-upserted") { + expect(result.value.thread.id).toBe(threadId); + } + } + }), + ); + + it.effect("emits for a missing owner row (deleted/unknown thread proceeds)", () => + Effect.gen(function* () { + const { stub } = makeSnapshotQuery(Option.none()); + + const result = yield* toThreadUpsertedShellStreamEvent(stub, threadId, 7); + + expect(Option.isSome(result)).toBe(true); + }), + ); +}); + +describe("isMethodAuthorized", () => { + const pluginRead = pluginReadScope("acme"); + + it("lets a least-privilege plugin-scoped token satisfy the plugin call/subscribe baseline", () => { + // A token holding only `plugin:acme:read` does NOT satisfy the orchestration + // read baseline, but must still reach the dispatcher for plugin invocation. + expect( + isMethodAuthorized(PLUGINS_WS_METHODS.call, AuthOrchestrationReadScope, [pluginRead]), + ).toBe(true); + expect( + isMethodAuthorized(PLUGINS_WS_METHODS.subscribe, AuthOrchestrationReadScope, [pluginRead]), + ).toBe(true); + }); + + it("does not let a plugin scope satisfy any non-plugin method (no broadening)", () => { + expect( + isMethodAuthorized(WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope, [pluginRead]), + ).toBe(false); + expect( + isMethodAuthorized(PLUGINS_WS_METHODS.list, AuthOrchestrationReadScope, [pluginRead]), + ).toBe(false); + }); + + it("keeps the orchestration-read baseline for plugin call and rejects an unscoped session", () => { + expect( + isMethodAuthorized(PLUGINS_WS_METHODS.call, AuthOrchestrationReadScope, [ + AuthOrchestrationReadScope, + ]), + ).toBe(true); + expect(isMethodAuthorized(PLUGINS_WS_METHODS.call, AuthOrchestrationReadScope, [])).toBe(false); + }); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 352d8eb6bf9..ca65af5f4ef 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, + DEFAULT_THREAD_OWNER, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthReviewWriteScope, @@ -18,8 +19,10 @@ import { AuthTerminalOperateScope, AuthAccessReadScope, AuthAccessStreamError, + AuthPluginsManageScope, type AuthAccessStreamEvent, type AuthEnvironmentScope, + type AuthScope, AuthSessionId, CommandId, type DiscoveredLocalServerList, @@ -57,6 +60,7 @@ import { type TerminalMetadataStreamEvent, WS_METHODS, WsRpcGroup, + isPluginScope, satisfiesScope, } from "@t3tools/contracts"; import { clamp } from "effect/Number"; @@ -114,12 +118,52 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import { PluginCatalog } from "./plugins/PluginCatalog.ts"; +import { PluginManagementRpcHandlers } from "./plugins/PluginManagementRpcHandlers.ts"; import { PluginRpcDispatcher } from "./plugins/PluginRpcDispatcher.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +/** + * Maps a thread-aggregate change into a shell-stream `thread-upserted` event, + * enforcing the owner visibility guard. + * + * The initial `subscribeShell` snapshot is owner-filtered, but the live stream + * maps every subsequent thread event through the (intentionally unfiltered) + * `getThreadShellById`. Without this guard a plugin's `agents.createThread` / + * `startTurn` would push the plugin thread's shell to every connected client + * (surfacing a hidden thread in the user's sidebar in real time). Non-user + * (plugin-owned) threads are hidden from user-facing views, so drop the event + * for them here — mirroring `AgentAwarenessRelay.publishThread`'s skip-check. + * + * A missing owner row (None) proceeds and emits: deleted/unknown threads keep + * the same downstream not-found handling as before this guard existed. Any + * lookup error fails closed (emits nothing), matching the prior + * `getThreadShellById` error handling. + */ +export const toThreadUpsertedShellStreamEvent = ( + projectionSnapshotQuery: ProjectionSnapshotQuery.ProjectionSnapshotQueryShape, + threadId: ThreadId, + sequence: number, +): Effect.Effect, never, never> => + projectionSnapshotQuery.getThreadOwnerById(threadId).pipe( + Effect.flatMap((owner) => + Option.isSome(owner) && owner.value !== DEFAULT_THREAD_OWNER + ? Effect.succeed(Option.none()) + : projectionSnapshotQuery.getThreadShellById(threadId).pipe( + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence, + thread: nextThread, + })), + ), + ), + ), + Effect.orElseSucceed(() => Option.none()), + ); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -352,8 +396,51 @@ const RPC_REQUIRED_SCOPE = new Map([ // performs the real per-plugin plugin::read|operate authorization. [PLUGINS_WS_METHODS.call, AuthOrchestrationReadScope], [PLUGINS_WS_METHODS.subscribe, AuthOrchestrationReadScope], + [PLUGINS_WS_METHODS.sourcesList, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.sourcesAdd, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.sourcesRemove, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.catalog, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installBegin, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installConfirm, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installAbort, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.setEnabled, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.uninstall, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.upgradeBegin, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.upgradeConfirm, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.checkUpdates, AuthPluginsManageScope], ]); +// Plugin method invocation (call/subscribe) carries the orchestration-read +// baseline above, but a least-privilege plugin-scoped token (e.g. one holding +// only `plugin::read`) legitimately does not satisfy that baseline. For +// these two methods, holding ANY plugin scope also satisfies the transport +// gate; the dispatcher then enforces the real per-plugin `plugin::read| +// operate` authorization. This preserves per-plugin least privilege without +// requiring read access to the whole orchestration surface. +const PLUGIN_INVOCATION_METHODS = new Set([ + PLUGINS_WS_METHODS.call, + PLUGINS_WS_METHODS.subscribe, +]); + +/** + * Transport-level authorization predicate for a WS RPC method. Exported for unit + * testing. A session may invoke `method` when it satisfies the method's declared + * baseline `requiredScope`, OR — for plugin call/subscribe only — when it holds + * any plugin scope. The latter lets a least-privilege plugin-scoped token reach + * the dispatcher, which then enforces the real per-plugin + * `plugin::read|operate` authorization. It does not broaden any other method. + */ +export function isMethodAuthorized( + method: string, + requiredScope: AuthEnvironmentScope, + scopes: ReadonlyArray, +): boolean { + return ( + satisfiesScope(requiredScope, scopes) || + (PLUGIN_INVOCATION_METHODS.has(method) && scopes.some(isPluginScope)) + ); +} + function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, revision: number, @@ -445,52 +532,49 @@ const makeWsRpcLayer = ( const relayClient = yield* RelayClient.RelayClient; const pluginCatalog = yield* PluginCatalog; const pluginRpcDispatcher = yield* PluginRpcDispatcher; + const pluginManagement = yield* PluginManagementRpcHandlers; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, requiredScope, }); + const requiredScopeForMethod = (method: string): AuthEnvironmentScope => { + const requiredScope = RPC_REQUIRED_SCOPE.get(method); + if (requiredScope === undefined) { + throw new Error(`RPC method ${method} has no declared authorization scope.`); + } + return requiredScope; + }; + const methodAuthorized = (method: string, requiredScope: AuthEnvironmentScope): boolean => + isMethodAuthorized(method, requiredScope, currentSession.scopes); const authorizeEffect = ( - requiredScope: AuthEnvironmentScope, + method: string, effect: Effect.Effect, - ): Effect.Effect => - satisfiesScope(requiredScope, currentSession.scopes) + ): Effect.Effect => { + const requiredScope = requiredScopeForMethod(method); + return methodAuthorized(method, requiredScope) ? effect : Effect.fail(authorizationError(requiredScope)); + }; const authorizeStream = ( - requiredScope: AuthEnvironmentScope, + method: string, stream: Stream.Stream, - ): Stream.Stream => - satisfiesScope(requiredScope, currentSession.scopes) + ): Stream.Stream => { + const requiredScope = requiredScopeForMethod(method); + return methodAuthorized(method, requiredScope) ? stream : Stream.fail(authorizationError(requiredScope)); - const requiredScopeForMethod = (method: string): AuthEnvironmentScope => { - const requiredScope = RPC_REQUIRED_SCOPE.get(method); - if (requiredScope === undefined) { - throw new Error(`RPC method ${method} has no declared authorization scope.`); - } - return requiredScope; }; const observeRpcEffect = ( method: string, effect: Effect.Effect, traceAttributes?: Readonly>, - ) => - instrumentRpcEffect( - method, - authorizeEffect(requiredScopeForMethod(method), effect), - traceAttributes, - ); + ) => instrumentRpcEffect(method, authorizeEffect(method, effect), traceAttributes); const observeRpcStream = ( method: string, stream: Stream.Stream, traceAttributes?: Readonly>, - ) => - instrumentRpcStream( - method, - authorizeStream(requiredScopeForMethod(method), stream), - traceAttributes, - ); + ) => instrumentRpcStream(method, authorizeStream(method, stream), traceAttributes); const observeRpcStreamEffect = ( method: string, effect: Effect.Effect< @@ -499,12 +583,7 @@ const makeWsRpcLayer = ( EffectContext >, traceAttributes?: Readonly>, - ) => - instrumentRpcStreamEffect( - method, - authorizeEffect(requiredScopeForMethod(method), effect), - traceAttributes, - ); + ) => instrumentRpcStreamEffect(method, authorizeEffect(method, effect), traceAttributes); const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => isOrchestrationDispatchCommandError(cause) ? cause @@ -657,32 +736,20 @@ const makeWsRpcLayer = ( }), ); case "thread.unarchived": - return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), + return toThreadUpsertedShellStreamEvent( + projectionSnapshotQuery, + event.payload.threadId, + event.sequence, ); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); } - return projectionSnapshotQuery - .getThreadShellById(ThreadId.make(event.aggregateId)) - .pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + return toThreadUpsertedShellStreamEvent( + projectionSnapshotQuery, + ThreadId.make(event.aggregateId), + event.sequence, + ); } }; @@ -1215,6 +1282,76 @@ const makeWsRpcLayer = ( "plugin.method": input.method, }, ), + [PLUGINS_WS_METHODS.sourcesList]: (_input) => + observeRpcEffect(PLUGINS_WS_METHODS.sourcesList, pluginManagement.listSources, { + "rpc.aggregate": "plugins", + }), + [PLUGINS_WS_METHODS.sourcesAdd]: (input) => + observeRpcEffect(PLUGINS_WS_METHODS.sourcesAdd, pluginManagement.addSource(input), { + "rpc.aggregate": "plugins", + }), + [PLUGINS_WS_METHODS.sourcesRemove]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.sourcesRemove, + pluginManagement.removeSource(input).pipe(Effect.as({})), + { + "rpc.aggregate": "plugins", + }, + ), + [PLUGINS_WS_METHODS.catalog]: (input) => + observeRpcEffect(PLUGINS_WS_METHODS.catalog, pluginManagement.catalog(input), { + "rpc.aggregate": "plugins", + }), + [PLUGINS_WS_METHODS.installBegin]: (input) => + observeRpcEffect(PLUGINS_WS_METHODS.installBegin, pluginManagement.beginInstall(input), { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + }), + [PLUGINS_WS_METHODS.installConfirm]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.installConfirm, + pluginManagement.confirmInstall(input.stageToken), + { "rpc.aggregate": "plugins" }, + ), + [PLUGINS_WS_METHODS.installAbort]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.installAbort, + pluginManagement.abortInstall(input.stageToken).pipe(Effect.as({})), + { "rpc.aggregate": "plugins" }, + ), + [PLUGINS_WS_METHODS.setEnabled]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.setEnabled, + pluginManagement.setEnabled(input).pipe(Effect.as({})), + { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + }, + ), + [PLUGINS_WS_METHODS.uninstall]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.uninstall, + pluginManagement.uninstall(input).pipe(Effect.as({})), + { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + }, + ), + [PLUGINS_WS_METHODS.upgradeBegin]: (input) => + observeRpcEffect(PLUGINS_WS_METHODS.upgradeBegin, pluginManagement.beginUpgrade(input), { + "rpc.aggregate": "plugins", + "plugin.id": input.pluginId, + }), + [PLUGINS_WS_METHODS.upgradeConfirm]: (input) => + observeRpcEffect( + PLUGINS_WS_METHODS.upgradeConfirm, + pluginManagement.confirmUpgrade(input.stageToken), + { "rpc.aggregate": "plugins" }, + ), + [PLUGINS_WS_METHODS.checkUpdates]: (_input) => + observeRpcEffect(PLUGINS_WS_METHODS.checkUpdates, pluginManagement.checkUpdates, { + "rpc.aggregate": "plugins", + }), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, diff --git a/apps/web/src/components/settings/SettingsSidebarNav.test.ts b/apps/web/src/components/settings/SettingsSidebarNav.test.ts index 5ef5a1f207e..9b608756806 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.test.ts +++ b/apps/web/src/components/settings/SettingsSidebarNav.test.ts @@ -10,6 +10,7 @@ describe("SettingsSidebarNav plugin entries", () => { "/settings/general", "/settings/keybindings", "/settings/providers", + "/settings/plugins", "/settings/source-control", "/settings/connections", "/settings/archived", @@ -33,6 +34,7 @@ describe("SettingsSidebarNav plugin entries", () => { "/settings/general", "/settings/keybindings", "/settings/providers", + "/settings/plugins", "/settings/source-control", "/settings/connections", "/settings/archived", diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 357fb16bf6f..a630f972780 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -29,6 +29,7 @@ export type CoreSettingsSectionPath = | "/settings/general" | "/settings/keybindings" | "/settings/providers" + | "/settings/plugins" | "/settings/source-control" | "/settings/connections" | "/settings/archived"; @@ -42,6 +43,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "General", to: "/settings/general", icon: Settings2Icon }, { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, + { label: "Plugins", to: "/settings/plugins", icon: PuzzleIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx new file mode 100644 index 00000000000..03aa1237e8a --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx @@ -0,0 +1,151 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { + PluginId, + type PluginInstallStaged, + type PluginInfo, + type PluginSourcesAddResult, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { InstalledPluginsSection } from "./PluginsSettings"; +import { + ALL_PLUGIN_SOURCES_VALUE, + abortPluginInstallConsentFlow, + addPluginSourceFlow, + beginPluginInstallConsentFlow, + confirmPluginInstallConsentFlow, + effectiveInstallSourceId, + pluginRequiresRelaunch, + removePluginSourceFlow, +} from "./PluginsSettings.logic"; + +const pluginId = PluginId.make("hello-board"); + +const plugin = (overrides: Partial = {}): PluginInfo => ({ + id: pluginId, + name: "Hello Board", + version: "1.0.0", + state: "active", + capabilities: ["database"], + hasWeb: true, + lastError: null, + ...overrides, +}); + +function commandFailure
(message: string): AtomCommandResult { + return AsyncResult.failure(Cause.fail(new Error(message))); +} + +describe("Plugins settings logic", () => { + it("detects relaunch states and selected install source", () => { + expect(pluginRequiresRelaunch(plugin({ state: "pending-remove" }))).toBe(true); + expect(pluginRequiresRelaunch(plugin({ state: "pending-upgrade" }))).toBe(true); + expect(pluginRequiresRelaunch(plugin({ state: "disabled-by-host" }))).toBe(true); + expect(pluginRequiresRelaunch(plugin({ state: "active" }))).toBe(false); + + expect(effectiveInstallSourceId(ALL_PLUGIN_SOURCES_VALUE, [{ id: "src-one" }])).toBe("src-one"); + expect( + effectiveInstallSourceId(ALL_PLUGIN_SOURCES_VALUE, [{ id: "src-one" }, { id: "src-two" }]), + ).toBeNull(); + expect(effectiveInstallSourceId("src-two", [{ id: "src-one" }])).toBe("src-two"); + }); + + it("surfaces source add and remove server errors from stubbed commands", async () => { + const addSource = vi.fn(async () => + commandFailure("Plugin sources must be HTTPS URLs."), + ); + const removeSource = vi.fn(async () => + commandFailure<{}>("Source is used by an installed plugin."), + ); + + await expect(addPluginSourceFlow({ addSource }, " http://invalid.test ")).resolves.toEqual({ + ok: false, + error: "Plugin sources must be HTTPS URLs.", + }); + expect(addSource).toHaveBeenCalledWith({ url: "http://invalid.test" }); + + await expect(removePluginSourceFlow({ removeSource }, "src-used")).resolves.toEqual({ + ok: false, + error: "Source is used by an installed plugin.", + }); + expect(removeSource).toHaveBeenCalledWith({ sourceId: "src-used" }); + }); + + it("runs install consent begin, confirm, and abort through stubbed commands", async () => { + const staged: PluginInstallStaged = { + stageToken: "stage-1", + manifest: { + id: pluginId, + name: "Hello Board", + version: "1.0.0", + hostApi: "^1.0.0", + capabilities: ["database"], + entries: { server: "server/index.js", web: "web/index.js" }, + }, + capabilityDescriptions: { + database: "Read and write plugin tables in the local database.", + }, + }; + const beginInstall = vi.fn(async () => AsyncResult.success(staged)); + const confirmInstall = vi.fn(async () => AsyncResult.success({ plugin: plugin() })); + const abortInstall = vi.fn(async () => AsyncResult.success({})); + + const begin = await beginPluginInstallConsentFlow( + { beginInstall }, + { sourceId: "src-local", pluginId, version: "1.0.0" }, + ); + expect(begin).toEqual({ ok: true, value: staged }); + expect(beginInstall).toHaveBeenCalledWith({ + sourceId: "src-local", + pluginId, + version: "1.0.0", + }); + + await expect( + confirmPluginInstallConsentFlow({ confirmInstall }, { stageToken: "stage-1" }), + ).resolves.toEqual({ ok: true, value: { plugin: plugin() } }); + expect(confirmInstall).toHaveBeenCalledWith({ stageToken: "stage-1" }); + + await expect( + abortPluginInstallConsentFlow({ abortInstall }, { stageToken: "stage-1" }), + ).resolves.toEqual({ ok: true, value: {} }); + expect(abortInstall).toHaveBeenCalledWith({ stageToken: "stage-1" }); + }); +}); + +describe("InstalledPluginsSection", () => { + it("renders installed plugins with failed and pending state details", () => { + const html = renderToStaticMarkup( + {}} + onCheckUpdates={() => {}} + onBeginUpgrade={() => {}} + onRequestUninstall={() => {}} + />, + ); + + expect(html).toContain("Hello Board"); + expect(html).toContain("Failed"); + expect(html).toContain("activation boom"); + expect(html).toContain("Pending removal"); + expect(html).toContain("Relaunch to apply"); + expect(html).toContain("Database"); + }); +}); diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts b/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts new file mode 100644 index 00000000000..315076cf22d --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts @@ -0,0 +1,188 @@ +import type { + MarketplaceVersion, + PluginInstallBeginInput, + PluginInstallConfirmInput, + PluginInstallConfirmResult, + PluginInstallStaged, + PluginInfo, + PluginSourcesAddInput, + PluginSourcesAddResult, + PluginSourcesRemoveInput, + PluginState, +} from "@t3tools/contracts"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult } from "effect/unstable/reactivity"; + +export const ALL_PLUGIN_SOURCES_VALUE = "__all__"; + +const RELAUNCH_STATES = new Set([ + "pending-remove", + "pending-upgrade", + "disabled", + "disabled-by-host", +]); + +export function pluginRequiresRelaunch(plugin: Pick): boolean { + return RELAUNCH_STATES.has(plugin.state); +} + +export function pluginStateLabel(state: PluginState): string { + switch (state) { + case "active": + return "Active"; + case "pending-remove": + return "Pending removal"; + case "pending-upgrade": + return "Pending upgrade"; + case "failed": + return "Failed"; + case "disabled": + return "Disabled"; + case "disabled-by-host": + return "Disabled by host"; + } +} + +export function pluginStateBadgeVariant( + state: PluginState, +): "success" | "warning" | "error" | "secondary" | "outline" { + switch (state) { + case "active": + return "success"; + case "failed": + return "error"; + case "pending-remove": + case "pending-upgrade": + case "disabled-by-host": + return "warning"; + case "disabled": + return "secondary"; + } +} + +export function latestMarketplaceVersion( + versions: ReadonlyArray, +): MarketplaceVersion | null { + return versions[0] ?? null; +} + +export function effectiveInstallSourceId( + selectedSourceId: string, + sources: ReadonlyArray<{ readonly id: string }>, +): string | null { + if (selectedSourceId !== ALL_PLUGIN_SOURCES_VALUE) { + return selectedSourceId; + } + return sources.length === 1 ? (sources[0]?.id ?? null) : null; +} + +export function humanErrorMessage(error: unknown, fallback = "The operation failed."): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim().length > 0 + ) { + return error.message; + } + return fallback; +} + +export function commandFailureMessage( + result: AtomCommandResult, + fallback = "The operation failed.", +): string | null { + if (AsyncResult.isSuccess(result)) { + return null; + } + return humanErrorMessage(squashAtomCommandFailure(result), fallback); +} + +export type PluginFlowResult = + | { readonly ok: true; readonly value: A } + | { readonly ok: false; readonly error: string }; + +function commandFlowResult( + result: AtomCommandResult, + fallback: string, +): PluginFlowResult { + const failure = commandFailureMessage(result, fallback); + if (failure !== null) { + return { ok: false, error: failure }; + } + if (AsyncResult.isSuccess(result)) { + return { ok: true, value: result.value }; + } + return { ok: false, error: fallback }; +} + +export async function addPluginSourceFlow( + commands: { + readonly addSource: ( + input: PluginSourcesAddInput, + ) => Promise>; + }, + url: string, +): Promise> { + return commandFlowResult( + await commands.addSource({ url: url.trim() }), + "Could not add plugin source.", + ); +} + +export async function removePluginSourceFlow( + commands: { + readonly removeSource: ( + input: PluginSourcesRemoveInput, + ) => Promise>; + }, + sourceId: string, +): Promise> { + return commandFlowResult( + await commands.removeSource({ sourceId }), + "Could not remove plugin source.", + ); +} + +export async function beginPluginInstallConsentFlow( + commands: { + readonly beginInstall: ( + input: PluginInstallBeginInput, + ) => Promise>; + }, + input: PluginInstallBeginInput, +): Promise> { + return commandFlowResult(await commands.beginInstall(input), "Could not stage plugin install."); +} + +export async function confirmPluginInstallConsentFlow( + commands: { + readonly confirmInstall: ( + input: PluginInstallConfirmInput, + ) => Promise>; + }, + input: PluginInstallConfirmInput, +): Promise> { + return commandFlowResult(await commands.confirmInstall(input), "Could not install plugin."); +} + +export async function abortPluginInstallConsentFlow( + commands: { + readonly abortInstall: ( + input: PluginInstallConfirmInput, + ) => Promise>; + }, + input: PluginInstallConfirmInput, +): Promise> { + return commandFlowResult(await commands.abortInstall(input), "Could not cancel plugin install."); +} diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.tsx new file mode 100644 index 00000000000..b9ec1299c2d --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginsSettings.tsx @@ -0,0 +1,1027 @@ +import { + AlertTriangleIcon, + BoxIcon, + DatabaseIcon, + DownloadIcon, + PlugIcon, + RefreshCwIcon, + RotateCcwIcon, + ShieldCheckIcon, + Trash2Icon, +} from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; +import type { + MarketplaceEntry, + MarketplaceVersion, + PluginCatalogInput, + PluginCatalogResult, + PluginCheckUpdatesResult, + PluginId, + PluginInfo, + PluginInstallBeginInput, + PluginInstallConfirmInput, + PluginInstallConfirmResult, + PluginInstallStaged, + PluginSetEnabledInput, + PluginSource, + PluginSourcesAddInput, + PluginSourcesAddResult, + PluginSourcesListResult, + PluginSourcesRemoveInput, + PluginUninstallInput, + PluginUpdateInfo, + PluginUpgradeBeginInput, + PluginUpgradeConfirmInput, +} from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; + +import { + abortPluginInstallCommand, + addPluginSourceCommand, + beginPluginInstallCommand, + beginPluginUpgradeCommand, + checkPluginUpdatesCommand, + confirmPluginInstallCommand, + confirmPluginUpgradeCommand, + getPluginCatalogCommand, + listPluginSourcesCommand, + pluginListAtom, + removePluginSourceCommand, + setPluginEnabledCommand, + uninstallPluginCommand, +} from "~/state/plugins"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { Alert, AlertDescription, AlertTitle } from "../../ui/alert"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../../ui/alert-dialog"; +import { Badge } from "../../ui/badge"; +import { Button } from "../../ui/button"; +import { Checkbox } from "../../ui/checkbox"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../../ui/dialog"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../../ui/empty"; +import { Input } from "../../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../../ui/select"; +import { Spinner } from "../../ui/spinner"; +import { Switch } from "../../ui/switch"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "../settingsLayout"; +import { + ALL_PLUGIN_SOURCES_VALUE, + abortPluginInstallConsentFlow, + addPluginSourceFlow, + beginPluginInstallConsentFlow, + commandFailureMessage, + confirmPluginInstallConsentFlow, + effectiveInstallSourceId, + latestMarketplaceVersion, + pluginRequiresRelaunch, + pluginStateBadgeVariant, + pluginStateLabel, + removePluginSourceFlow, +} from "./PluginsSettings.logic"; + +type InstallIntent = "install" | "upgrade"; + +interface StagedPluginAction { + readonly intent: InstallIntent; + readonly staged: PluginInstallStaged; + readonly entryName: string; +} + +interface UninstallTarget { + readonly plugin: PluginInfo; + readonly removeData: boolean; +} + +interface PluginSettingsCommands { + readonly listSources: ( + input: void, + ) => Promise>; + readonly addSource: ( + input: PluginSourcesAddInput, + ) => Promise>; + readonly removeSource: ( + input: PluginSourcesRemoveInput, + ) => Promise>; + readonly catalog: ( + input: PluginCatalogInput | void, + ) => Promise>; + readonly beginInstall: ( + input: PluginInstallBeginInput, + ) => Promise>; + readonly confirmInstall: ( + input: PluginInstallConfirmInput, + ) => Promise>; + readonly abortInstall: ( + input: PluginInstallConfirmInput, + ) => Promise>; + readonly setEnabled: (input: PluginSetEnabledInput) => Promise>; + readonly uninstall: (input: PluginUninstallInput) => Promise>; + readonly beginUpgrade: ( + input: PluginUpgradeBeginInput, + ) => Promise>; + readonly confirmUpgrade: ( + input: PluginUpgradeConfirmInput, + ) => Promise>; + readonly checkUpdates: ( + input: void, + ) => Promise>; +} + +function usePluginSettingsCommands(): PluginSettingsCommands { + const listSources = useAtomCommand(listPluginSourcesCommand, { reportFailure: false }); + const addSource = useAtomCommand(addPluginSourceCommand, { reportFailure: false }); + const removeSource = useAtomCommand(removePluginSourceCommand, { reportFailure: false }); + const catalog = useAtomCommand(getPluginCatalogCommand, { reportFailure: false }); + const beginInstall = useAtomCommand(beginPluginInstallCommand, { reportFailure: false }); + const confirmInstall = useAtomCommand(confirmPluginInstallCommand, { reportFailure: false }); + const abortInstall = useAtomCommand(abortPluginInstallCommand, { reportFailure: false }); + const setEnabled = useAtomCommand(setPluginEnabledCommand, { reportFailure: false }); + const uninstall = useAtomCommand(uninstallPluginCommand, { reportFailure: false }); + const beginUpgrade = useAtomCommand(beginPluginUpgradeCommand, { reportFailure: false }); + const confirmUpgrade = useAtomCommand(confirmPluginUpgradeCommand, { reportFailure: false }); + const checkUpdates = useAtomCommand(checkPluginUpdatesCommand, { reportFailure: false }); + + return useMemo( + () => ({ + listSources, + addSource, + removeSource, + catalog, + beginInstall, + confirmInstall, + abortInstall, + setEnabled, + uninstall, + beginUpgrade, + confirmUpgrade, + checkUpdates, + }), + [ + abortInstall, + addSource, + beginInstall, + beginUpgrade, + catalog, + checkUpdates, + confirmInstall, + confirmUpgrade, + listSources, + removeSource, + setEnabled, + uninstall, + ], + ); +} + +function updateMapFromResult(result: PluginCheckUpdatesResult): Map { + return new Map(result.updates.map((update) => [update.pluginId, update])); +} + +function capabilityLabel(capability: string): string { + switch (capability) { + case "agents": + return "Agents"; + case "vcs": + return "VCS"; + case "terminals": + return "Terminals"; + case "database": + return "Database"; + case "projections.read": + return "Projections read"; + case "environments.read": + return "Environments read"; + case "secrets": + return "Secrets"; + case "http": + return "HTTP"; + case "sourceControl": + return "Source control"; + case "textGeneration": + return "Text generation"; + default: + return capability; + } +} + +function CapabilityBadges({ capabilities }: { readonly capabilities: ReadonlyArray }) { + if (capabilities.length === 0) { + return No declared capabilities; + } + + return ( +
+ {capabilities.map((capability) => ( + + {capabilityLabel(capability)} + + ))} +
+ ); +} + +function SectionError({ message }: { readonly message: string | null }) { + if (!message) return null; + return ( + + + Plugin operation failed + {message} + + ); +} + +function RelaunchBanner({ state }: { readonly state: PluginInfo["state"] }) { + return ( + + + Relaunch to apply + + This plugin is {pluginStateLabel(state).toLowerCase()}; restart the app to finish applying + the change. + + + ); +} + +function InstalledPluginRow({ + plugin, + update, + busy, + onToggleEnabled, + onCheckUpdates, + onBeginUpgrade, + onRequestUninstall, +}: { + readonly plugin: PluginInfo; + readonly update: PluginUpdateInfo | undefined; + readonly busy: boolean; + readonly onToggleEnabled: (plugin: PluginInfo, enabled: boolean) => void; + readonly onCheckUpdates: () => void; + readonly onBeginUpgrade: (plugin: PluginInfo, version: string) => void; + readonly onRequestUninstall: (plugin: PluginInfo) => void; +}) { + const checked = plugin.state === "active" || plugin.state === "pending-upgrade"; + const canToggle = plugin.state === "active" || plugin.state === "disabled"; + const stateBadge = ( + + {pluginStateLabel(plugin.state)} + + ); + + return ( + + {plugin.name} + {stateBadge} + + } + description={`${plugin.id} · ${plugin.version}`} + status={} + control={ +
+ onToggleEnabled(plugin, enabled)} + /> + + {update ? ( + + ) : null} + +
+ } + > + {plugin.lastError ? ( + + + Activation failed + {plugin.lastError} + + ) : null} + {pluginRequiresRelaunch(plugin) ? : null} +
+ ); +} + +export function InstalledPluginsSection({ + plugins, + updates, + busy, + error, + onToggleEnabled, + onCheckUpdates, + onBeginUpgrade, + onRequestUninstall, +}: { + readonly plugins: ReadonlyArray; + readonly updates: ReadonlyMap; + readonly busy: boolean; + readonly error: string | null; + readonly onToggleEnabled: (plugin: PluginInfo, enabled: boolean) => void; + readonly onCheckUpdates: () => void; + readonly onBeginUpgrade: (plugin: PluginInfo, version: string) => void; + readonly onRequestUninstall: (plugin: PluginInfo) => void; +}) { + return ( + } + headerAction={ + + } + > +
+ +
+ {plugins.length === 0 ? ( + + + + + + No plugins installed + Installed plugins will appear here. + + + ) : ( + plugins.map((plugin) => ( + + )) + )} +
+ ); +} + +function SourcesSection({ + sources, + selectedSourceId, + addUrl, + busy, + error, + onAddUrlChange, + onSelectedSourceChange, + onAddSource, + onRemoveSource, +}: { + readonly sources: ReadonlyArray; + readonly selectedSourceId: string; + readonly addUrl: string; + readonly busy: boolean; + readonly error: string | null; + readonly onAddUrlChange: (value: string) => void; + readonly onSelectedSourceChange: (value: string) => void; + readonly onAddSource: () => void; + readonly onRemoveSource: (sourceId: string) => void; +}) { + const submit = (event: FormEvent) => { + event.preventDefault(); + onAddSource(); + }; + + return ( + }> +
+ +
+ onAddUrlChange(event.currentTarget.value)} + /> + +
+
+ + {sources.length === 0 ? ( +

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

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

{source.url}

+

{source.id}

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

+ This plugin does not request host capabilities. +

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

{capabilityLabel(capability)}

+

{description}

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

Hello Board

+

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

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

+ No notes yet. +

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

{note.body}

+ +
+ )) + )} +
+
+
+ ); +} + +export default defineWebPlugin({ + register: (ctx) => { + ctx.registerRoute({ + path: "notes", + component: () => , + }); + ctx.registerSidebarSection({ + id: "hello-board", + title: "Hello Board", + render: ({ routeBasePath }) => ( + + Notes + + ), + }); + }, +}); diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 75131ee8dd3..9c145604de9 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -29,10 +29,17 @@ import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { EnvironmentRpcRequestObserver, + addPluginSource, + beginPluginInstall, callPlugin, + checkPluginUpdates, + confirmPluginInstall, + getPluginCatalog, listPlugins, + listPluginSources, request, runStream, + setPluginEnabled, subscribe, subscribePlugin, } from "./client.ts"; @@ -137,6 +144,113 @@ describe("environment RPC", () => { }), ); + it.effect("calls plugin management helpers with typed payloads", () => + Effect.gen(function* () { + const pluginId = PluginId.make("test-plugin"); + const observedInputs: Array = []; + const client = { + [PLUGINS_WS_METHODS.sourcesList]: () => Effect.succeed({ sources: [] }), + [PLUGINS_WS_METHODS.sourcesAdd]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ + source: { + id: "src-test", + url: "https://example.test/marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }, + }); + }, + [PLUGINS_WS_METHODS.catalog]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ entries: [], errors: [] }); + }, + [PLUGINS_WS_METHODS.installBegin]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ + stageToken: "stage-token", + manifest: { + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + hostApi: "^1.0.0", + capabilities: [], + entries: { web: "web/index.js" }, + }, + capabilityDescriptions: {}, + }); + }, + [PLUGINS_WS_METHODS.installConfirm]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({ + plugin: { + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + state: "active", + capabilities: [], + hasWeb: true, + hasStyles: false, + lastError: null, + }, + }); + }, + [PLUGINS_WS_METHODS.setEnabled]: (input: unknown) => { + observedInputs.push(input); + return Effect.succeed({}); + }, + [PLUGINS_WS_METHODS.checkUpdates]: () => Effect.succeed({ updates: [] }), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const provide = Effect.provideService( + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ); + + expect(yield* listPluginSources().pipe(provide)).toEqual({ sources: [] }); + expect( + yield* addPluginSource({ url: "https://example.test/marketplace.json" }).pipe(provide), + ).toEqual({ + source: { + id: "src-test", + url: "https://example.test/marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }, + }); + expect(yield* getPluginCatalog({ sourceId: "src-test" }).pipe(provide)).toEqual({ + entries: [], + errors: [], + }); + expect( + yield* beginPluginInstall({ sourceId: "src-test", pluginId, version: "1.0.0" }).pipe( + provide, + ), + ).toMatchObject({ stageToken: "stage-token" }); + expect(yield* confirmPluginInstall({ stageToken: "stage-token" }).pipe(provide)).toEqual({ + plugin: { + id: pluginId, + name: "Test Plugin", + version: "1.0.0", + state: "active", + capabilities: [], + hasWeb: true, + hasStyles: false, + lastError: null, + }, + }); + yield* setPluginEnabled({ pluginId, enabled: false }).pipe(provide); + expect(yield* checkPluginUpdates().pipe(provide)).toEqual({ updates: [] }); + + expect(observedInputs).toEqual([ + { url: "https://example.test/marketplace.json" }, + { sourceId: "src-test" }, + { sourceId: "src-test", pluginId, version: "1.0.0" }, + { stageToken: "stage-token" }, + { pluginId, enabled: false }, + ]); + }), + ); + it.effect("calls plugin methods with optional payloads", () => Effect.gen(function* () { const pluginId = PluginId.make("test-plugin"); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c676ddeab7d..652fc09a563 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -2,7 +2,16 @@ import { ORCHESTRATION_WS_METHODS, PLUGINS_WS_METHODS, WS_METHODS, + type PluginCatalogInput, + type PluginInstallBeginInput, + type PluginInstallConfirmInput, type PluginId, + type PluginSetEnabledInput, + type PluginSourcesAddInput, + type PluginSourcesRemoveInput, + type PluginUninstallInput, + type PluginUpgradeBeginInput, + type PluginUpgradeConfirmInput, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -251,6 +260,74 @@ export const listPlugins = Effect.fn("EnvironmentRpc.listPlugins")(function* () return yield* request(PLUGINS_WS_METHODS.list, {}); }); +export const listPluginSources = Effect.fn("EnvironmentRpc.listPluginSources")(function* () { + return yield* request(PLUGINS_WS_METHODS.sourcesList, {}); +}); + +export const addPluginSource = Effect.fn("EnvironmentRpc.addPluginSource")(function* ( + input: PluginSourcesAddInput, +) { + return yield* request(PLUGINS_WS_METHODS.sourcesAdd, input); +}); + +export const removePluginSource = Effect.fn("EnvironmentRpc.removePluginSource")(function* ( + input: PluginSourcesRemoveInput, +) { + return yield* request(PLUGINS_WS_METHODS.sourcesRemove, input); +}); + +export const getPluginCatalog = Effect.fn("EnvironmentRpc.getPluginCatalog")(function* ( + input: PluginCatalogInput = {}, +) { + return yield* request(PLUGINS_WS_METHODS.catalog, input); +}); + +export const beginPluginInstall = Effect.fn("EnvironmentRpc.beginPluginInstall")(function* ( + input: PluginInstallBeginInput, +) { + return yield* request(PLUGINS_WS_METHODS.installBegin, input); +}); + +export const confirmPluginInstall = Effect.fn("EnvironmentRpc.confirmPluginInstall")(function* ( + input: PluginInstallConfirmInput, +) { + return yield* request(PLUGINS_WS_METHODS.installConfirm, input); +}); + +export const abortPluginInstall = Effect.fn("EnvironmentRpc.abortPluginInstall")(function* ( + input: PluginInstallConfirmInput, +) { + return yield* request(PLUGINS_WS_METHODS.installAbort, input); +}); + +export const setPluginEnabled = Effect.fn("EnvironmentRpc.setPluginEnabled")(function* ( + input: PluginSetEnabledInput, +) { + return yield* request(PLUGINS_WS_METHODS.setEnabled, input); +}); + +export const uninstallPlugin = Effect.fn("EnvironmentRpc.uninstallPlugin")(function* ( + input: PluginUninstallInput, +) { + return yield* request(PLUGINS_WS_METHODS.uninstall, input); +}); + +export const beginPluginUpgrade = Effect.fn("EnvironmentRpc.beginPluginUpgrade")(function* ( + input: PluginUpgradeBeginInput, +) { + return yield* request(PLUGINS_WS_METHODS.upgradeBegin, input); +}); + +export const confirmPluginUpgrade = Effect.fn("EnvironmentRpc.confirmPluginUpgrade")(function* ( + input: PluginUpgradeConfirmInput, +) { + return yield* request(PLUGINS_WS_METHODS.upgradeConfirm, input); +}); + +export const checkPluginUpdates = Effect.fn("EnvironmentRpc.checkPluginUpdates")(function* () { + return yield* request(PLUGINS_WS_METHODS.checkUpdates, {}); +}); + export const callPlugin = Effect.fn("EnvironmentRpc.callPlugin")(function* ( pluginId: PluginId, method: string, diff --git a/packages/contracts/src/plugin.test.ts b/packages/contracts/src/plugin.test.ts index 889c5cbf226..45bd75f8d41 100644 --- a/packages/contracts/src/plugin.test.ts +++ b/packages/contracts/src/plugin.test.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { HOST_API_VERSION, PluginLockfile, PluginManifest, hostApiSatisfies } from "./plugin.ts"; +import { + HOST_API_VERSION, + MarketplaceEntry, + PluginInstallStaged, + PluginLockfile, + PluginManifest, + hostApiSatisfies, +} from "./plugin.ts"; const decodeManifest = Schema.decodeUnknownSync(PluginManifest); const decodeLockfile = Schema.decodeUnknownSync(PluginLockfile); const encodeLockfile = Schema.encodeSync(PluginLockfile); +const decodeMarketplaceEntry = Schema.decodeUnknownSync(MarketplaceEntry); +const decodeInstallStaged = Schema.decodeUnknownSync(PluginInstallStaged); const minimalManifest = { id: "test-plugin", @@ -123,3 +132,44 @@ describe("PluginLockfile", () => { expect(encodeLockfile(decoded)).toEqual(decoded); }); }); + +describe("MarketplaceEntry", () => { + it("decodes marketplace plugin versions", () => { + const decoded = decodeMarketplaceEntry({ + id: "test-plugin", + name: "Test Plugin", + description: "Adds test plugin behavior.", + capabilities: ["agents"], + versions: [ + { + version: "1.0.0", + tarball: "https://example.test/plugin.tgz", + sha256: "a".repeat(64), + hostApi: "^1.0.0", + minAppVersion: "0.0.1", + publishedAt: "2026-07-03T00:00:00.000Z", + }, + ], + }); + + expect(decoded.id).toBe("test-plugin"); + expect(decoded.versions[0]?.sha256).toBe("a".repeat(64)); + }); +}); + +describe("PluginInstallStaged", () => { + it("decodes staged install metadata with capability descriptions", () => { + const decoded = decodeInstallStaged({ + stageToken: "token", + manifest: { + ...minimalManifest, + capabilities: ["agents"], + }, + capabilityDescriptions: { + agents: "Run AI agents", + }, + }); + + expect(decoded.capabilityDescriptions.agents).toBe("Run AI agents"); + }); +}); diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts index 8f531894341..4de903526e7 100644 --- a/packages/contracts/src/plugin.ts +++ b/packages/contracts/src/plugin.ts @@ -32,6 +32,7 @@ export type PluginCapability = typeof PluginCapability.Type; const SemverString = TrimmedNonEmptyString.check(Schema.isPattern(SEMVER_PATTERN)); const HostApiRange = TrimmedNonEmptyString.check(Schema.isPattern(HOST_API_RANGE_PATTERN)); const OptionalUrl = Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(2048))); +const Sha256Hex = TrimmedNonEmptyString.check(Schema.isPattern(/^[a-f0-9]{64}$/i)); const RelativeEntryPath = TrimmedNonEmptyString.check( Schema.makeFilter((entryPath) => { @@ -143,6 +144,159 @@ export const PluginListResult = Schema.Struct({ }); export type PluginListResult = typeof PluginListResult.Type; +export const PluginSource = Schema.Struct({ + id: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + addedAt: IsoDateTime, +}); +export type PluginSource = typeof PluginSource.Type; + +export const MarketplaceVersion = Schema.Struct({ + version: SemverString, + tarball: TrimmedNonEmptyString, + sha256: Sha256Hex, + hostApi: HostApiRange, + minAppVersion: Schema.optionalKey(SemverString), + publishedAt: IsoDateTime, +}); +export type MarketplaceVersion = typeof MarketplaceVersion.Type; + +export const MarketplaceEntry = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + description: TrimmedString.check(Schema.isMaxLength(500)), + author: Schema.optionalKey(PluginAuthor), + capabilities: Schema.Array(PluginCapability), + versions: Schema.Array(MarketplaceVersion), +}); +export type MarketplaceEntry = typeof MarketplaceEntry.Type; + +export const PluginInstallStaged = Schema.Struct({ + stageToken: TrimmedNonEmptyString, + manifest: PluginManifest, + capabilityDescriptions: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), +}); +export type PluginInstallStaged = typeof PluginInstallStaged.Type; + +export const PluginSourceError = Schema.Struct({ + sourceId: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, +}); +export type PluginSourceError = typeof PluginSourceError.Type; + +export const PluginSourcesListResult = Schema.Struct({ + sources: Schema.Array(PluginSource), +}); +export type PluginSourcesListResult = typeof PluginSourcesListResult.Type; + +export const PluginSourcesAddInput = Schema.Struct({ + url: TrimmedNonEmptyString, +}); +export type PluginSourcesAddInput = typeof PluginSourcesAddInput.Type; + +export const PluginSourcesAddResult = Schema.Struct({ + source: PluginSource, +}); +export type PluginSourcesAddResult = typeof PluginSourcesAddResult.Type; + +export const PluginSourcesRemoveInput = Schema.Struct({ + sourceId: TrimmedNonEmptyString, +}); +export type PluginSourcesRemoveInput = typeof PluginSourcesRemoveInput.Type; + +export const PluginCatalogInput = Schema.Struct({ + sourceId: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type PluginCatalogInput = typeof PluginCatalogInput.Type; + +export const PluginCatalogResult = Schema.Struct({ + entries: Schema.Array(MarketplaceEntry), + errors: Schema.Array(PluginSourceError), +}); +export type PluginCatalogResult = typeof PluginCatalogResult.Type; + +export const PluginInstallBeginInput = Schema.Struct({ + sourceId: TrimmedNonEmptyString, + pluginId: PluginId, + version: SemverString, +}); +export type PluginInstallBeginInput = typeof PluginInstallBeginInput.Type; + +export const PluginInstallConfirmInput = Schema.Struct({ + stageToken: TrimmedNonEmptyString, +}); +export type PluginInstallConfirmInput = typeof PluginInstallConfirmInput.Type; + +export const PluginInstallConfirmResult = Schema.Struct({ + plugin: PluginInfo, +}); +export type PluginInstallConfirmResult = typeof PluginInstallConfirmResult.Type; + +export const PluginInstallAbortInput = PluginInstallConfirmInput; +export type PluginInstallAbortInput = typeof PluginInstallAbortInput.Type; + +export const PluginSetEnabledInput = Schema.Struct({ + pluginId: PluginId, + enabled: Schema.Boolean, +}); +export type PluginSetEnabledInput = typeof PluginSetEnabledInput.Type; + +export const PluginUninstallInput = Schema.Struct({ + pluginId: PluginId, + removeData: Schema.Boolean, +}); +export type PluginUninstallInput = typeof PluginUninstallInput.Type; + +export const PluginUpgradeBeginInput = Schema.Struct({ + pluginId: PluginId, + version: SemverString, +}); +export type PluginUpgradeBeginInput = typeof PluginUpgradeBeginInput.Type; + +export const PluginUpgradeConfirmInput = PluginInstallConfirmInput; +export type PluginUpgradeConfirmInput = typeof PluginUpgradeConfirmInput.Type; + +export const PluginUpgradeConfirmResult = Schema.Struct({ + plugin: PluginInfo, +}); +export type PluginUpgradeConfirmResult = typeof PluginUpgradeConfirmResult.Type; + +export const PluginUpdateInfo = Schema.Struct({ + pluginId: PluginId, + currentVersion: SemverString, + latestVersion: SemverString, +}); +export type PluginUpdateInfo = typeof PluginUpdateInfo.Type; + +export const PluginCheckUpdatesResult = Schema.Struct({ + updates: Schema.Array(PluginUpdateInfo), +}); +export type PluginCheckUpdatesResult = typeof PluginCheckUpdatesResult.Type; + +export class PluginManagementError extends Schema.TaggedErrorClass()( + "PluginManagementError", + { + code: Schema.Literals([ + "invalid-source", + "source-not-found", + "catalog-fetch-failed", + "plugin-not-found", + "version-not-found", + "download-failed", + "checksum-mismatch", + "extract-failed", + "manifest-invalid", + "stage-not-found", + "filesystem", + "lockfile", + "activation-failed", + ]), + message: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) {} + export const PluginMethodInput = Schema.Struct({ pluginId: PluginId, method: TrimmedNonEmptyString, @@ -154,6 +308,18 @@ export const PLUGINS_WS_METHODS = { list: "plugins.list", call: "plugins.call", subscribe: "plugins.subscribe", + sourcesList: "plugins.sources.list", + sourcesAdd: "plugins.sources.add", + sourcesRemove: "plugins.sources.remove", + catalog: "plugins.catalog", + installBegin: "plugins.install.begin", + installConfirm: "plugins.install.confirm", + installAbort: "plugins.install.abort", + setEnabled: "plugins.setEnabled", + uninstall: "plugins.uninstall", + upgradeBegin: "plugins.upgrade.begin", + upgradeConfirm: "plugins.upgrade.confirm", + checkUpdates: "plugins.checkUpdates", } as const; const LockfileSource = Schema.Struct({ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 03d22aaff9b..e4d81e93ae4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -145,9 +145,26 @@ import { import { VcsError } from "./vcs.ts"; import { PLUGINS_WS_METHODS, + PluginCatalogInput, + PluginCatalogResult, + PluginCheckUpdatesResult, + PluginInstallBeginInput, + PluginInstallConfirmInput, + PluginInstallConfirmResult, + PluginInstallStaged, PluginListResult, + PluginManagementError, PluginMethodInput, PluginRpcError, + PluginSetEnabledInput, + PluginSourcesAddInput, + PluginSourcesAddResult, + PluginSourcesListResult, + PluginSourcesRemoveInput, + PluginUninstallInput, + PluginUpgradeBeginInput, + PluginUpgradeConfirmInput, + PluginUpgradeConfirmResult, } from "./plugin.ts"; export const WS_METHODS = { @@ -228,6 +245,18 @@ export const WS_METHODS = { pluginsList: PLUGINS_WS_METHODS.list, pluginsCall: PLUGINS_WS_METHODS.call, pluginsSubscribe: PLUGINS_WS_METHODS.subscribe, + pluginsSourcesList: PLUGINS_WS_METHODS.sourcesList, + pluginsSourcesAdd: PLUGINS_WS_METHODS.sourcesAdd, + pluginsSourcesRemove: PLUGINS_WS_METHODS.sourcesRemove, + pluginsCatalog: PLUGINS_WS_METHODS.catalog, + pluginsInstallBegin: PLUGINS_WS_METHODS.installBegin, + pluginsInstallConfirm: PLUGINS_WS_METHODS.installConfirm, + pluginsInstallAbort: PLUGINS_WS_METHODS.installAbort, + pluginsSetEnabled: PLUGINS_WS_METHODS.setEnabled, + pluginsUninstall: PLUGINS_WS_METHODS.uninstall, + pluginsUpgradeBegin: PLUGINS_WS_METHODS.upgradeBegin, + pluginsUpgradeConfirm: PLUGINS_WS_METHODS.upgradeConfirm, + pluginsCheckUpdates: PLUGINS_WS_METHODS.checkUpdates, // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", @@ -711,6 +740,78 @@ export const WsPluginsSubscribeRpc = Rpc.make(PLUGINS_WS_METHODS.subscribe, { stream: true, }); +export const WsPluginsSourcesListRpc = Rpc.make(PLUGINS_WS_METHODS.sourcesList, { + payload: Schema.Struct({}), + success: PluginSourcesListResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsSourcesAddRpc = Rpc.make(PLUGINS_WS_METHODS.sourcesAdd, { + payload: PluginSourcesAddInput, + success: PluginSourcesAddResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsSourcesRemoveRpc = Rpc.make(PLUGINS_WS_METHODS.sourcesRemove, { + payload: PluginSourcesRemoveInput, + success: Schema.Struct({}), + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsCatalogRpc = Rpc.make(PLUGINS_WS_METHODS.catalog, { + payload: PluginCatalogInput, + success: PluginCatalogResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsInstallBeginRpc = Rpc.make(PLUGINS_WS_METHODS.installBegin, { + payload: PluginInstallBeginInput, + success: PluginInstallStaged, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsInstallConfirmRpc = Rpc.make(PLUGINS_WS_METHODS.installConfirm, { + payload: PluginInstallConfirmInput, + success: PluginInstallConfirmResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsInstallAbortRpc = Rpc.make(PLUGINS_WS_METHODS.installAbort, { + payload: PluginInstallConfirmInput, + success: Schema.Struct({}), + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsSetEnabledRpc = Rpc.make(PLUGINS_WS_METHODS.setEnabled, { + payload: PluginSetEnabledInput, + success: Schema.Struct({}), + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsUninstallRpc = Rpc.make(PLUGINS_WS_METHODS.uninstall, { + payload: PluginUninstallInput, + success: Schema.Struct({}), + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsUpgradeBeginRpc = Rpc.make(PLUGINS_WS_METHODS.upgradeBegin, { + payload: PluginUpgradeBeginInput, + success: PluginInstallStaged, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsUpgradeConfirmRpc = Rpc.make(PLUGINS_WS_METHODS.upgradeConfirm, { + payload: PluginUpgradeConfirmInput, + success: PluginUpgradeConfirmResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + +export const WsPluginsCheckUpdatesRpc = Rpc.make(PLUGINS_WS_METHODS.checkUpdates, { + payload: Schema.Struct({}), + success: PluginCheckUpdatesResult, + error: Schema.Union([PluginManagementError, EnvironmentAuthorizationError]), +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, @@ -776,6 +877,18 @@ export const WsRpcGroup = RpcGroup.make( WsPluginsListRpc, WsPluginsCallRpc, WsPluginsSubscribeRpc, + WsPluginsSourcesListRpc, + WsPluginsSourcesAddRpc, + WsPluginsSourcesRemoveRpc, + WsPluginsCatalogRpc, + WsPluginsInstallBeginRpc, + WsPluginsInstallConfirmRpc, + WsPluginsInstallAbortRpc, + WsPluginsSetEnabledRpc, + WsPluginsUninstallRpc, + WsPluginsUpgradeBeginRpc, + WsPluginsUpgradeConfirmRpc, + WsPluginsCheckUpdatesRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed266e9594d..20e0d8d4792 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -663,6 +663,31 @@ 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) + fixtures/hello-board: + dependencies: + '@effect/atom-react': + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0) + '@t3tools/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + '@t3tools/plugin-sdk-web': + specifier: workspace:* + version: link:../../packages/plugin-sdk-web + effect: + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + react: + specifier: 19.2.6 + version: 19.2.6 + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + '@types/react': + specifier: ~19.2.14 + version: 19.2.16 + infra/relay: dependencies: '@clerk/backend': @@ -821,9 +846,6 @@ importers: '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0) - '@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/client-runtime': specifier: workspace:* version: link:../client-runtime diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 25e6fd2889e..9ff68983803 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - apps/* + - fixtures/* - infra/* - oxlint-plugin-t3code - packages/* From 4d1a78b0377528a52bdde6675d462733a797a284 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sun, 5 Jul 2026 21:15:40 -0400 Subject: [PATCH 9/9] Add filesystem + httpClient capabilities and cross-cutting hardening Filesystem (workspace-grant-scoped) and httpClient (SSRF-guarded egress) capabilities with palette wiring, plus the capability interface expansions they unblock (sourceControl/vcs PR-loop parity, projections getMessageById, textGeneration board proposals) and the review-tribunal hardening packets: marketplace/install ingestion, web host surfaces, capability facades, and git/gh stderr handling. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk --- apps/server/src/git/GitManager.test.ts | 48 + .../HelloBoardFixture.integration.test.ts | 90 +- .../src/plugins/OutboundUrlValidator.test.ts | 114 +++ .../src/plugins/OutboundUrlValidator.ts | 258 +++++ apps/server/src/plugins/PluginCatalog.ts | 3 + apps/server/src/plugins/PluginHost.test.ts | 734 +++++++++++++- apps/server/src/plugins/PluginHost.ts | 591 +++++++++-- .../src/plugins/PluginHttpRoutes.test.ts | 73 +- apps/server/src/plugins/PluginHttpRoutes.ts | 64 +- .../src/plugins/PluginInstaller.test.ts | 467 ++++++++- apps/server/src/plugins/PluginInstaller.ts | 269 +++-- .../PluginManagementRpcHandlers.test.ts | 77 +- .../plugins/PluginManagementRpcHandlers.ts | 80 +- .../src/plugins/PluginMarketplace.test.ts | 118 ++- apps/server/src/plugins/PluginMarketplace.ts | 80 +- .../src/plugins/PluginRpcDispatcher.test.ts | 36 + .../src/plugins/PluginWebRoutes.test.ts | 56 +- apps/server/src/plugins/PluginWebRoutes.ts | 61 +- .../src/plugins/PluginWorkspaceGrants.ts | 32 + .../capabilities/AgentsCapability.test.ts | 856 +++++++++++++++- .../plugins/capabilities/AgentsCapability.ts | 444 ++++++-- .../capabilities/DatabaseCapability.test.ts | 19 + .../capabilities/DatabaseCapability.ts | 9 + .../capabilities/FilesystemCapability.test.ts | 400 ++++++++ .../capabilities/FilesystemCapability.ts | 946 ++++++++++++++++++ .../capabilities/HttpClientCapability.test.ts | 351 +++++++ .../capabilities/HttpClientCapability.ts | 349 +++++++ .../capabilities/PluginCapabilities.test.ts | 321 +++++- .../ProjectionsReadCapability.test.ts | 92 ++ .../capabilities/ProjectionsReadCapability.ts | 21 +- .../plugins/capabilities/SecretsCapability.ts | 17 +- .../capabilities/SourceControlCapability.ts | 108 +- .../capabilities/TerminalsCapability.ts | 112 ++- .../capabilities/TextGenerationCapability.ts | 1 + .../capabilities/VcsCapability.test.ts | 556 +++++++++- .../src/plugins/capabilities/VcsCapability.ts | 567 +++++++++-- .../plugins/guardedOutboundHttpGet.test.ts | 192 ++++ .../src/plugins/guardedOutboundHttpGet.ts | 61 ++ .../plugins/readHttpResponseBytesCapped.ts | 30 +- apps/server/src/server.ts | 16 +- .../src/sourceControl/GitHubCli.test.ts | 405 +++++++- apps/server/src/sourceControl/GitHubCli.ts | 370 +++++++ .../BoardProposalNoTool.test.ts | 155 +++ .../ClaudeTextGeneration.test.ts | 36 + .../textGeneration/ClaudeTextGeneration.ts | 135 ++- .../CodexTextGeneration.test.ts | 88 ++ .../src/textGeneration/CodexTextGeneration.ts | 149 ++- .../CursorTextGeneration.test.ts | 27 + .../textGeneration/CursorTextGeneration.ts | 13 + .../textGeneration/GrokTextGeneration.test.ts | 27 + .../src/textGeneration/GrokTextGeneration.ts | 12 + .../OpenCodeTextGeneration.test.ts | 121 ++- .../textGeneration/OpenCodeTextGeneration.ts | 51 +- .../src/textGeneration/TextGeneration.test.ts | 32 + .../src/textGeneration/TextGeneration.ts | 44 +- .../textGeneration/TextGenerationPrompts.ts | 41 + apps/server/src/vcs/GitVcsDriver.ts | 1 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 15 +- apps/server/src/vcs/GitVcsDriverCore.ts | 36 +- apps/server/src/vcs/VcsProcess.test.ts | 23 +- .../components/CommandPalette.logic.test.ts | 72 ++ .../src/components/CommandPalette.logic.ts | 11 + apps/web/src/components/CommandPalette.tsx | 23 +- apps/web/src/components/Sidebar.tsx | 14 + .../plugins/PluginsSettings.logic.test.tsx | 39 + .../settings/plugins/PluginsSettings.logic.ts | 9 +- .../settings/plugins/PluginsSettings.tsx | 4 +- apps/web/src/plugins/PluginProjectActions.tsx | 57 ++ .../web/src/plugins/PluginSidebarSections.tsx | 16 +- apps/web/src/plugins/PluginUiHost.test.tsx | 117 +++ apps/web/src/plugins/PluginUiHost.tsx | 142 ++- apps/web/src/plugins/hostSingletons.test.ts | 20 +- apps/web/src/plugins/hostSingletons.ts | 42 +- .../_chat.$environmentId.p.$pluginId.$.tsx | 39 +- apps/web/src/routes/settings.$.tsx | 21 +- apps/web/src/state/plugins.test.ts | 93 +- apps/web/src/state/plugins.ts | 107 +- fixtures/hello-board/manifest.json | 2 +- fixtures/hello-board/scripts/build.mjs | 114 ++- fixtures/hello-board/server/index.ts | 40 +- packages/contracts/src/git.test.ts | 31 + packages/contracts/src/git.ts | 42 + .../contracts/src/internal/errorStderr.ts | 14 + packages/contracts/src/plugin.test.ts | 65 +- packages/contracts/src/plugin.ts | 104 +- packages/contracts/src/vcs.test.ts | 46 + packages/contracts/src/vcs.ts | 25 +- packages/plugin-sdk-web/package.json | 2 +- packages/plugin-sdk-web/src/externals.ts | 8 + packages/plugin-sdk-web/src/index.test.tsx | 9 +- packages/plugin-sdk-web/src/index.ts | 69 ++ packages/plugin-sdk-web/vite.config.ts | 15 +- packages/plugin-sdk/src/index.test.ts | 49 +- packages/plugin-sdk/src/index.ts | 459 ++++++++- packages/shared/src/pluginHostWeb.test.ts | 22 +- packages/shared/src/pluginHostWeb.ts | 845 +++++++++++++++- pnpm-lock.yaml | 3 + 97 files changed, 12491 insertions(+), 799 deletions(-) create mode 100644 apps/server/src/plugins/OutboundUrlValidator.test.ts create mode 100644 apps/server/src/plugins/OutboundUrlValidator.ts create mode 100644 apps/server/src/plugins/PluginWorkspaceGrants.ts create mode 100644 apps/server/src/plugins/capabilities/DatabaseCapability.test.ts create mode 100644 apps/server/src/plugins/capabilities/FilesystemCapability.test.ts create mode 100644 apps/server/src/plugins/capabilities/FilesystemCapability.ts create mode 100644 apps/server/src/plugins/capabilities/HttpClientCapability.test.ts create mode 100644 apps/server/src/plugins/capabilities/HttpClientCapability.ts create mode 100644 apps/server/src/plugins/capabilities/ProjectionsReadCapability.test.ts create mode 100644 apps/server/src/plugins/guardedOutboundHttpGet.test.ts create mode 100644 apps/server/src/plugins/guardedOutboundHttpGet.ts create mode 100644 apps/server/src/textGeneration/BoardProposalNoTool.test.ts create mode 100644 apps/web/src/plugins/PluginProjectActions.tsx create mode 100644 packages/contracts/src/internal/errorStderr.ts create mode 100644 packages/contracts/src/vcs.test.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..d70296cda3f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -362,6 +362,13 @@ function createTextGeneration( }), ), ), + generateBoardProposal: () => + Effect.fail( + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "generateBoardProposal not configured for git tests", + }), + ), }; } @@ -551,8 +558,49 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { input.title, "--body-file", input.bodyFile, + ...(input.draft ? ["--draft"] : []), + ], + }).pipe(Effect.asVoid), + mergePullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "merge", + String(input.number), + input.strategy === "merge" + ? "--merge" + : input.strategy === "rebase" + ? "--rebase" + : "--squash", ], }).pipe(Effect.asVoid), + getPullRequestDetail: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + "--json", + "state,mergedAt,reviewDecision,headRefOid,url", + ], + }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + listPullRequestChecks: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "checks", String(input.number), "--json", "name,state,bucket,link"], + }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + listPullRequestReviews: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "view", String(input.number), "--json", "reviews"], + }).pipe(Effect.map((result) => JSON.parse(result.stdout).reviews)), + listPullRequestReviewComments: (input) => + execute({ + cwd: input.cwd, + args: ["api", `repos/${input.repo}/pulls/${input.number}/comments`], + }).pipe(Effect.map((result) => JSON.parse(result.stdout))), getDefaultBranch: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/plugins/HelloBoardFixture.integration.test.ts b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts index 0eea089dbdf..6db75f80215 100644 --- a/apps/server/src/plugins/HelloBoardFixture.integration.test.ts +++ b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { AuthStandardClientScopes, PluginId, type AuthScope } from "@t3tools/contracts"; +import { AuthStandardClientScopes, PluginId, ProjectId, type AuthScope } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -8,7 +8,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -30,6 +30,8 @@ import * as TerminalManager from "../terminal/Manager.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ServerLifecycleEvents from "../serverLifecycleEvents.ts"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { OutboundUrlError, OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import * as PluginCatalogModule from "./PluginCatalog.ts"; import * as PluginHostModule from "./PluginHost.ts"; import * as PluginHttpRegistry from "./PluginHttpRegistry.ts"; @@ -43,6 +45,7 @@ import * as PluginRpcDispatcherModule from "./PluginRpcDispatcher.ts"; import * as PluginRuntimeRegistryLayer from "./PluginRuntimeRegistry.ts"; const pluginId = PluginId.make("hello-board"); +const WORKSPACE_ROOT_ENV = "T3_HELLO_BOARD_WORKSPACE_ROOT"; const fixtureRoot = decodeURIComponent( new URL("../../../../fixtures/hello-board", import.meta.url).pathname, ); @@ -61,6 +64,21 @@ const TestHttpClientLive = Layer.succeed( Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), ), ); +const TestOutboundLookupLive = Layer.succeed(OutboundUrlLookup, (host: string) => + host === "fixture.test" + ? Effect.succeed([{ address: "140.82.112.3", family: 4 as const }]) + : Effect.fail(new OutboundUrlError({ reason: `unexpected lookup ${host}` })), +); +const TestPluginHttpClientTransportLive = Layer.succeed( + PluginHttpClientTransportService, + (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.make(request.method as "GET")(request.url.toString()), + new Response("hello http", { status: 200 }), + ), + ), +); const PluginRuntimeRegistryLayerLive = PluginRuntimeRegistryLayer.layer; const PluginHttpRegistryLayerLive = PluginHttpRegistry.layer; @@ -85,7 +103,24 @@ const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getCommandReadModel: unexpectedCapabilityUse, getSnapshot: unexpectedCapabilityUse, - getShellSnapshot: unexpectedCapabilityUse, + getShellSnapshot: () => + Effect.sync(() => ({ + snapshotSequence: 1, + updatedAt: "2026-07-03T00:00:00.000Z", + projects: [ + { + id: ProjectId.make("hello-board-project"), + title: "Hello Board Project", + workspaceRoot: process.env[WORKSPACE_ROOT_ENV] ?? process.cwd(), + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-03T00:00:00.000Z", + updatedAt: "2026-07-03T00:00:00.000Z", + }, + ], + threads: [], + })), getArchivedShellSnapshot: unexpectedCapabilityUse, getSnapshotSequence: unexpectedCapabilityUse, getCounts: unexpectedCapabilityUse, @@ -183,6 +218,11 @@ const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( getRepositoryCloneUrls: unexpectedCapabilityUse, createRepository: unexpectedCapabilityUse, createPullRequest: unexpectedCapabilityUse, + mergePullRequest: unexpectedCapabilityUse, + getPullRequestDetail: unexpectedCapabilityUse, + listPullRequestChecks: unexpectedCapabilityUse, + listPullRequestReviews: unexpectedCapabilityUse, + listPullRequestReviewComments: unexpectedCapabilityUse, getDefaultBranch: unexpectedCapabilityUse, checkoutPullRequest: unexpectedCapabilityUse, }), @@ -197,6 +237,8 @@ const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( subscribe: unexpectedCapabilityUse, subscribeMetadata: unexpectedCapabilityUse, }), + TestOutboundLookupLive, + TestPluginHttpClientTransportLive, ); const PluginHostLayerLive = PluginHostModule.layer.pipe( @@ -215,7 +257,11 @@ const PluginCatalogLayerLive = PluginCatalogModule.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayerLive), Layer.provideMerge(PluginRuntimeRegistryLayerLive), ); -const PluginMarketplaceLayerLive = PluginMarketplaceModule.layer; +// The marketplace fetches untrusted URLs through the SSRF guard, so it +// needs the same lookup + pinned-transport test stubs as the capability. +const PluginMarketplaceLayerLive = PluginMarketplaceModule.layer.pipe( + Layer.provide(Layer.mergeAll(TestOutboundLookupLive, TestPluginHttpClientTransportLive)), +); const PluginInstallerLayerLive = PluginInstallerModule.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayerLive), Layer.provideMerge(PluginMarketplaceLayerLive), @@ -309,6 +355,7 @@ const withPluginDev = (effect: Effect.Effect) => Effect.sync(() => ({ pluginDev: process.env.T3_PLUGIN_DEV, healthyDelay: process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS, + workspaceRoot: process.env[WORKSPACE_ROOT_ENV], })), () => Effect.sync(() => { @@ -327,6 +374,11 @@ const withPluginDev = (effect: Effect.Effect) => } else { process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = previous.healthyDelay; } + if (previous.workspaceRoot === undefined) { + delete process.env[WORKSPACE_ROOT_ENV]; + } else { + process.env[WORKSPACE_ROOT_ENV] = previous.workspaceRoot; + } }), ); @@ -342,7 +394,11 @@ layer("hello-board fixture plugin", (it) => { const catalog = yield* PluginCatalogModule.PluginCatalog; const dispatcher = yield* PluginRpcDispatcherModule.PluginRpcDispatcher; const outDir = yield* fs.makeTempDirectoryScoped({ prefix: "hello-board-fixture-" }); + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "hello-board-workspace-", + }); const config = yield* ServerConfig.ServerConfig; + process.env[WORKSPACE_ROOT_ENV] = workspaceRoot; yield* buildFixture(outDir); yield* linkHostPluginExternals(config.pluginsDir); @@ -360,6 +416,8 @@ layer("hello-board fixture plugin", (it) => { }); assert.equal(staged.manifest.id, pluginId); assert.property(staged.capabilityDescriptions, "database"); + assert.property(staged.capabilityDescriptions, "filesystem"); + assert.property(staged.capabilityDescriptions, "httpClient"); const confirmed = yield* handlers.confirmInstall(staged.stageToken); assert.equal(confirmed.plugin.id, pluginId); @@ -370,6 +428,7 @@ layer("hello-board fixture plugin", (it) => { id: plugin.id, state: plugin.state, hasWeb: plugin.hasWeb, + hasStyles: plugin.hasStyles, capabilities: plugin.capabilities, lastError: plugin.lastError, })), @@ -377,7 +436,8 @@ layer("hello-board fixture plugin", (it) => { id: pluginId, state: "active", hasWeb: true, - capabilities: ["database"], + hasStyles: false, + capabilities: ["database", "filesystem", "httpClient"], lastError: null, }, ); @@ -398,6 +458,26 @@ layer("hello-board fixture plugin", (it) => { )) as ReadonlyArray<{ readonly body?: unknown }>; assert.equal(notes[0]?.body, "hello from fixture"); + const capabilityResult = (yield* dispatcher.call( + pluginId, + "exerciseCapabilities", + {}, + session(AuthStandardClientScopes), + )) as { + readonly file?: unknown; + readonly status?: unknown; + readonly body?: unknown; + }; + assert.deepEqual(capabilityResult, { + file: "hello filesystem", + status: 200, + body: "hello http", + }); + assert.equal( + yield* fs.readFileString(path.join(workspaceRoot, ".hello-board", "capability.txt")), + "hello filesystem", + ); + const tables = yield* sql<{ readonly name: string }>` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'p_hello_board_notes' `; diff --git a/apps/server/src/plugins/OutboundUrlValidator.test.ts b/apps/server/src/plugins/OutboundUrlValidator.test.ts new file mode 100644 index 00000000000..a82bb11bb3f --- /dev/null +++ b/apps/server/src/plugins/OutboundUrlValidator.test.ts @@ -0,0 +1,114 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { OutboundUrlValidator } from "./OutboundUrlValidator.ts"; + +const validateWith = (url: string, addrs: ReadonlyArray) => + Effect.exit(OutboundUrlValidator.validate(url, { lookup: () => Effect.succeed(addrs) })); + +describe("OutboundUrlValidator", () => { + it.effect("accepts a public https host", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("https://hooks.slack.com/services/x", ["140.82.112.3"]))._tag, + "Success", + ); + }), + ); + + it.effect("rejects non-https by default", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("http://hooks.slack.com/x", ["140.82.112.3"]))._tag, + "Failure", + ); + }), + ); + + it.effect("blocks private, loopback, link-local, metadata, ULA, and CGNAT addresses", () => + Effect.gen(function* () { + for (const addr of [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "100.127.255.255", + "::1", + "fe80::1", + "fc00::1", + "fdff::1", + ]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Failure", addr); + } + }), + ); + + it.effect("blocks special-use IPv4 ranges but accepts neighboring public ranges", () => + Effect.gen(function* () { + for (const addr of [ + "0.0.0.0", + "192.0.0.1", + "192.0.2.5", + "192.88.99.1", + "198.18.0.1", + "198.19.255.255", + "198.51.100.5", + "203.0.113.5", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255", + ]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Failure", addr); + } + for (const addr of ["100.63.255.255", "100.128.0.1", "172.32.0.1", "198.20.0.1"]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Success", addr); + } + }), + ); + + it.effect( + "blocks IPv4-mapped IPv6, NAT64, 6to4, decimal IPv4, octal IPv4, and mixed answers", + () => + Effect.gen(function* () { + for (const [url, addr] of [ + ["https://x.test/y", "::ffff:10.0.0.1"], + ["https://x.test/y", "::ffff:7f00:1"], + ["https://x.test/y", "0:0:0:0:0:ffff:7f00:1"], + ["https://x.test/y", "::7f00:1"], + ["https://x.test/y", "64:ff9b::7f00:1"], + ["https://x.test/y", "2002:7f00:1::"], + ["https://2130706433/y", "127.0.0.1"], + ["https://0177.0.0.1/y", "127.0.0.1"], + ] as const) { + assert.equal((yield* validateWith(url, [addr]))._tag, "Failure", `${url} -> ${addr}`); + } + assert.equal( + (yield* validateWith("https://x.test/y", ["140.82.112.3", "10.0.0.1"]))._tag, + "Failure", + ); + }), + ); + + it.effect("allows http only for loopback when explicitly enabled for plugin development", () => + Effect.gen(function* () { + const allowed = yield* Effect.exit( + OutboundUrlValidator.validate("http://localhost:5173/x", { + lookup: () => Effect.succeed(["127.0.0.1"]), + allowHttpLoopback: true, + }), + ); + const rejected = yield* Effect.exit( + OutboundUrlValidator.validate("http://example.test/x", { + lookup: () => Effect.succeed(["140.82.112.3"]), + allowHttpLoopback: true, + }), + ); + + assert.equal(allowed._tag, "Success"); + assert.equal(rejected._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/plugins/OutboundUrlValidator.ts b/apps/server/src/plugins/OutboundUrlValidator.ts new file mode 100644 index 00000000000..e881021e5a5 --- /dev/null +++ b/apps/server/src/plugins/OutboundUrlValidator.ts @@ -0,0 +1,258 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeDns from "node:dns"; + +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export class OutboundUrlError extends Data.TaggedError("OutboundUrlError")<{ + readonly reason: string; +}> {} + +export interface ResolvedAddress { + readonly address: string; + readonly family: 4 | 6; +} + +export interface UrlValidatorDeps { + readonly lookup: ( + host: string, + ) => Effect.Effect, OutboundUrlError | Error>; + readonly allowHttpLoopback?: boolean | undefined; +} + +export interface ResolvedOutboundUrl { + readonly url: URL; + readonly addresses: ReadonlyArray; +} + +const normalizeResolvedAddress = (entry: string | ResolvedAddress): ResolvedAddress => { + if (typeof entry !== "string") return entry; + return { address: entry, family: entry.includes(":") ? 6 : 4 }; +}; + +// Hard deadline on getaddrinfo: an unresponsive resolver can otherwise hold +// the request for the OS retry window (15-30s+) BEFORE the transport-level +// timeout even starts. getaddrinfo itself is not cancellable, so the orphaned +// lookup resolves harmlessly in the background after the fiber moves on. +const DNS_LOOKUP_TIMEOUT_MS = 5_000; + +export const defaultLookup = ( + host: string, +): Effect.Effect, OutboundUrlError> => + Effect.tryPromise({ + try: async () => { + const records = await NodeDns.promises.lookup(host, { all: true }); + return records.map( + (record): ResolvedAddress => ({ + address: record.address, + family: record.family === 6 ? 6 : 4, + }), + ); + }, + catch: (error) => { + const code = (error as { code?: unknown })?.code; + const suffix = typeof code === "string" ? ` (${code})` : ""; + return new OutboundUrlError({ reason: `DNS resolution failed for ${host}${suffix}` }); + }, + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(DNS_LOOKUP_TIMEOUT_MS), + orElse: () => new OutboundUrlError({ reason: `DNS resolution timed out for ${host}` }), + }), + ); + +export class OutboundUrlLookup extends Context.Service< + OutboundUrlLookup, + UrlValidatorDeps["lookup"] +>()("t3/plugins/OutboundUrlValidator/OutboundUrlLookup") {} + +export const OutboundUrlLookupLive = Layer.succeed(OutboundUrlLookup, defaultLookup); + +// INVARIANT: only call this on canonical dotted decimal from WHATWG URL or DNS. +const ipv4Bytes = (ip: string): ReadonlyArray | null => { + if (!ip.includes(".") || ip.includes(":")) return null; + const parts = ip.split(".").map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return null; + } + return parts; +}; + +const isDisallowedV4 = (bytes: ReadonlyArray): boolean => { + const first = bytes[0] ?? -1; + const second = bytes[1] ?? -1; + const third = bytes[2] ?? -1; + if (first === 0) return true; + if (first === 10) return true; + if (first === 127) return true; + if (first === 100 && second >= 64 && second <= 127) return true; + if (first === 169 && second === 254) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 192 && second === 0 && third === 0) return true; + if (first === 192 && second === 0 && third === 2) return true; + if (first === 192 && second === 88 && third === 99) return true; + if (first === 192 && second === 168) return true; + if (first === 198 && (second === 18 || second === 19)) return true; + if (first === 198 && second === 51 && third === 100) return true; + if (first === 203 && second === 0 && third === 113) return true; + if (first >= 224) return true; + return false; +}; + +const ipv6Bytes = (raw: string): ReadonlyArray | null => { + let ip = raw.toLowerCase().replace(/^\[|\]$/g, ""); + const zone = ip.indexOf("%"); + if (zone !== -1) ip = ip.slice(0, zone); + if (!ip.includes(":")) return null; + + const lastColon = ip.lastIndexOf(":"); + const tail = ip.slice(lastColon + 1); + if (tail.includes(".")) { + const v4 = ipv4Bytes(tail); + if (!v4) return null; + const hi = (((v4[0] ?? 0) << 8) | (v4[1] ?? 0)).toString(16); + const lo = (((v4[2] ?? 0) << 8) | (v4[3] ?? 0)).toString(16); + ip = `${ip.slice(0, lastColon + 1)}${hi}:${lo}`; + } + + const halves = ip.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tailParts = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : null; + let hextets: ReadonlyArray; + if (tailParts === null) { + hextets = head; + } else { + const fill = 8 - head.length - tailParts.length; + if (fill < 0) return null; + hextets = [...head, ...Array(fill).fill("0"), ...tailParts]; + } + if (hextets.length !== 8) return null; + + const bytes: number[] = []; + for (const hextet of hextets) { + if (!/^[0-9a-f]{1,4}$/.test(hextet)) return null; + const value = Number.parseInt(hextet, 16); + bytes.push((value >> 8) & 0xff, value & 0xff); + } + return bytes; +}; + +const embeddedV4 = (bytes: ReadonlyArray): ReadonlyArray | null => { + const zerosThrough = (length: number): boolean => + bytes.slice(0, length).every((byte) => byte === 0); + if (zerosThrough(10) && bytes[10] === 0xff && bytes[11] === 0xff) { + return bytes.slice(12, 16); + } + if ( + bytes[0] === 0x00 && + bytes[1] === 0x64 && + bytes[2] === 0xff && + bytes[3] === 0x9b && + bytes.slice(4, 12).every((byte) => byte === 0) + ) { + return bytes.slice(12, 16); + } + if (zerosThrough(12)) return bytes.slice(12, 16); + if (bytes[0] === 0x20 && bytes[1] === 0x02) return bytes.slice(2, 6); + return null; +}; + +const isPrivateV6 = (raw: string): boolean => { + const bytes = ipv6Bytes(raw); + if (!bytes) return true; + if (bytes.slice(0, 15).every((byte) => byte === 0) && (bytes[15] === 0 || bytes[15] === 1)) { + return true; + } + const first = bytes[0] ?? 0; + const second = bytes[1] ?? 0; + if (first === 0xfe && (second & 0xc0) === 0x80) return true; + if ((first & 0xfe) === 0xfc) return true; + if (first === 0xff) return true; + const v4 = embeddedV4(bytes); + if (v4) return isDisallowedV4(v4); + return false; +}; + +const isBlocked = (ip: string): boolean => { + const v4 = ipv4Bytes(ip); + if (v4) return isDisallowedV4(v4); + return isPrivateV6(ip); +}; + +const isLoopback = (ip: string): boolean => { + const v4 = ipv4Bytes(ip); + if (v4) return v4[0] === 127; + const bytes = ipv6Bytes(ip); + if (!bytes) return false; + if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return true; + // IPv4-mapped IPv6 loopback (::ffff:127.0.0.0/104): dns.lookup can return + // this form for localhost on dual-stack hosts. Only the mapped form is + // accepted — 6to4/NAT64 embeddings are NOT loopback connectivity. + return ( + bytes.slice(0, 10).every((byte) => byte === 0) && + bytes[10] === 0xff && + bytes[11] === 0xff && + bytes[12] === 127 + ); +}; + +const mapLookupError = (host: string, error: unknown) => + error instanceof OutboundUrlError + ? error + : new OutboundUrlError({ reason: `DNS resolution failed for ${host}` }); + +export const OutboundUrlValidator = { + resolve: ( + rawUrl: string, + deps: UrlValidatorDeps = { lookup: defaultLookup }, + ): Effect.Effect => + Effect.gen(function* () { + let parsed: URL; + // @effect-diagnostics-next-line tryCatchInEffectGen:off -- WHATWG URL parsing is a synchronous guard converted to an Effect failure. + try { + parsed = new URL(rawUrl); + } catch { + return yield* new OutboundUrlError({ reason: "Malformed URL" }); + } + + const isHttps = parsed.protocol === "https:"; + const allowHttpLoopback = deps.allowHttpLoopback === true && parsed.protocol === "http:"; + if (!isHttps && !allowHttpLoopback) { + return yield* new OutboundUrlError({ reason: "Only https:// targets are allowed" }); + } + + const addresses = yield* deps.lookup(parsed.hostname).pipe( + Effect.map((records) => records.map(normalizeResolvedAddress)), + Effect.mapError((error) => mapLookupError(parsed.hostname, error)), + ); + if (addresses.length === 0) { + return yield* new OutboundUrlError({ reason: "Host did not resolve" }); + } + + for (const address of addresses) { + if (isHttps) { + if (isBlocked(address.address)) { + return yield* new OutboundUrlError({ + reason: `Resolved to a disallowed address (${address.address})`, + }); + } + } else if (!isLoopback(address.address)) { + return yield* new OutboundUrlError({ + reason: `HTTP development target resolved outside loopback (${address.address})`, + }); + } + } + + return { url: parsed, addresses }; + }), + + validate: (rawUrl: string, deps?: UrlValidatorDeps): Effect.Effect => + OutboundUrlValidator.resolve(rawUrl, deps).pipe(Effect.map((result) => result.url)), +}; diff --git a/apps/server/src/plugins/PluginCatalog.ts b/apps/server/src/plugins/PluginCatalog.ts index afce5a648b6..5f110b2107e 100644 --- a/apps/server/src/plugins/PluginCatalog.ts +++ b/apps/server/src/plugins/PluginCatalog.ts @@ -40,6 +40,7 @@ const pluginInfoFromRuntime = ( state: entry?.state ?? "active", capabilities: Array.from(runtime.manifest.capabilities), hasWeb: runtime.manifest.entries.web !== undefined, + hasStyles: runtime.manifest.entries.styles !== undefined, lastError: entry?.lastError ?? null, }; }; @@ -51,6 +52,7 @@ const fallbackPluginInfo = (pluginId: string, entry: PluginLockfilePlugin): Plug state: entry.state, capabilities: [], hasWeb: false, + hasStyles: false, lastError: entry.lastError, }); @@ -81,6 +83,7 @@ export const make = Effect.fn("PluginCatalog.make")(function* () { state: entry.state, capabilities: Array.from(manifest.capabilities), hasWeb: manifest.entries.web !== undefined, + hasStyles: manifest.entries.styles !== undefined, lastError: entry.lastError, }), ), diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts index ad25a71c581..a48b65879ec 100644 --- a/apps/server/src/plugins/PluginHost.test.ts +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -1,4 +1,4 @@ -import { assert, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { PluginId, @@ -6,7 +6,9 @@ import { type PluginCapability, type PluginLockfilePlugin, } from "@t3tools/contracts/plugin"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -36,6 +38,8 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr import * as TerminalManager from "../terminal/Manager.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import * as PluginHostModule from "./PluginHost.ts"; import * as PluginHttpRegistry from "./PluginHttpRegistry.ts"; import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; @@ -197,22 +201,35 @@ const testLayerBase = PluginHostModule.layer.pipe( getRepositoryCloneUrls: unexpectedCapabilityUse, createRepository: unexpectedCapabilityUse, createPullRequest: unexpectedCapabilityUse, + mergePullRequest: unexpectedCapabilityUse, + getPullRequestDetail: unexpectedCapabilityUse, + listPullRequestChecks: unexpectedCapabilityUse, + listPullRequestReviews: unexpectedCapabilityUse, + listPullRequestReviewComments: 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.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({ + open: unexpectedCapabilityUse, + attachStream: unexpectedCapabilityUse, + write: unexpectedCapabilityUse, + resize: unexpectedCapabilityUse, + clear: unexpectedCapabilityUse, + restart: unexpectedCapabilityUse, + close: unexpectedCapabilityUse, + subscribe: unexpectedCapabilityUse, + subscribeMetadata: unexpectedCapabilityUse, + }), + Layer.succeed(OutboundUrlLookup, () => + Effect.die(new Error("unexpected outbound lookup in host test")), + ), + Layer.succeed(PluginHttpClientTransportService, () => + Effect.die(new Error("unexpected http client transport in host test")), + ), + ), ), ); @@ -235,6 +252,19 @@ const decodeCapabilityMarker = Schema.decodeEffect( }), ), ); +const decodeNewCapabilityMarker = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + filesystemAvailable: Schema.Boolean, + filesystemUnavailable: Schema.Boolean, + httpClientAvailable: Schema.Boolean, + httpClientUnavailable: Schema.Boolean, + }), + ), +); +const decodeCaughtMarker = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ caught: Schema.String })), +); const makeLockEntry = (overrides: Partial = {}): PluginLockfilePlugin => ({ version: "1.0.0", @@ -352,6 +382,115 @@ export default { }; `; +const newCapabilityGateEntrySource = () => ` +import { createRequire } from "node:module"; +const require = createRequire(${JSON.stringify(NodeURL.pathToFileURL(import.meta.url).href)}); +const Effect = require("effect/Effect"); +const NodeFs = require("node:fs"); + +const available = (effect) => Effect.exit(effect).pipe(Effect.map((exit) => exit._tag === "Success")); +const unavailable = (effect) => + Effect.exit(effect).pipe( + Effect.map((exit) => exit._tag === "Failure" && String(exit.cause).includes("PluginCapabilityUnavailable")), + ); + +export default { + register(hostApi) { + return Effect.gen(function* () { + const marker = { + filesystemAvailable: yield* available(hostApi.filesystem), + filesystemUnavailable: yield* unavailable(hostApi.filesystem), + httpClientAvailable: yield* available(hostApi.httpClient), + httpClientUnavailable: yield* unavailable(hostApi.httpClient), + }; + NodeFs.mkdirSync(hostApi.config.dataDir, { recursive: true }); + NodeFs.writeFileSync(hostApi.config.dataDir + "/new-capabilities.json", JSON.stringify(marker)); + return {}; + }); + }, +}; +`; + +const interruptEntrySource = () => ` +import { createRequire } from "node:module"; +const require = createRequire(${JSON.stringify(NodeURL.pathToFileURL(import.meta.url).href)}); +const Effect = require("effect/Effect"); + +export default { + register() { + // Genuinely interrupt activation for THIS plugin. In start's per-plugin loop + // an interrupt-only cause now RE-RAISES (host-shutdown semantics), stopping + // the loop promptly rather than plodding through the remaining plugins. + return Effect.interrupt; + }, +}; +`; + +const cancelDuringActivationEntrySource = () => ` +import { createRequire } from "node:module"; +const require = createRequire(${JSON.stringify(NodeURL.pathToFileURL(import.meta.url).href)}); +const NodeFs = require("node:fs"); +const NodePath = require("node:path"); + +export default { + register(hostApi) { + // Simulate a concurrent disable/uninstall landing DURING activation: flip + // this plugin's persisted lifecycle state to "disabled" BEFORE the host's + // pre-put re-check reads it, so the host aborts activation via the typed + // PluginActivationCanceled sentinel (not a fiber interrupt). dataDir is + // //data, so the lockfile is two levels up. + const dataDir = hostApi.config.dataDir; + const pluginRoot = NodePath.dirname(dataDir); + const pluginId = NodePath.basename(pluginRoot); + const lockfilePath = NodePath.join(NodePath.dirname(pluginRoot), "plugins.json"); + const lockfile = JSON.parse(NodeFs.readFileSync(lockfilePath, "utf8")); + lockfile.plugins[pluginId].state = "disabled"; + NodeFs.writeFileSync(lockfilePath, JSON.stringify(lockfile)); + return {}; + }, +}; +`; + +const registerCountEntrySource = () => ` +import { createRequire } from "node:module"; +const require = createRequire(${JSON.stringify(NodeURL.pathToFileURL(import.meta.url).href)}); +const NodeFs = require("node:fs"); + +export default { + register(hostApi) { + // Append one marker per register() call so the test can assert loadPlugin ran + // exactly once under concurrent activation. + NodeFs.mkdirSync(hostApi.config.dataDir, { recursive: true }); + NodeFs.appendFileSync(hostApi.config.dataDir + "/register-count", "x"); + return {}; + }, +}; +`; + +const catchUnavailableEntrySource = () => ` +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* () { + // hostApi.agents is undeclared for this plugin. A typed Effect.fail is + // recoverable via Effect.catch; a defect (Effect.die) would NOT be caught + // and would crash register instead of degrading gracefully. + const caught = yield* hostApi.agents.pipe( + Effect.as("unexpected-success"), + Effect.catch((error) => Effect.succeed(error && error._tag ? error._tag : "unknown")), + ); + NodeFs.mkdirSync(hostApi.config.dataDir, { recursive: true }); + NodeFs.writeFileSync(hostApi.config.dataDir + "/caught.json", JSON.stringify({ caught })); + return {}; + }); + }, +}; +`; + layer("PluginModuleLoader", (it) => { it.effect("loads a definePlugin-shaped default export from inside the plugin dir", () => Effect.gen(function* () { @@ -435,6 +574,51 @@ layer("PluginHost", (it) => { }), ); + it.effect( + "clears activatingSince immediately on success, before the healthy window elapses", + () => + Effect.gen(function* () { + const pluginId = PluginId.make("test-plugin"); + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + const host = yield* PluginHostModule.PluginHost; + const previousHealthyDelay = process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + + yield* runMigrations({ toMigrationInclusive: 34 }); + // Seed a prior crashCount so we can prove it is NOT reset yet (the delayed + // reset is gated behind a long stability window that never elapses here). + yield* installPlugin({ + pluginId, + lockEntry: { activation: { activatingSince: null, crashCount: 1 } }, + }); + + // A long window means the delayed crashCount reset never fires during the + // test; only the immediate on-success clear of activatingSince can run. + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "600000"; + try { + yield* host.start; + for (let attempt = 0; attempt < 10; attempt++) { + if ((yield* registry.list).length === 1) break; + 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; + } + } + + assert.equal((yield* registry.list).length, 1); + const lockfile = yield* store.readLockfile; + // activatingSince cleared on successful activation (a quick restart now + // would NOT be mistaken for an interrupted activation)... + assert.equal(lockfile.plugins[pluginId]?.activation.activatingSince, null); + // ...while crashCount is still preserved until the stability window ends. + assert.equal(lockfile.plugins[pluginId]?.activation.crashCount, 1); + }), + ); + it.effect("publishes plugin state changes on the server lifecycle stream", () => Effect.gen(function* () { const pluginId = PluginId.make("lifecycle-plugin"); @@ -537,6 +721,65 @@ layer("PluginHost", (it) => { }), ); + it.effect("gates filesystem and httpClient independently by manifest declaration", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* PluginHostModule.PluginHost; + + const cases = [ + { + pluginId: PluginId.make("filesystem-only"), + capabilities: ["filesystem"] as const, + expected: { + filesystemAvailable: true, + filesystemUnavailable: false, + httpClientAvailable: false, + httpClientUnavailable: true, + }, + }, + { + pluginId: PluginId.make("http-client-only"), + capabilities: ["httpClient"] as const, + expected: { + filesystemAvailable: false, + filesystemUnavailable: true, + httpClientAvailable: true, + httpClientUnavailable: false, + }, + }, + { + pluginId: PluginId.make("neither-new-cap"), + capabilities: [] as const, + expected: { + filesystemAvailable: false, + filesystemUnavailable: true, + httpClientAvailable: false, + httpClientUnavailable: true, + }, + }, + ]; + + for (const testCase of cases) { + yield* installPlugin({ + pluginId: testCase.pluginId, + capabilities: testCase.capabilities, + entrySource: newCapabilityGateEntrySource(), + }); + } + + yield* host.start; + yield* Effect.yieldNow; + + for (const testCase of cases) { + const dataDir = pluginDataDir(config.pluginsDir, testCase.pluginId, path.join); + const marker = yield* fs.readFileString(path.join(dataDir, "new-capabilities.json")); + assert.deepEqual(yield* decodeNewCapabilityMarker(marker), testCase.expected); + } + }), + ); + it.effect("does not load anything when T3_NO_PLUGINS is set", () => Effect.gen(function* () { const pluginId = PluginId.make("disabled-env"); @@ -594,4 +837,471 @@ layer("PluginHost", (it) => { assert.isFalse(yield* fs.exists(pluginDir)); }), ); + + it.effect("resets crash health when promoting a staged upgrade", () => + Effect.gen(function* () { + const pluginId = PluginId.make("upgrade-plugin"); + const host = yield* PluginHostModule.PluginHost; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + const previousHealthyDelay = process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + const emptyEntry = "export default { register() { return {}; } };"; + + yield* runMigrations({ toMigrationInclusive: 34 }); + // Both the current (1.0.0) and staged (2.0.0) version dirs must exist so + // the post-promotion load of 2.0.0 succeeds. + yield* installPlugin({ pluginId, entrySource: emptyEntry, lockEntry: { version: "1.0.0" } }); + yield* installPlugin({ pluginId, entrySource: emptyEntry, lockEntry: { version: "2.0.0" } }); + // Stage a pending upgrade carrying a prior crashCount + error that must NOT + // carry over to the new build. + yield* store.updatePlugin(pluginId, () => + Effect.succeed( + makeLockEntry({ + version: "1.0.0", + state: "pending-upgrade", + staged: { version: "2.0.0", sha256: "sha2", stagedAt: now }, + activation: { activatingSince: null, crashCount: 1 }, + lastError: "old failure", + }), + ), + ); + + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + try { + yield* host.start; + for (let attempt = 0; attempt < 10; attempt++) { + if ((yield* registry.list).some((runtime) => runtime.manifest.id === pluginId)) break; + 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 lockfile = yield* store.readLockfile; + const entry = lockfile.plugins[pluginId]; + assert.equal(entry?.version, "2.0.0"); + assert.equal(entry?.state, "active"); + assert.equal(entry?.activation.crashCount, 0); + assert.equal(entry?.lastError, null); + }), + ); + + it.effect("deactivatePlugin publishes the persisted state, not a hardcoded disabled", () => + Effect.gen(function* () { + const pluginId = PluginId.make("deactivate-state-plugin"); + const host = yield* PluginHostModule.PluginHost; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const previousHealthyDelay = process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + + // Subscribe before start (like the lifecycle test above) to deterministically + // observe both the activation "active" publish and the later deactivation + // publish. + const eventFiber = yield* lifecycleEvents.stream.pipe( + Stream.filter((event) => event.type === "plugins" && event.payload.pluginId === pluginId), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + // A migration-free entry so activation succeeds regardless of sibling tests + // (still enters the runtime registry, so deactivate finds a live runtime). + yield* installPlugin({ + pluginId, + entrySource: "export default { register() { return {}; } };", + }); + + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + try { + yield* host.start; + for (let attempt = 0; attempt < 10; attempt++) { + if ((yield* registry.list).some((runtime) => runtime.manifest.id === pluginId)) break; + 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; + } + } + + // Persist "pending-remove" (as uninstall does) before tearing down. + yield* store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current ? { ...current, state: "pending-remove", enabled: false } : undefined, + ), + ); + yield* host.deactivatePlugin(pluginId); + + const events = Array.from(yield* Fiber.join(eventFiber)); + assert.deepEqual(events[0]?.payload, { + kind: "plugin-state-changed", + pluginId, + state: "active", + }); + // The deactivation publish reflects the ACTUAL persisted state + // ("pending-remove"), not a hardcoded "disabled". + assert.deepEqual(events[1]?.payload, { + kind: "plugin-state-changed", + pluginId, + state: "pending-remove", + }); + }), + ); + + it.effect("an undeclared capability fails with a catchable typed error, not a defect", () => + Effect.gen(function* () { + const pluginId = PluginId.make("catch-capability"); + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* PluginHostModule.PluginHost; + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + + yield* installPlugin({ + pluginId, + capabilities: [], + entrySource: catchUnavailableEntrySource(), + }); + + yield* host.start; + yield* Effect.yieldNow; + + // register completed (a defect would have crashed it) and the plugin is + // active, having recovered from the undeclared-capability failure. + const runtimes = yield* registry.list; + assert.isTrue(runtimes.some((runtime) => runtime.manifest.id === pluginId)); + + const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const caughtFile = yield* fs.readFileString(path.join(dataDir, "caught.json")); + assert.deepEqual(yield* decodeCaughtMarker(caughtFile), { + caught: "PluginCapabilityUnavailable", + }); + }), + ); + + it.effect( + "a cancelled activation clears activatingSince and is not counted as a crash (R5-1)", + () => + Effect.gen(function* () { + const pluginId = PluginId.make("cancel-clears-marker-plugin"); + 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; + + // The plugin flips its own lockfile state to "disabled" mid-register, so + // the host's pre-put re-check aborts activation via the typed cancel + // sentinel (a concurrent disable/uninstall arriving during activation). + yield* installPlugin({ + pluginId, + entrySource: cancelDuringActivationEntrySource(), + }); + + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + try { + yield* host.start; + } finally { + if (previousHealthyDelay === undefined) { + delete process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + } else { + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = previousHealthyDelay; + } + } + + // The plugin did not go live. + const runtimes = yield* registry.list; + assert.isFalse(runtimes.some((runtime) => runtime.manifest.id === pluginId)); + + const entry = (yield* store.readLockfile).plugins[pluginId]; + // Core R5-1 regression: the activating marker is cleared on the clean + // cancel teardown (the OLD interrupt branch skipped this, leaving it + // set)... + assert.equal(entry?.activation.activatingSince, null); + // ...the intentional cancellation is NOT counted as a crash... + assert.equal(entry?.activation.crashCount, 0); + // ...and it is NOT marked "failed"; the requested state is preserved. + assert.notEqual(entry?.state, "failed"); + assert.equal(entry?.state, "disabled"); + }), + ); + + it.effect("start skips a plugin whose activation is cancelled and still activates the rest", () => + Effect.gen(function* () { + const cancelledId = PluginId.make("cancel-during-start-plugin"); + const survivorId = PluginId.make("after-cancel-plugin"); + 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; + + // Insertion order: the cancelling plugin is processed first, so if its + // cancel aborted the loop the survivor would never activate. The typed + // sentinel keeps the loop going (unlike a genuine interrupt). + yield* installPlugin({ + pluginId: cancelledId, + entrySource: cancelDuringActivationEntrySource(), + }); + yield* installPlugin({ + pluginId: survivorId, + entrySource: "export default { register() { return {}; } };", + }); + + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + try { + yield* host.start; + for (let attempt = 0; attempt < 10; attempt++) { + if ((yield* registry.list).some((runtime) => runtime.manifest.id === survivorId)) break; + 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; + // The cancelled plugin did not activate... + assert.isFalse(runtimes.some((runtime) => runtime.manifest.id === cancelledId)); + // ...but the loop continued and activated the next plugin. + assert.isTrue(runtimes.some((runtime) => runtime.manifest.id === survivorId)); + // The cancel left the marker cleared and the requested state intact. + const entry = (yield* store.readLockfile).plugins[cancelledId]; + assert.equal(entry?.activation.activatingSince, null); + assert.equal(entry?.state, "disabled"); + }), + ); + + it.effect("start stops the loop when a plugin's activation is genuinely interrupted (R5-2)", () => + Effect.gen(function* () { + const interruptedId = PluginId.make("shutdown-interrupt-plugin"); + const survivorId = PluginId.make("after-shutdown-interrupt-plugin"); + 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; + + // Insertion order: the interrupting plugin is processed first. A GENUINE + // interrupt-only cause (host-shutdown semantics) now re-raises out of the + // loop, so the survivor that follows must NOT be reached. + yield* installPlugin({ pluginId: interruptedId, entrySource: interruptEntrySource() }); + yield* installPlugin({ + pluginId: survivorId, + entrySource: "export default { register() { return {}; } };", + }); + + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + try { + // host.start completes (the trailing ignoreCause swallows the re-raised + // interrupt) but the loop terminated early at the interrupted plugin. + yield* host.start; + } 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; + // The interrupted plugin did not activate... + assert.isFalse(runtimes.some((runtime) => runtime.manifest.id === interruptedId)); + // ...and the loop STOPPED, so the following plugin was never reached. + assert.isFalse(runtimes.some((runtime) => runtime.manifest.id === survivorId)); + const entry = (yield* store.readLockfile).plugins[interruptedId]; + // The interrupt teardown clears the activating marker (so a later start + // does not miscount it as a crash) and leaves the persisted state intact. + assert.equal(entry?.activation.activatingSince, null); + assert.equal(entry?.state, "active"); + + // Neutralize the genuinely-interrupting plugin so it cannot stop the loop + // of any host.start run by a later test in this shared-lockfile block. + yield* store.updatePlugin(interruptedId, ({ current }) => + Effect.succeed(current ? { ...current, enabled: false, state: "disabled" } : undefined), + ); + }), + ); + + it.effect( + "activatePlugin fails instead of silently no-opping when the lockfile is unreadable", + () => + Effect.gen(function* () { + const pluginId = PluginId.make("read-failure-plugin"); + const fs = yield* FileSystem.FileSystem; + const host = yield* PluginHostModule.PluginHost; + const store = yield* PluginLockfileStoreLayer.PluginLockfileStore; + + // Install so a valid lockfile exists, then corrupt it so readLockfile fails + // with a parse error (NOT NotFound, which readLockfile treats as an empty + // lockfile). Restore it afterwards so the shared lockfile stays valid for + // sibling tests. + yield* installPlugin({ pluginId }); + const original = yield* fs.readFileString(store.lockfilePath); + yield* fs.writeFileString(store.lockfilePath, "{ not valid json"); + + const exit = yield* Effect.exit(host.activatePlugin(pluginId)); + + yield* fs.writeFileString(store.lockfilePath, original); + // Previously the read failure was swallowed and replaced with an empty + // lockfile, so activatePlugin SUCCEEDED without loading anything. It must now + // propagate the failure. + assert.isTrue(Exit.isFailure(exit)); + }), + ); + + it.effect( + "concurrent activatePlugin for the same plugin loads it once (single-flight, no leaked runtime)", + () => + Effect.gen(function* () { + const pluginId = PluginId.make("single-flight-plugin"); + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* PluginHostModule.PluginHost; + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + const previousHealthyDelay = process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + + yield* installPlugin({ pluginId, entrySource: registerCountEntrySource() }); + + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + try { + // Two concurrent activations. Without single-flight both pass the empty- + // registry check and both run loadPlugin (→ two register() calls; the + // first runtime's scope is leaked when the second registry.put overwrites + // it). The per-plugin lock serializes them so the second's registry.get + // double-check short-circuits. + yield* Effect.all([host.activatePlugin(pluginId), host.activatePlugin(pluginId)], { + concurrency: "unbounded", + }); + } finally { + if (previousHealthyDelay === undefined) { + delete process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + } else { + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = previousHealthyDelay; + } + } + + // register() ran exactly once → loadPlugin ran exactly once. + const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const registerCount = yield* fs.readFileString(path.join(dataDir, "register-count")); + assert.equal(registerCount, "x"); + // Exactly one live runtime is registered for the plugin. + const runtimes = yield* registry.list; + assert.equal(runtimes.filter((runtime) => runtime.manifest.id === pluginId).length, 1); + }), + ); + + it.effect("concurrent host.start and activatePlugin for the same plugin load it once", () => + Effect.gen(function* () { + const pluginId = PluginId.make("start-vs-activate-plugin"); + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* PluginHostModule.PluginHost; + const registry = yield* PluginRuntimeRegistryLayer.PluginRuntimeRegistry; + const previousHealthyDelay = process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + + yield* installPlugin({ pluginId, entrySource: registerCountEntrySource() }); + + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = "0"; + try { + // The start loop and an enable/install RPC's activatePlugin race for the + // SAME plugin. Before the fix the start loop called loadPlugin directly, + // bypassing the per-plugin single-flight lock, so both could run + // loadPlugin: register() twice, migrator twice (the second + // plugin_migrations INSERT PK-conflicts → spurious "failed"), and the + // second registry.put orphaned the first runtime's scope. Routing the + // start loop through the shared lock + a registry.get double-check makes + // it load exactly once regardless of who wins the race. + yield* Effect.all([host.start, host.activatePlugin(pluginId)], { + concurrency: "unbounded", + }); + } finally { + if (previousHealthyDelay === undefined) { + delete process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS; + } else { + process.env.T3_PLUGIN_HOST_HEALTHY_DELAY_MS = previousHealthyDelay; + } + } + + // register() ran exactly once → loadPlugin ran exactly once. + const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const registerCount = yield* fs.readFileString(path.join(dataDir, "register-count")); + assert.equal(registerCount, "x"); + const runtimes = yield* registry.list; + assert.equal(runtimes.filter((runtime) => runtime.manifest.id === pluginId).length, 1); + }), + ); + + // NOTE: the EXTERNAL-interrupt teardown fix in loadPlugin (the uninterruptibleMask + // ladder that lets Scope.close + registry.remove + clearActivatingMarker run when + // effect@4.0.0-beta.78's `Effect.exit` cannot capture an external fiber interrupt) + // is exercised by standalone runtime probes rather than an integration test here: + // externally interrupting a REAL forked/raced activation makes the plugin's + // dynamic import never settle under vite-plus/vitest, hanging the run before the + // interrupt path is even reached. The internal-interrupt path is covered by the + // "start stops the loop when a plugin's activation is genuinely interrupted" + // (R5-2) test above. +}); + +describe("PluginHost cause predicates", () => { + const sentinel = (reason: string) => + new PluginHostModule.PluginActivationCanceled({ pluginId: "p", reason }); + + it("causeIsActivationCanceledOnly is true ONLY when every reason is the cancel sentinel", () => { + // A single sentinel fail, and several sentinel fails, are pure cancels. + assert.isTrue(PluginHostModule.causeIsActivationCanceledOnly(Cause.fail(sentinel("disabled")))); + assert.isTrue( + PluginHostModule.causeIsActivationCanceledOnly( + Cause.combine(Cause.fail(sentinel("a")), Cause.fail(sentinel("b"))), + ), + ); + + // A MIXED cause (sentinel + teardown defect, or sentinel + interrupt) must + // NOT count as a clean cancel — otherwise a real teardown failure would be + // silently dropped and a shutdown interrupt swallowed. + assert.isFalse( + PluginHostModule.causeIsActivationCanceledOnly( + Cause.combine(Cause.fail(sentinel("x")), Cause.die(new Error("teardown"))), + ), + ); + assert.isFalse( + PluginHostModule.causeIsActivationCanceledOnly( + Cause.combine(Cause.fail(sentinel("x")), Cause.interrupt()), + ), + ); + + // A pure interrupt and a pure non-sentinel error are not cancels; neither is + // the empty cause (no reasons). + assert.isFalse(PluginHostModule.causeIsActivationCanceledOnly(Cause.interrupt())); + assert.isFalse(PluginHostModule.causeIsActivationCanceledOnly(Cause.fail(new Error("boom")))); + assert.isFalse(PluginHostModule.causeIsActivationCanceledOnly(Cause.empty)); + }); + + it("causeContainsInterrupt is true whenever the cause carries ANY interrupt reason", () => { + // A pure interrupt and a sentinel+interrupt mix both contain an interrupt. + assert.isTrue(PluginHostModule.causeContainsInterrupt(Cause.interrupt())); + assert.isTrue( + PluginHostModule.causeContainsInterrupt( + Cause.combine(Cause.fail(sentinel("x")), Cause.interrupt()), + ), + ); + + // A pure sentinel, a pure error, and sentinel+defect carry no interrupt. + assert.isFalse(PluginHostModule.causeContainsInterrupt(Cause.fail(sentinel("x")))); + assert.isFalse(PluginHostModule.causeContainsInterrupt(Cause.fail(new Error("boom")))); + assert.isFalse( + PluginHostModule.causeContainsInterrupt( + Cause.combine(Cause.fail(sentinel("x")), Cause.die(new Error("teardown"))), + ), + ); + }); }); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 57884b3956c..9d2557f7374 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -23,12 +23,15 @@ 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 HashMap from "effect/HashMap"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import packageJson from "../../package.json" with { type: "json" }; @@ -51,20 +54,31 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { makeAgentsCapability } from "./capabilities/AgentsCapability.ts"; import { makeDatabaseCapability } from "./capabilities/DatabaseCapability.ts"; import { makeEnvironmentsReadCapability } from "./capabilities/EnvironmentsReadCapability.ts"; +import { makeFilesystemCapability } from "./capabilities/FilesystemCapability.ts"; import { makeHttpCapability } from "./capabilities/HttpCapability.ts"; +import { + makeHttpClientCapability, + PluginHttpClientTransportService, +} from "./capabilities/HttpClientCapability.ts"; import { makeProjectionsReadCapability } from "./capabilities/ProjectionsReadCapability.ts"; import { makeSecretsCapability } from "./capabilities/SecretsCapability.ts"; import { makeSourceControlCapability } from "./capabilities/SourceControlCapability.ts"; import { makeTerminalsCapability } from "./capabilities/TerminalsCapability.ts"; import { makeTextGenerationCapability } from "./capabilities/TextGenerationCapability.ts"; import { makeVcsCapability } from "./capabilities/VcsCapability.ts"; -import { PluginLockfileStore } from "./PluginLockfileStore.ts"; +import { OutboundUrlLookup } from "./OutboundUrlValidator.ts"; +import { + PluginLockfileStore, + type PluginLockfileCorruptError, + type PluginLockfileReadError, +} from "./PluginLockfileStore.ts"; import { PluginHttpRegistry } from "./PluginHttpRegistry.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"; +import { makePluginWorkspaceGrants, type PluginWorkspaceGrants } from "./PluginWorkspaceGrants.ts"; const APP_VERSION = packageJson.version; const PRESERVE_DATA_MARKER = ".preserve-data-on-remove"; @@ -77,6 +91,17 @@ const healthyActivationDelay = () => { : Duration.seconds(30); }; +// Bound plugin-controlled register()/recover() so an unresponsive plugin fails +// activation via the normal failure path instead of stalling the host: a hung +// register()/recover() would otherwise block server startup or an +// install/enable request indefinitely. +const registrationTimeout = () => { + const overrideMs = Number.parseInt(process.env.T3_PLUGIN_HOST_REGISTER_TIMEOUT_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 }, @@ -86,6 +111,45 @@ export class PluginRegistrationError extends Schema.TaggedErrorClass()( + "PluginActivationCanceled", + { pluginId: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} activation was canceled: ${this.reason}`; + } +} + +// True ONLY when a cause is composed EXCLUSIVELY of the activation-cancel +// sentinel (at least one reason, and EVERY reason is the sentinel). Scope.close +// can produce a MIXED cause that combines the sentinel with a finalizer/teardown +// error/defect or a fiber interrupt; a "contains" check would fold those benign- +// looking mixed causes into the cancel branch and silently drop a real teardown +// failure or swallow a shutdown interrupt. Requiring "only" keeps a mixed cause +// out of the cancel branch. Iterating cause.reasons and narrowing with +// isFailReason is the idiomatic effect-4 way to inspect a cause for a specific +// tagged error; Schema.is is the schema-aware runtime check for the value. +// Exported for unit testing over hand-built causes. +const isActivationCanceled = Schema.is(PluginActivationCanceled); +export const causeIsActivationCanceledOnly = (cause: Cause.Cause): boolean => + cause.reasons.length > 0 && + cause.reasons.every((reason) => Cause.isFailReason(reason) && isActivationCanceled(reason.error)); + +// True when a cause contains AT LEAST ONE interrupt reason, even when mixed with +// failures or defects. Cause.hasInterrupts is the idiomatic effect-4 "contains +// any interrupt" primitive (as opposed to hasInterruptsOnly, which is true only +// when EVERY reason is an interrupt). Any interrupt component means a host +// shutdown is in flight, so it must win over the sentinel/error branches and +// re-raise. Exported for unit testing over hand-built causes. +export const causeContainsInterrupt = (cause: Cause.Cause): boolean => + Cause.hasInterrupts(cause); + export class PluginCapabilityUnavailable extends Schema.TaggedErrorClass()( "PluginCapabilityUnavailable", { capability: Schema.String }, @@ -99,7 +163,11 @@ export class PluginHost extends Context.Service< PluginHost, { readonly start: Effect.Effect; - readonly activatePlugin: (pluginId: PluginId) => Effect.Effect; + // A lockfile read/parse failure now propagates (previously swallowed into a + // silent no-op); callers treat it as an activation failure. + readonly activatePlugin: ( + pluginId: PluginId, + ) => Effect.Effect; readonly deactivatePlugin: (pluginId: PluginId) => Effect.Effect; } >()("t3/plugins/PluginHost") {} @@ -120,12 +188,17 @@ const resolveRegistration = ( return Effect.succeed(value); }).pipe( Effect.catchCause((cause) => - Effect.fail( - new PluginRegistrationError({ - pluginId, - detail: Cause.pretty(cause), - }), - ), + // A clean host shutdown interrupts the activation fiber mid-register(); + // let that interruption propagate instead of persisting a spurious + // "failed" registration error. + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail( + new PluginRegistrationError({ + pluginId, + detail: Cause.pretty(cause), + }), + ), ), ); @@ -133,31 +206,56 @@ function validateRegistration( pluginId: PluginId, registration: PluginRegistration, ): Effect.Effect { - const methods = new Set(); + // Plugin modules are dynamically loaded JS, so the SDK's `"read" | "operate"` + // scope type is NOT enforced at runtime. Validate rpc AND streams identically + // here: an out-of-whitelist scope (e.g. a typo/casing like "Operate") must be + // rejected at registration rather than silently fall through to the weaker + // read requirement in the dispatcher. + const rpcMethods = 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)) { + if (rpcMethods.has(rpc.method)) { return Effect.fail( new PluginRegistrationError({ pluginId, detail: `duplicate RPC method ${rpc.method}` }), ); } - methods.add(rpc.method); + rpcMethods.add(rpc.method); + } + const streamMethods = new Set(); + for (const stream of registration.streams ?? []) { + if (stream.scope !== "read" && stream.scope !== "operate") { + return Effect.fail( + new PluginRegistrationError({ pluginId, detail: `invalid stream scope ${stream.scope}` }), + ); + } + if (streamMethods.has(stream.method)) { + return Effect.fail( + new PluginRegistrationError({ + pluginId, + detail: `duplicate stream method ${stream.method}`, + }), + ); + } + streamMethods.add(stream.method); } return Effect.void; } const unavailable = (capability: string) => - Effect.die(new PluginCapabilityUnavailable({ capability })); + // Typed failure (not a defect) so a plugin that calls an undeclared capability + // can catch/degrade gracefully instead of crashing the call as a defect. + Effect.fail(new PluginCapabilityUnavailable({ capability })); const makeHostApi = (input: { readonly pluginId: PluginId; readonly capabilities: ReadonlyArray; readonly dataDir: string; readonly logger: PluginLogger; + readonly grants: PluginWorkspaceGrants; readonly deps: { readonly sql: SqlClient.SqlClient; readonly secretStore: ServerSecretStore.ServerSecretStore["Service"]; @@ -177,6 +275,8 @@ const makeHostApi = (input: { readonly sourceControlRegistry: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; readonly github: GitHubCli.GitHubCli["Service"]; readonly terminals: TerminalManager.TerminalManager["Service"]; + readonly outboundLookup: OutboundUrlLookup["Service"]; + readonly httpClientTransport: PluginHttpClientTransportService["Service"]; }; }): { readonly api: PluginHostApi; readonly teardown: ReadonlyArray> } => { const capabilities = new Set(input.capabilities); @@ -216,6 +316,11 @@ const makeHostApi = (input: { makeVcsCapability({ git: input.deps.git, checkpoints: input.deps.checkpointStore, + snapshots: input.deps.snapshots, + grants: input.grants, + fileSystem: input.deps.fileSystem, + path: input.deps.path, + worktreesDir: input.deps.config.worktreesDir, }), ), terminals: available("terminals", terminalsBundle.capability), @@ -247,11 +352,29 @@ const makeHostApi = (input: { }), ), http: available("http", makeHttpCapability(input.pluginId)), + filesystem: available( + "filesystem", + makeFilesystemCapability({ + snapshots: input.deps.snapshots, + grants: input.grants, + }), + ), + httpClient: available( + "httpClient", + makeHttpClientCapability({ + lookup: input.deps.outboundLookup, + transport: input.deps.httpClientTransport, + }), + ), sourceControl: available( "sourceControl", makeSourceControlCapability({ registry: input.deps.sourceControlRegistry, github: input.deps.github, + snapshots: input.deps.snapshots, + grants: input.grants, + fileSystem: input.deps.fileSystem, + path: input.deps.path, }), ), textGeneration: available( @@ -272,9 +395,14 @@ const upgradeLockfileEntry = ( sourceId: entry.sourceId, enabled: entry.enabled, state: "active", - activation: entry.activation, + // Reset activation health for the new build. Carrying over the old version's + // crashCount could immediately trip the repeated-crash safe mode on the first + // startup of the upgrade, and its lastError would surface a stale failure the + // new version never produced. activatingSince starts null; loadPlugin sets it + // when the fresh activation begins. + activation: { activatingSince: null, crashCount: 0 }, installedAt: entry.installedAt, - lastError: entry.lastError, + lastError: null, }); const getLockfilePlugin = (lockfile: PluginLockfile, pluginId: PluginId) => @@ -290,7 +418,11 @@ const updateFailure = ( current ? { ...current, - state: "failed", + // Only an in-flight activation ("active") should flip to "failed". + // If the user concurrently disabled/uninstalled/upgraded the plugin + // while it was activating, preserve that requested lifecycle state + // rather than clobbering it with "failed". + state: current.state === "active" ? "failed" : current.state, lastError: message, activation: { ...current.activation, @@ -345,8 +477,37 @@ export const make = Effect.fn("PluginHost.make")(function* () { const sourceControlRegistry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const github = yield* GitHubCli.GitHubCli; const terminals = yield* TerminalManager.TerminalManager; + const outboundLookup = yield* OutboundUrlLookup; + const httpClientTransport = yield* PluginHttpClientTransportService; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + // Per-pluginId single-flight for activation/deactivation. Two concurrent + // activatePlugin(pluginId) calls would otherwise both observe an empty registry + // and both run loadPlugin; the second registry.put overwrites the first runtime's + // entry, orphaning its scope — the first runtime's forked services, + // terminal-cleanup finalizers, and HTTP routes stay live but unreachable, and a + // later deactivatePlugin only tears down the second. A per-plugin single-permit + // semaphore, get-or-created atomically under a synchronized ref, serializes them + // so the second caller's registry.get double-check short-circuits instead of + // loading a second runtime. + const activationLocks = yield* SynchronizedRef.make( + HashMap.empty(), + ); + const withPluginActivationLock = ( + pluginId: PluginId, + effect: Effect.Effect, + ): Effect.Effect => + SynchronizedRef.modifyEffect(activationLocks, (locks) => { + const existing = HashMap.get(locks, pluginId); + return Option.isSome(existing) + ? Effect.succeed([existing.value, locks] as const) + : Semaphore.make(1).pipe( + Effect.map( + (semaphore) => [semaphore, HashMap.set(locks, pluginId, semaphore)] as const, + ), + ); + }).pipe(Effect.flatMap((semaphore) => semaphore.withPermits(1)(effect))); + const publishPluginStateChanged = (pluginId: PluginId, state: PluginState) => lifecycleEvents .publish({ @@ -362,9 +523,59 @@ export const make = Effect.fn("PluginHost.make")(function* () { const markFailure = (pluginId: PluginId, message: string) => updateFailure(store, pluginId, message).pipe( - Effect.tap(() => publishPluginStateChanged(pluginId, "failed")), + // Publish the state that was actually persisted: updateFailure preserves a + // concurrently-requested lifecycle state (disabled/pending-remove/...) + // instead of forcing "failed", so announce that rather than a stale + // "failed". + Effect.flatMap((lockfile) => { + const state = getLockfilePlugin(lockfile, pluginId)?.state; + return state === undefined ? Effect.void : publishPluginStateChanged(pluginId, state); + }), ); + // Clear ONLY the in-flight activation marker, preserving state/crashCount/ + // lastError. Used after a clean interrupt/cancel teardown so reconcile on the + // next start does not mistake the intentional cancellation for a crash (a + // lingering activatingSince bumps crashCount and eventually forces "failed"). + const clearActivatingMarker = (pluginId: PluginId) => + store + .updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { ...current, activation: { ...current.activation, activatingSince: null } } + : undefined, + ), + ) + .pipe(Effect.ignore); + + // Outer handler for a loadPlugin failure that escaped the activation-exit + // block (setup errors, or a re-raised interrupt/cancel from that block). + // Three dispositions, checked in order: + // - contains ANY interrupt (clean shutdown / scope close, even when mixed + // with the sentinel or a teardown error): re-raise the whole cause so it + // keeps propagating and the host stops promptly. + // - EXCLUSIVELY the activation-cancel sentinel (concurrent disable/uninstall + // aborted the activation): benign — the teardown already ran and the marker + // was cleared, so just log and swallow, leaving the persisted state intact. + // - anything else (a genuine error, INCLUDING the sentinel combined with a + // real teardown/finalizer error): persist "failed" and log. + const handleLoadFailureCause = ( + pluginId: PluginId, + logMessage: string, + cause: Cause.Cause, + ) => + causeContainsInterrupt(cause) + ? Effect.failCause(cause as Cause.Cause) + : causeIsActivationCanceledOnly(cause) + ? Effect.logWarning("Plugin activation canceled", { + pluginId, + cause: Cause.pretty(cause), + }).pipe(Effect.ignore) + : markFailure(pluginId, Cause.pretty(cause)).pipe( + Effect.andThen(Effect.logWarning(logMessage, { pluginId, cause: Cause.pretty(cause) })), + Effect.ignore, + ); + const readManifest = (pluginDir: string) => fs .readFileString(pluginManifestPath(pluginDir, path.join)) @@ -380,6 +591,17 @@ export const make = Effect.fn("PluginHost.make")(function* () { detail: `manifest id ${manifest.id} does not match lockfile id`, }); } + // The plugin is loaded from the lockfile version's directory, so a manifest + // whose version disagrees with the lockfile entry means the staged bytes do + // not match the recorded install: runtime/catalog state would report a + // different version than the lockfile/upgrade state, and migrations (keyed + // only by plugin id) would run against ambiguous provenance. Reject it. + if (manifest.version !== entry.version) { + return yield* new PluginRegistrationError({ + pluginId, + detail: `manifest version ${manifest.version} does not match lockfile version ${entry.version}`, + }); + } if (!hostApiSatisfies(manifest.hostApi, HOST_API_VERSION)) { yield* store .updatePlugin(pluginId, ({ current }) => @@ -424,11 +646,13 @@ export const make = Effect.fn("PluginHost.make")(function* () { const readiness = yield* Deferred.make(); const logger = makePluginLogger(pluginId); const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); + const grants = yield* makePluginWorkspaceGrants; const { api: hostApi, teardown: hostApiTeardown } = makeHostApi({ pluginId, capabilities: manifest.capabilities, dataDir, logger, + grants, deps: { sql, secretStore, @@ -448,6 +672,8 @@ export const make = Effect.fn("PluginHost.make")(function* () { sourceControlRegistry, github, terminals, + outboundLookup, + httpClientTransport, }, }); @@ -460,16 +686,38 @@ export const make = Effect.fn("PluginHost.make")(function* () { } yield* fs.makeDirectory(dataDir, { recursive: true }); const definition = yield* loader.loadServerEntry(pluginDir, serverEntry); - const registration = yield* resolveRegistration(pluginId, definition, hostApi); + const registration = yield* resolveRegistration(pluginId, definition, hostApi).pipe( + Effect.timeout(registrationTimeout()), + ); yield* validateRegistration(pluginId, registration); yield* migrator.run(pluginId, registration.migrations ?? []); if (registration.recover) { - yield* registration.recover(); + yield* registration.recover().pipe(Effect.timeout(registrationTimeout())); } if (manifest.capabilities.includes("http") && (registration.http?.length ?? 0) > 0) { yield* httpRegistry.put(pluginId, registration.http ?? []); yield* Scope.addFinalizer(scope, httpRegistry.remove(pluginId)); } + // Re-check lifecycle state right before publishing the runtime. A + // concurrent disable/uninstall flips the lockfile and runs + // deactivatePlugin, which finds no runtime yet (we have not put it) and + // returns early. Without this guard activation would finish and the + // now-disabled/pending-remove plugin's runtime + services + HTTP routes + // would go live anyway. Abort via the typed cancel sentinel (NOT a fiber + // interrupt) so the failure branch closes the scope (removing any partial + // HTTP registration), skips the registry.put + "active" publish, clears + // the activating marker, and leaves the persisted state intact — while + // staying distinguishable from a genuine host-shutdown interruption. + const stateBeforePut = yield* store.readLockfile.pipe( + Effect.map((current) => getLockfilePlugin(current, pluginId)?.state), + Effect.orElseSucceed(() => undefined as PluginState | undefined), + ); + if (stateBeforePut !== "active") { + return yield* new PluginActivationCanceled({ + pluginId, + reason: `lifecycle state changed to ${stateBeforePut ?? "missing"} during activation`, + }); + } yield* registry.put(pluginId, { manifest, registration, readiness, scope }); for (const service of registration.services ?? []) { yield* startService({ pluginId, logger, service }).pipe( @@ -478,23 +726,36 @@ export const make = Effect.fn("PluginHost.make")(function* () { ); } 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, - ), - ); + // Clear activatingSince immediately on successful activation. Activation + // has COMPLETED, so the plugin is no longer "activating"; leaving the + // marker set until the delayed healthy-clear fires would make an + // unrelated process restart within the stability window look like an + // interrupted activation, wrongly incrementing crashCount and eventually + // failing a healthy plugin. crashCount is still only forgiven (reset to + // 0) after the stability window, so genuine activation-time crash loops + // keep accumulating across restarts. + const markActivated = (forgiveCrashes: boolean) => + store.updatePlugin(pluginId, ({ current }) => + Effect.succeed( + current + ? { + ...current, + activation: { + activatingSince: null, + crashCount: forgiveCrashes ? 0 : current.activation.crashCount, + }, + lastError: null, + } + : undefined, + ), + ); + yield* markActivated(false); const healthyDelay = healthyActivationDelay(); if (Duration.toMillis(healthyDelay) === 0) { - yield* clearHealthyActivation; + yield* markActivated(true); } else { yield* clock.sleep(healthyDelay).pipe( - Effect.flatMap(() => clearHealthyActivation), + Effect.flatMap(() => markActivated(true)), Effect.ignoreCause({ log: true }), Effect.forkScoped, Scope.provide(scope), @@ -502,15 +763,70 @@ export const make = Effect.fn("PluginHost.make")(function* () { } }); - 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* markFailure(pluginId, message); - yield* Effect.logWarning("Plugin activation failed", { pluginId, cause: message }); - } else { - yield* publishPluginStateChanged(pluginId, "active"); - } + // effect@4.0.0-beta.78's `exitFailCause` evaluator skips EVERY `contE` + // continuation while an EXTERNAL fiber interrupt is pending and the fiber is + // interruptible (internal/core.ts: `while (fiber.interruptible && + // fiber._interruptedCause && cont)`). `Effect.exit` is a plain contE/contA + // primitive with no `contAll`, so a real host-shutdown interrupt of a + // still-interruptible `activation` would unwind straight past this capture: + // the exit ladder below (Scope.close terminal-teardown + httpRegistry.remove + // finalizers, registry.remove, clearActivatingMarker) would never run, + // leaking service fibers/terminals into a never-closed detached scope and + // stranding `activatingSince` so reconcile phantom-crashes a healthy plugin. + // (Internal interrupts — Effect.interrupt, Effect.timeout's child — are + // captured fine, which is all the older tests covered.) Running the capture + + // ladder inside `uninterruptibleMask` (with `restore` keeping `activation` + // itself interruptible so shutdown still stops register()/services promptly) + // makes the teardown fire on external interrupt too, and the interrupt still + // propagates once the mask is left because it stays pending. + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const exit = yield* restore(activation.pipe(Scope.provide(scope))).pipe(Effect.exit); + if (Exit.isFailure(exit)) { + yield* Scope.close(scope, exit); + // Activation may have already inserted the runtime into the registry + // (registry.put runs mid-activation, before later steps). On failure the + // scope is now closed, so drop the stale entry — otherwise registry.get + // and registry.list keep reporting the plugin as active with a dead + // scope and an unresolved readiness Deferred. + yield* registry.remove(pluginId).pipe(Effect.ignore); + if (causeContainsInterrupt(exit.cause)) { + // ANY interruption (a clean host shutdown / scope close / stop), even + // when mixed with the cancel sentinel or a teardown error. The teardown + // above already ran, so this is NOT a crash — clear the activating + // marker so reconcile on the next start does not miscount it, then + // re-raise the whole cause so the persisted lifecycle state stays intact + // and the host stops promptly. + yield* clearActivatingMarker(pluginId); + return yield* Effect.failCause(exit.cause as Cause.Cause); + } + if (causeIsActivationCanceledOnly(exit.cause)) { + // EXCLUSIVELY the cancel sentinel: a concurrent disable/uninstall + // cancelled this activation via the pre-put re-check. The teardown above + // already ran (not a crash) — clear the activating marker and re-raise + // the typed sentinel so callers can tell it apart from a genuine error + // (do NOT mark "failed") and from a shutdown interrupt (handled above). + yield* clearActivatingMarker(pluginId); + return yield* Effect.failCause(exit.cause as Cause.Cause); + } + // Genuine error — INCLUDING the sentinel combined with a real teardown/ + // finalizer error/defect. Do NOT drop it: persist "failed". + const message = Cause.pretty(exit.cause); + yield* markFailure(pluginId, message); + yield* Effect.logWarning("Plugin activation failed", { pluginId, cause: message }); + return; + } + // Announce the state actually persisted, not a hardcoded "active", so a + // concurrent disable/uninstall (which flips the lockfile and runs + // deactivatePlugin after registry.put but before this publish) isn't + // contradicted. + const persistedState = yield* store.readLockfile.pipe( + Effect.map((lockfile) => getLockfilePlugin(lockfile, pluginId)?.state ?? "active"), + Effect.orElseSucceed(() => "active" as PluginState), + ); + yield* publishPluginStateChanged(pluginId, persistedState); + }), + ); }); const activatePlugin: PluginHost["Service"]["activatePlugin"] = (pluginId) => @@ -519,43 +835,58 @@ export const make = Effect.fn("PluginHost.make")(function* () { yield* Effect.logInfo("Plugin host disabled by T3_NO_PLUGINS", { pluginId }); return; } - const active = yield* registry.get(pluginId); - if (Option.isSome(active)) return; - const lockfile = yield* store.readLockfile.pipe( - Effect.catchCause((cause) => - Effect.logWarning("Plugin hot activation could not read lockfile", { - pluginId, - cause: Cause.pretty(cause), - }).pipe(Effect.as({ plugins: {}, sources: [] })), - ), - ); - const entry = getLockfilePlugin(lockfile, pluginId); - if (!entry?.enabled || entry.state !== "active") return; - yield* loader.ensureHostSingletonResolution; - yield* loadPlugin(pluginId, entry).pipe( - Effect.catchCause((cause) => - markFailure(pluginId, Cause.pretty(cause)).pipe( - Effect.andThen( - Effect.logWarning("Plugin hot activation failed", { - pluginId, - cause: Cause.pretty(cause), - }), + // Single-flight per pluginId (see activationLocks): a second concurrent + // activatePlugin waits for the first to finish, then its registry.get + // double-check returns Some and short-circuits — no second loadPlugin, no + // leaked runtime. The registry.get early-return stays INSIDE the lock. + yield* withPluginActivationLock( + pluginId, + Effect.gen(function* () { + const active = yield* registry.get(pluginId); + if (Option.isSome(active)) return; + // Propagate a lockfile read/parse failure instead of substituting an empty + // lockfile: an empty lockfile makes getLockfilePlugin return undefined and + // the guard below no-op, so activatePlugin would SUCCEED without loading + // anything and callers (PluginInstaller) would treat a failed + // install/enable as done. A genuinely MISSING lockfile is already handled + // inside readLockfile (it returns EMPTY_PLUGIN_LOCKFILE for a null read), + // so this only surfaces real read/parse errors. + const lockfile = yield* store.readLockfile; + const entry = getLockfilePlugin(lockfile, pluginId); + if (!entry?.enabled || entry.state !== "active") return; + yield* loader.ensureHostSingletonResolution; + yield* loadPlugin(pluginId, entry).pipe( + Effect.catchCause((cause) => + handleLoadFailureCause(pluginId, "Plugin hot activation failed", cause), ), - Effect.ignore, - ), - ), + ); + }), ); }); const deactivatePlugin: PluginHost["Service"]["deactivatePlugin"] = (pluginId) => - Effect.gen(function* () { - const runtime = yield* registry.get(pluginId); - if (Option.isNone(runtime)) return; - yield* Scope.close(runtime.value.scope, Exit.void).pipe(Effect.ignore); - yield* registry.remove(pluginId); - yield* httpRegistry.remove(pluginId).pipe(Effect.ignore); - yield* publishPluginStateChanged(pluginId, "disabled"); - }); + // Share the per-plugin activation lock so an activate/deactivate pair for one + // plugin can't interleave (the round-4/5 pre-put re-check + persisted-state + // publish remain as defense-in-depth). Neither path calls the other while + // holding the lock, so this cannot deadlock. + withPluginActivationLock( + pluginId, + Effect.gen(function* () { + const runtime = yield* registry.get(pluginId); + if (Option.isNone(runtime)) return; + yield* Scope.close(runtime.value.scope, Exit.void).pipe(Effect.ignore); + yield* registry.remove(pluginId); + yield* httpRegistry.remove(pluginId).pipe(Effect.ignore); + // Announce the state that is actually persisted rather than a hardcoded + // "disabled": uninstall sets "pending-remove" then calls this, and + // publishing "disabled" would contradict the lockfile + list APIs. + const persistedState = yield* store.readLockfile.pipe( + Effect.map((lockfile) => getLockfilePlugin(lockfile, pluginId)?.state ?? "disabled"), + Effect.orElseSucceed(() => "disabled" as PluginState), + ); + yield* publishPluginStateChanged(pluginId, persistedState); + }), + ); const reconcilePendingState = (pluginId: PluginId, entry: PluginLockfilePlugin) => Effect.gen(function* () { @@ -563,21 +894,29 @@ export const make = Effect.fn("PluginHost.make")(function* () { const pluginRoot = path.join(config.pluginsDir, pluginId); const dataDir = pluginDataDir(config.pluginsDir, pluginId, path.join); const markerPath = path.join(pluginRoot, PRESERVE_DATA_MARKER); - const preserveData = yield* fs.exists(markerPath).pipe(Effect.orElseSucceed(() => false)); - const preservedDataDir = path.join( - config.pluginsDir, - `.preserved-${pluginId}-${yield* clock.currentTimeMillis}`, - ); - if (preserveData && (yield* fs.exists(dataDir).pipe(Effect.orElseSucceed(() => false)))) { - yield* fs.rename(dataDir, preservedDataDir); - } - yield* fs.remove(pluginRoot, { recursive: true, force: true }); - if ( - preserveData && - (yield* fs.exists(preservedDataDir).pipe(Effect.orElseSucceed(() => false))) - ) { - yield* fs.makeDirectory(pluginRoot, { recursive: true }); - yield* fs.rename(preservedDataDir, dataDir); + const exists = (target: string) => + fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + // Deterministic (NOT timestamped) preserve path. A crash between the + // rename-out and rename-back must be recoverable on the next start; a + // per-attempt `.preserved--` path stranded the data forever + // because a retry computed a fresh timestamp and never found the old dir. + const preservedDataDir = path.join(config.pluginsDir, `.preserved-${pluginId}`); + // A leftover preserved dir is proof a prior reconcile of THIS plugin + // crashed after moving data aside — adopt it as the preserve intent even + // if the marker (which lived inside the now-removed root) is already gone. + const preservedAlready = yield* exists(preservedDataDir); + const preserveData = preservedAlready || (yield* exists(markerPath)); + if (preserveData) { + if (!preservedAlready && (yield* exists(dataDir))) { + yield* fs.rename(dataDir, preservedDataDir); + } + yield* fs.remove(pluginRoot, { recursive: true, force: true }); + if (yield* exists(preservedDataDir)) { + yield* fs.makeDirectory(pluginRoot, { recursive: true }); + yield* fs.rename(preservedDataDir, dataDir); + } + } else { + yield* fs.remove(pluginRoot, { recursive: true, force: true }); } yield* store.removePlugin(pluginId); return false; @@ -660,22 +999,78 @@ export const make = Effect.fn("PluginHost.make")(function* () { 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) => - markFailure(pluginId, Cause.pretty(cause)).pipe( - Effect.andThen( - Effect.logWarning("Plugin activation failed before scope acquisition", { - pluginId, - cause: Cause.pretty(cause), - }), + // Route the start-loop activation through the SAME per-plugin single-flight + // lock as activatePlugin, with a registry.get double-check inside it. + // Commands are accepted before the host finishes starting, and + // PluginInstaller.setEnabled/confirmInstall call host.activatePlugin on the + // RPC fiber. Without this lock, an enable/install RPC racing the start loop + // for the SAME plugin runs two concurrent loadPlugin: register() twice, + // migrator.run twice (the second INSERT INTO plugin_migrations PK-conflicts → + // spurious "failed"), and the second registry.put orphans the first runtime's + // scope. The lock serializes them; the registry.get double-check + // short-circuits the loser instead of loading a second runtime. + yield* withPluginActivationLock( + pluginId, + Effect.gen(function* () { + const active = yield* registry.get(pluginId); + if (Option.isSome(active)) return; + yield* loadPlugin(pluginId, currentEntry).pipe( + Effect.catchCause((cause) => + // A pure per-plugin self-cancel (the pre-put state re-check firing the + // typed PluginActivationCanceled sentinel, and ONLY that, for ONE + // plugin) is benign: log and CONTINUE so the remaining plugins still + // activate. Everything else goes through handleLoadFailureCause, which + // RE-RAISES any interrupt-containing cause (host shutdown, even mixed + // with the sentinel) — that propagates out of this loop so the trailing + // Effect.ignoreCause ends start promptly instead of plodding through the + // rest of the plugins during shutdown — and marks a real error + // (including the sentinel combined with a teardown error) as "failed". + causeIsActivationCanceledOnly(cause) + ? Effect.logWarning("Plugin activation canceled during start; skipping", { + pluginId, + cause: Cause.pretty(cause), + }) + : handleLoadFailureCause( + pluginId, + "Plugin activation failed before scope acquisition", + cause, + ), ), - Effect.ignore, - ), - ), + ); + }), ); } }).pipe(Effect.ignoreCause({ log: true })); + // Close every active plugin's runtime scope at host teardown. Each activation + // creates a DETACHED scope (Scope.make in loadPlugin) that nothing else owns, + // so without this a dev-restart / desktop reload / test teardown leaves the + // service fibers, terminal-cleanup finalizers, and HTTP-route registrations of + // still-active plugins leaked in-process. Registered as a layer-scope finalizer + // (Layer.effect runs make in the layer's build scope), so it fires when the + // server runtime is torn down. Direct scope close — not deactivatePlugin — to + // avoid acquiring per-plugin locks or publishing lifecycle events while the + // whole server is going down. + yield* Effect.addFinalizer(() => + registry.list.pipe( + Effect.flatMap((runtimes) => + Effect.forEach( + runtimes, + (runtime) => { + const pluginId = runtime.manifest.id as PluginId; + return Scope.close(runtime.scope, Exit.void).pipe( + Effect.ignore, + Effect.andThen(httpRegistry.remove(pluginId).pipe(Effect.ignore)), + Effect.andThen(registry.remove(pluginId)), + ); + }, + { concurrency: "unbounded", discard: true }, + ), + ), + Effect.ignore, + ), + ); + return PluginHost.of({ start, activatePlugin, deactivatePlugin }); }); diff --git a/apps/server/src/plugins/PluginHttpRoutes.test.ts b/apps/server/src/plugins/PluginHttpRoutes.test.ts index 27b4a10a347..24f88458139 100644 --- a/apps/server/src/plugins/PluginHttpRoutes.test.ts +++ b/apps/server/src/plugins/PluginHttpRoutes.test.ts @@ -2,8 +2,10 @@ 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 type { PluginHttpDescriptor, PluginHttpResponse } from "@t3tools/plugin-sdk"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { @@ -17,7 +19,8 @@ import { import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import { PluginHttpRegistry } from "./PluginHttpRegistry.ts"; import * as PluginHttpRegistryLayer from "./PluginHttpRegistry.ts"; -import { pluginHttpRouteLayer } from "./PluginHttpRoutes.ts"; +import { pluginHttpRouteLayer, respondToPluginHandlerExit } from "./PluginHttpRoutes.ts"; +import { makePluginLogger } from "./PluginLogger.ts"; const pluginId = PluginId.make("http-plugin"); @@ -179,6 +182,50 @@ it.layer(PluginHttpRegistryLayer.layer)("PluginHttpRegistry", (it) => { ); }); +describe("respondToPluginHandlerExit", () => { + const logger = makePluginLogger(pluginId); + const context = { method: "POST", path: "/hook" }; + + it.effect("maps a successful handler exit to its HTTP response", () => + Effect.gen(function* () { + const response = yield* respondToPluginHandlerExit( + Exit.succeed({ status: 201, body: "ok" } satisfies PluginHttpResponse), + logger, + context, + ); + assert.equal(response.status, 201); + }), + ); + + it.effect("converts a genuine handler failure into a 500", () => + Effect.gen(function* () { + const exit = (yield* Effect.exit(Effect.die(new Error("boom")))) as Exit.Exit< + PluginHttpResponse, + Error + >; + const response = yield* respondToPluginHandlerExit(exit, logger, context); + assert.equal(response.status, 500); + }), + ); + + it.effect("re-raises an interrupt instead of answering a 500 to a dead socket", () => + Effect.gen(function* () { + const interruptedExit = (yield* Effect.exit(Effect.interrupt)) as Exit.Exit< + PluginHttpResponse, + Error + >; + const outcome = yield* Effect.exit( + respondToPluginHandlerExit(interruptedExit, logger, context), + ); + assert.isTrue(Exit.isFailure(outcome)); + if (Exit.isFailure(outcome)) { + // Propagated as an interrupt — not swallowed into a 500 response value. + assert.isTrue(Cause.hasInterruptsOnly(outcome.cause)); + } + }), + ); +}); + if (loopbackAvailable) { it.layer(makeRouteLayer())("plugin http route layer", (it) => { it.effect("round-trips a public route through the router", () => @@ -211,6 +258,28 @@ if (loopbackAvailable) { }), ); + it.effect("reaches a root '/' route with and without a trailing slash", () => + Effect.gen(function* () { + const registry = yield* PluginHttpRegistry; + yield* registry.put(pluginId, [ + { + method: "POST", + path: "/", + auth: "public", + handler: () => Effect.succeed({ status: 200, body: "root" }), + }, + ]); + + const withoutSlash = yield* postText("/hooks/plugins/http-plugin", ""); + assert.equal(withoutSlash.status, 200); + assert.equal(yield* withoutSlash.text, "root"); + + const withSlash = yield* postText("/hooks/plugins/http-plugin/", ""); + assert.equal(withSlash.status, 200); + assert.equal(yield* withSlash.text, "root"); + }), + ); + it.effect("returns 413 when the request body exceeds the route cap", () => Effect.gen(function* () { const registry = yield* PluginHttpRegistry; diff --git a/apps/server/src/plugins/PluginHttpRoutes.ts b/apps/server/src/plugins/PluginHttpRoutes.ts index 1a9a8f7b450..4cfd2283854 100644 --- a/apps/server/src/plugins/PluginHttpRoutes.ts +++ b/apps/server/src/plugins/PluginHttpRoutes.ts @@ -1,8 +1,9 @@ import { pluginOperateScope, satisfiesScope } from "@t3tools/contracts"; -import type { PluginId } from "@t3tools/contracts/plugin"; -import type { PluginHttpResponse } from "@t3tools/plugin-sdk"; +import { PLUGIN_ID_PATTERN_SOURCE, type PluginId } from "@t3tools/contracts/plugin"; +import type { PluginHttpResponse, PluginLogger } from "@t3tools/plugin-sdk"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -25,7 +26,10 @@ 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; +// Derive the inbound-route id gate from the same source the `PluginId` schema +// checks against, so the two can never drift: if they did, `HttpCapability` +// would hand a plugin a `basePath` this router rejects, 404-ing every webhook. +const PLUGIN_ID_PATTERN = new RegExp(`^${PLUGIN_ID_PATTERN_SOURCE}$`, "u"); function bodyLimit(value: number | undefined): number { if (value === undefined || !Number.isFinite(value)) return DEFAULT_MAX_BODY_BYTES; @@ -138,6 +142,36 @@ function toHttpResponse(response: PluginHttpResponse): HttpServerResponse.HttpSe return HttpServerResponse.jsonUnsafe(body, options); } +// Re-raise an interrupt-only cause without widening the typed error channel: +// `Cause.hasInterruptsOnly` guarantees the cause carries no failures/defects, so +// narrowing to `Cause` is sound. +const propagateInterrupt = (cause: Cause.Cause): Effect.Effect => + Effect.failCause(cause as Cause.Cause); + +// A handler — or the surrounding route — that is interrupted (client disconnect, +// server/plugin-scope shutdown) must PROPAGATE cancellation, not get logged as a +// failure and answered with a 500 to a dead socket. Convert only genuine +// failures/defects into a 500; re-raise interrupt-only causes. +export const respondToPluginHandlerExit = ( + exit: Exit.Exit, + logger: PluginLogger, + context: { readonly method: string; readonly path: string }, +): Effect.Effect => { + if (Exit.isSuccess(exit)) { + return Effect.succeed(toHttpResponse(exit.value)); + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return propagateInterrupt(exit.cause); + } + return logger + .error("plugin http handler failed", { + method: context.method, + path: context.path, + cause: Cause.pretty(exit.cause), + }) + .pipe(Effect.as(HttpServerResponse.text("Internal Server Error", { status: 500 }))); +}; + export const pluginHttpRouteLayer = HttpRouter.add( "*", `${ROUTE_PREFIX}/*`, @@ -205,16 +239,10 @@ export const pluginHttpRouteLayer = HttpRouter.add( ) .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); + return yield* respondToPluginHandlerExit(exit, logger, { + method: request.method, + path: parsed.routePath, + }); }).pipe( Effect.catchTags({ EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, @@ -222,9 +250,13 @@ export const pluginHttpRouteLayer = HttpRouter.add( 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 })), - ), + // A cancelled request (client disconnect / scope shutdown) interrupts the + // whole route: propagate it rather than logging + 500-ing a dead socket. + Cause.hasInterruptsOnly(cause) + ? propagateInterrupt(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/PluginInstaller.test.ts b/apps/server/src/plugins/PluginInstaller.test.ts index 16d86b1c647..4229a171fff 100644 --- a/apps/server/src/plugins/PluginInstaller.test.ts +++ b/apps/server/src/plugins/PluginInstaller.test.ts @@ -12,11 +12,13 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as NodeCrypto from "node:crypto"; import * as NodeZlib from "node:zlib"; import * as ServerConfig from "../config.ts"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { OutboundUrlError, OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import { PluginCatalog } from "./PluginCatalog.ts"; import { PluginHost } from "./PluginHost.ts"; import { PluginInstaller } from "./PluginInstaller.ts"; @@ -117,55 +119,72 @@ const tarballForManifestJson = ( ...extraEntries, ]); -const marketplaceJson = (sha: string, version = "1.0.0") => ({ +const marketplaceJson = (sha: string, versions: ReadonlyArray = ["1.0.0"]) => ({ plugins: [ { id: pluginId, name: "Test Plugin", description: "Adds tests.", capabilities: ["agents" as const], - versions: [ - { - version, - tarball: tarballUrl, - sha256: sha, - hostApi: "^1.0.0", - publishedAt: "2026-07-03T00:00:00.000Z", - }, - ], + versions: versions.map((version) => ({ + version, + tarball: tarballUrl, + sha256: sha, + hostApi: "^1.0.0", + publishedAt: "2026-07-03T00:00:00.000Z", + })), }, ], }); -function installerLayer(input: { +interface InstallerLayerInput { readonly tarball: Uint8Array; readonly marketplaceSha?: string; + readonly marketplaceVersions?: ReadonlyArray; readonly activated?: Array; -}) { + readonly deactivated?: Array; + /** Host → pinned address returned by the stub DNS lookup. */ + readonly hosts?: Record; + /** URL → response override, checked before the default marketplace/tarball routes. */ + readonly responses?: Record Response>; + /** Receives every URL the stub transport is asked to fetch. */ + readonly transportLog?: Array; +} + +function installerDeps(input: InstallerLayerInput) { const platform = NodeServices.layer; const config = ServerConfig.layerTest(process.cwd(), { prefix: "t3-installer-" }).pipe( Layer.provide(platform), ); - const marketplace = marketplaceJson(input.marketplaceSha ?? sha256(input.tarball)); - const marketplaceBody = encodeMarketplaceJson(marketplace); - const http = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - const url = request.url.toString(); - if (url === "https://market.test/marketplace.json") { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(marketplaceBody, { headers: { "content-type": "application/json" } }), - ), - ); - } - if (url === tarballUrl) { - return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(input.tarball))); - } - return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 404 }))); - }), + const marketplace = marketplaceJson( + input.marketplaceSha ?? sha256(input.tarball), + input.marketplaceVersions, ); + const marketplaceBody = encodeMarketplaceJson(marketplace); + const hosts = input.hosts ?? { "market.test": "93.184.216.34" }; + const lookup = Layer.succeed(OutboundUrlLookup, (host: string) => { + const address = hosts[host]; + return address === undefined + ? Effect.fail(new OutboundUrlError({ reason: `unexpected lookup ${host}` })) + : Effect.succeed([{ address, family: 4 as const }]); + }); + const transport = Layer.succeed(PluginHttpClientTransportService, (request) => { + const url = request.url.toString(); + input.transportLog?.push(url); + const respond = (response: Response) => + Effect.succeed(HttpClientResponse.fromWeb(HttpClientRequest.get(url), response)); + const override = input.responses?.[url]; + if (override) return respond(override()); + if (url === "https://market.test/marketplace.json") { + return respond( + new Response(marketplaceBody, { headers: { "content-type": "application/json" } }), + ); + } + if (url === tarballUrl) { + return respond(new Response(input.tarball)); + } + return respond(new Response("", { status: 404 })); + }); const host = Layer.succeed( PluginHost, PluginHost.of({ @@ -174,7 +193,10 @@ function installerLayer(input: { Effect.sync(() => { input.activated?.push(id); }), - deactivatePlugin: () => Effect.void, + deactivatePlugin: (id) => + Effect.sync(() => { + input.deactivated?.push(id); + }), }), ); const catalog = Layer.succeed( @@ -188,23 +210,28 @@ function installerLayer(input: { state: "active" as const, capabilities: ["agents" as const], hasWeb: false, + hasStyles: false, lastError: null, }, ]), }), ); - return PluginInstallerModule.layer.pipe( - Layer.provideMerge(PluginMarketplaceModule.layer), + return PluginMarketplaceModule.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayer.layer), Layer.provideMerge(host), Layer.provideMerge(catalog), - Layer.provideMerge(http), + Layer.provideMerge(lookup), + Layer.provideMerge(transport), Layer.provideMerge(TestClock.layer()), Layer.provideMerge(config), - Layer.provide(platform), + Layer.provideMerge(platform), ); } +function installerLayer(input: InstallerLayerInput) { + return PluginInstallerModule.layer.pipe(Layer.provideMerge(installerDeps(input))); +} + const seedSource = Effect.gen(function* () { const store = yield* PluginLockfileStore; yield* store.updateSources(() => @@ -465,6 +492,44 @@ it.effect("PluginInstaller rejects invalid manifests before staging can be confi ), ); +it("compareSemver orders versions by semver precedence and ignores build metadata", () => { + const cmp = PluginInstallerModule.compareSemver; + // A release outranks its prereleases. + assert.isAbove(cmp("1.0.0", "1.0.0-rc.1"), 0); + assert.isBelow(cmp("1.0.0-rc.1", "1.0.0"), 0); + // Numeric prerelease identifiers compare numerically, not lexically. + assert.isBelow(cmp("1.0.0-rc.2", "1.0.0-rc.10"), 0); + // The full precedence chain from semver.org §11. + const ordered = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + ]; + for (let index = 0; index < ordered.length - 1; index++) { + assert.isBelow(cmp(ordered[index]!, ordered[index + 1]!), 0); + assert.isAbove(cmp(ordered[index + 1]!, ordered[index]!), 0); + } + // Build metadata does not affect precedence. + assert.equal(cmp("1.0.0+build1", "1.0.0+build2"), 0); + // Plain x.y.z core comparison (as the minAppVersion checks rely on) is intact. + assert.isAbove(cmp("1.2.0", "1.0.0"), 0); + assert.isAbove(cmp("2.0.0", "1.9.9"), 0); + // A descending sort (as checkUpdates uses) picks the true latest of a + // prerelease set. + const latest = [ + { version: "1.0.0-rc.2" }, + { version: "1.0.0" }, + { version: "1.0.0-rc.10" }, + { version: "0.9.9" }, + ].toSorted((left, right) => cmp(right.version, left.version))[0]; + assert.equal(latest?.version, "1.0.0"); +}); + it("plugin id collision follows the DB table prefix, not the raw id", () => { // Same prefix / one a prefix of the other → collide. assert.isTrue(PluginInstallerModule.pluginTablePrefixesCollide("test", "test")); @@ -496,6 +561,32 @@ it.effect("PluginInstaller begin-confirm updates the lockfile and hot-activates" ); }); +it.effect("PluginInstaller describes filesystem and httpClient consent", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + + assert.equal( + staged.capabilityDescriptions.filesystem, + "Read and write files in your project workspace and in worktrees this plugin creates", + ); + assert.equal( + staged.capabilityDescriptions.httpClient, + "Make requests to public external HTTPS services", + ); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifest(manifest({ capabilities: ["filesystem", "httpClient"] })), + }), + ), + ), + ), +); + it.effect("PluginInstaller abort and expired tokens clean staging", () => Effect.scoped( Effect.gen(function* () { @@ -525,8 +616,9 @@ it.effect("PluginInstaller abort and expired tokens clean staging", () => ), ); -it.effect("PluginInstaller stages upgrades and uninstall marks pending remove", () => - Effect.scoped( +it.effect("PluginInstaller stages upgrades and uninstall marks pending remove", () => { + const deactivated: Array = []; + return Effect.scoped( Effect.gen(function* () { const installer = yield* PluginInstaller; const store = yield* PluginLockfileStore; @@ -553,6 +645,303 @@ it.effect("PluginInstaller stages upgrades and uninstall marks pending remove", yield* installer.uninstall({ pluginId, removeData: false }); lockfile = yield* store.readLockfile; assert.equal(lockfile.plugins[pluginId]?.state, "pending-remove"); + // uninstall must tear down the live runtime immediately, not wait for a + // server restart to apply pending-remove. + assert.deepEqual(deactivated, [pluginId]); + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest(), deactivated }))), + ); +}); + +it.effect("PluginInstaller rejects upgrading to the already-installed version", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + yield* store.updatePlugin(pluginId, () => + Effect.succeed({ + version: "1.0.0", + sha256: "existing", + sourceId, + enabled: true, + state: "active", + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + // A same-version upgrade must be rejected in beginUpgrade, before any + // staging, so moveStagingToVersionDir can never remove the live version + // dir out from under the running plugin. + const result = yield* Effect.result(installer.beginUpgrade({ pluginId, version: "1.0.0" })); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "manifest-invalid"); + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest() }))), + ), +); + +it.effect( + "PluginInstaller rejects tarball redirects to blocked hosts without following them", + () => { + const transportLog: Array = []; + return Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const result = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.code, "download-failed"); + assert.include(result.failure.message, "not allowed"); + } + // The redirect target must never be fetched: the guard validates the + // Location (and its resolved addresses) before issuing any request. + assert.deepEqual( + transportLog.filter((url) => url.startsWith("https://internal.market.test")), + [], + ); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifest(), + hosts: { + "market.test": "93.184.216.34", + "internal.market.test": "10.0.0.5", + }, + responses: { + [tarballUrl]: () => + new Response(null, { + status: 302, + headers: { location: "https://internal.market.test/latest/meta-data" }, + }), + }, + transportLog, + }), + ), + ), + ); + }, +); + +it.effect("PluginInstaller follows tarball redirects to allowed hosts", () => { + const tarball = tarballForManifest(); + return Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + + assert.equal(staged.manifest.id, pluginId); + }).pipe( + Effect.provide( + installerLayer({ + tarball, + hosts: { + "market.test": "93.184.216.34", + "cdn.market.test": "203.0.114.7", + }, + responses: { + [tarballUrl]: () => + new Response(null, { + status: 302, + headers: { location: "https://cdn.market.test/test-plugin-1.0.0.tgz" }, + }), + "https://cdn.market.test/test-plugin-1.0.0.tgz": () => new Response(tarball), + }, + }), + ), + ), + ); +}); + +it.effect("PluginInstaller confirmInstall re-checks id collisions under the lockfile writer", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + // Simulate a concurrent install committing an entry between this flow's + // begin (which validated against a lockfile snapshot) and its confirm. + yield* store.updatePlugin(pluginId, () => + Effect.succeed({ + version: "2.0.0", + sha256: "concurrent", + sourceId, + enabled: true, + state: "active" as const, + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const result = yield* Effect.result(installer.confirmInstall(staged.stageToken)); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "manifest-invalid"); + // The concurrently-committed entry must be left untouched. + const lockfile = yield* store.readLockfile; + assert.equal(lockfile.plugins[pluginId]?.version, "2.0.0"); + assert.equal(lockfile.plugins[pluginId]?.sha256, "concurrent"); + }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest() }))), + ), +); + +it.effect("PluginInstaller checkUpdates does not offer prereleases to stable installs", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + yield* store.updatePlugin(pluginId, () => + Effect.succeed({ + version: "1.0.0", + sha256: "installed", + sourceId, + enabled: true, + state: "active" as const, + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const { updates } = yield* installer.checkUpdates; + + assert.deepEqual(updates, []); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifest(), + marketplaceVersions: ["1.0.0", "1.1.0-rc.1"], + }), + ), + ), + ), +); + +it.effect("PluginInstaller checkUpdates offers prereleases to prerelease installs", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const store = yield* PluginLockfileStore; + yield* seedSource; + yield* store.updatePlugin(pluginId, () => + Effect.succeed({ + version: "1.1.0-rc.1", + sha256: "installed", + sourceId, + enabled: true, + state: "active" as const, + activation: { activatingSince: null, crashCount: 0 }, + installedAt: "2026-07-03T00:00:00.000Z", + lastError: null, + }), + ); + + const { updates } = yield* installer.checkUpdates; + + assert.equal(updates.length, 1); + assert.equal(updates[0]?.latestVersion, "1.1.0-rc.2"); + }).pipe( + Effect.provide( + installerLayer({ + tarball: tarballForManifest(), + marketplaceVersions: ["1.0.0", "1.1.0-rc.1", "1.1.0-rc.2"], + }), + ), + ), + ), +); + +it.effect("PluginInstaller cleanup tolerates staging directories that cannot be removed", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* seedSource; + + const staged = yield* installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }); + const stagingDir = path.join(config.pluginsDir, ".staging", staged.stageToken); + // Strip write permission so the expired dir's entries cannot be + // unlinked, then trigger cleanupExpired via a fresh beginInstall: the + // stuck dir must not fail the new staging operation. + yield* fs.chmod(stagingDir, 0o500); + yield* TestClock.adjust("16 minutes"); + const result = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + yield* fs.chmod(stagingDir, 0o700).pipe(Effect.ignore); + + assert.isTrue(Result.isSuccess(result)); }).pipe(Effect.provide(installerLayer({ tarball: tarballForManifest() }))), ), ); + +it.effect("PluginInstaller startup sweep reaps orphaned staging directories", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // An orphan left by an interrupt between mkdir and the StageRecord being + // recorded (or by a failed cleanup removal) is unreachable by design — + // stage records are in-memory only — so construction must reap it. + const orphan = path.join(config.pluginsDir, ".staging", "orphan"); + yield* fs.makeDirectory(orphan, { recursive: true }); + + yield* PluginInstallerModule.make(); + + assert.isFalse(yield* fs.exists(orphan)); + }).pipe(Effect.provide(installerDeps({ tarball: tarballForManifest() }))), + ), +); + +it.effect("PluginInstaller cleans staging when decompression fails", () => + Effect.scoped( + Effect.gen(function* () { + const installer = yield* PluginInstaller; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* seedSource; + + const result = yield* Effect.result( + installer.beginInstall({ sourceId, pluginId, version: "1.0.0" }), + ); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "extract-failed"); + + // The staging dir is created before decompression; a decompress failure + // (gzip-bomb cap) must clean it up rather than orphan it under .staging. + const stagingRoot = path.join(config.pluginsDir, ".staging"); + const exists = yield* fs.exists(stagingRoot).pipe(Effect.orElseSucceed(() => false)); + const entries = exists ? yield* fs.readDirectory(stagingRoot) : []; + assert.deepEqual(entries, []); + }).pipe( + Effect.provide( + Layer.mergeAll( + installerLayer({ + tarball: NodeZlib.gzipSync( + tarballForManifestJson(encodeManifestJson(manifest()), [ + { name: "assets/repeated.bin", body: new Uint8Array(1024 * 1024) }, + ]), + ), + }), + NodeServices.layer, + ), + ), + ), + ), +); diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts index 4f1f303962f..79548199d80 100644 --- a/apps/server/src/plugins/PluginInstaller.ts +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -4,7 +4,9 @@ import { PluginId, PluginManagementError, PluginManifest, + compareSemver, hostApiSatisfies, + isPrereleaseVersion, type MarketplaceVersion, type PluginId as PluginIdType, type PluginInfo, @@ -20,13 +22,16 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { HttpClientResponse } from "effect/unstable/http"; import * as NodeCrypto from "node:crypto"; import * as NodeURL from "node:url"; import * as NodeZlib from "node:zlib"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { guardedOutboundHttpGet } from "./guardedOutboundHttpGet.ts"; +import { OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import { PluginCatalog } from "./PluginCatalog.ts"; import { PluginHost } from "./PluginHost.ts"; import { pluginSqlPrefix } from "./PluginMigrator.ts"; @@ -36,6 +41,7 @@ import { pluginManifestPath, pluginVersionDir } from "./PluginPaths.ts"; import { readHttpResponseBytesCapped } from "./readHttpResponseBytesCapped.ts"; const DOWNLOAD_MAX_BYTES = 64 * 1024 * 1024; +const DOWNLOAD_TIMEOUT_MS = 120_000; const EXTRACT_TOTAL_MAX_BYTES = 128 * 1024 * 1024; const EXTRACT_FILE_MAX_BYTES = 16 * 1024 * 1024; const DECOMPRESSION_RATIO_MAX = 100; @@ -80,6 +86,8 @@ export const PLUGIN_CAPABILITY_DESCRIPTIONS = { "environments.read": "Read environment metadata", secrets: "Store plugin secrets", http: "Serve plugin HTTP routes", + filesystem: "Read and write files in your project workspace and in worktrees this plugin creates", + httpClient: "Make requests to public external HTTPS services", sourceControl: "Use source control integrations", textGeneration: "Request text generation", } satisfies Record; @@ -190,17 +198,10 @@ const validateRelativeArchivePath = (entryPath: string) => { return null; }; -const ensureSemver = (value: string) => value.split(/[+-]/u)[0]?.split(".").map(Number) ?? []; - -const compareSemver = (left: string, right: string) => { - const leftParts = ensureSemver(left); - const rightParts = ensureSemver(right); - for (let index = 0; index < 3; index++) { - const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); - if (diff !== 0) return diff; - } - return left.localeCompare(right); -}; +// The semver-precedence comparator lives in @t3tools/contracts/plugin so the +// web catalog UI ranks versions identically; re-exported here for existing +// server-side importers. +export { compareSemver }; const installedEntry = (entry: PluginLockfilePlugin | undefined): PluginLockfilePlugin => { if (!entry) { @@ -394,10 +395,11 @@ const extractTar = (input: { export const make = Effect.fn("PluginInstaller.make")(function* () { const config = yield* ServerConfig.ServerConfig; - const httpClient = yield* HttpClient.HttpClient; const clock = yield* Clock.Clock; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const lookup = yield* OutboundUrlLookup; + const transport = yield* PluginHttpClientTransportService; const store = yield* PluginLockfileStore; const marketplace = yield* PluginMarketplace; const host = yield* PluginHost; @@ -405,6 +407,12 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { const stages = yield* Ref.make(new Map()); const stagingRoot = path.join(config.pluginsDir, ".staging"); + // Stage records live only in this process's memory, so anything under + // .staging at startup is unreachable by design (an interrupt/crash between + // mkdir and record, or a removal that failed during cleanup). Reap it all + // best-effort so orphans cannot accumulate across runs. + yield* fs.remove(stagingRoot, { recursive: true, force: true }).pipe(Effect.ignore); + const removePath = (target: string) => fs .remove(target, { recursive: true, force: true }) @@ -427,7 +435,10 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { } return [removed, next]; }); - yield* Effect.forEach(expired, (stage) => removePath(stage.stagingDir), { + // Removal is best-effort per directory: one stuck dir (EACCES, locked + // file) must not fail the caller (getStage/beginInstall/confirm*) that + // merely triggered cleanup. Leftovers are reaped by the startup sweep. + yield* Effect.forEach(expired, (stage) => removePath(stage.stagingDir).pipe(Effect.ignore), { concurrency: 4, discard: true, }); @@ -480,16 +491,33 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { ), ); } - return httpClient.execute(HttpClientRequest.get(url)).pipe( + // Tarball URLs come from untrusted marketplace.json data: fetch them + // through the SSRF guard (per-hop URL validation + DNS-pinned transport, + // redirects re-validated) instead of the raw host HttpClient, which would + // happily follow a 30x into loopback/private/metadata addresses. + return guardedOutboundHttpGet({ url, lookup, transport, timeoutMs: DOWNLOAD_TIMEOUT_MS }).pipe( Effect.mapError((cause) => - managementError("download-failed", "Failed to download plugin tarball.", { url, cause }), + cause._tag === "OutboundUrlError" + ? managementError( + "download-failed", + `Plugin tarball URL is not allowed: ${cause.reason}.`, + { + url, + }, + ) + : managementError("download-failed", "Failed to download plugin tarball.", { + url, + cause, + }), ), Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError((cause) => - managementError("download-failed", "Plugin tarball returned a non-OK response.", { - url, - cause, - }), + isPluginManagementError(cause) + ? cause + : managementError("download-failed", "Plugin tarball returned a non-OK response.", { + url, + cause, + }), ), Effect.flatMap((response) => readHttpResponseBytesCapped({ @@ -619,7 +647,12 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { ), ); - const tarBytes = yield* decompressTarball(downloaded); + const tarBytes = yield* decompressTarball(downloaded).pipe( + // Clean up the just-created staging dir if decompression fails (corrupt + // archive / gzip-bomb cap). Without this the dir orphans on disk with no + // StageRecord, so cleanupExpired can never reap it. + Effect.catch((error) => removePath(stagingDir).pipe(Effect.andThen(Effect.fail(error)))), + ); yield* extractTar({ fs, path, tarBytes, outputDir: stagingDir }).pipe( Effect.catch((error) => removePath(stagingDir).pipe(Effect.andThen(Effect.fail(error)))), ); @@ -731,33 +764,80 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { const confirmInstall: PluginInstaller["Service"]["confirmInstall"] = (stageToken) => Effect.gen(function* () { - const stage = yield* getStage(stageToken, "install"); - yield* moveStagingToVersionDir(stage); - const installedAt = DateTime.formatIso(yield* DateTime.now); - yield* store - .updatePlugin(stage.pluginId, () => - Effect.succeed({ - version: stage.version, - sha256: stage.sha256, - sourceId: stage.sourceId, - enabled: true, - state: "active", - activation: { activatingSince: null, crashCount: 0 }, - installedAt, - lastError: null, - }), - ) - .pipe(Effect.mapError(lockfileError)); - yield* dropStage(stageToken); - yield* host - .activatePlugin(stage.pluginId) - .pipe( - Effect.mapError((cause) => - managementError("activation-failed", "Plugin activation failed.", { cause }), - ), + // Armed only for the window between "files moved into the version dir" and + // "lockfile entry written". If the commit fails there, the moved files + // would otherwise orphan on disk with no lockfile entry. Once the entry is + // written it owns the files, so we disarm (a later activation failure must + // NOT delete files a committed entry points at). Never remove a dir that + // pre-existed the move. + let orphanedVersionDir: string | null = null; + return yield* Effect.gen(function* () { + const stage = yield* getStage(stageToken, "install"); + const destination = pluginVersionDir( + config.pluginsDir, + stage.pluginId, + stage.version, + path.join, ); - return { plugin: yield* pluginInfo(stage.pluginId) }; - }).pipe(Effect.tapError(() => cleanupStage(stageToken))); + const preexisted = yield* fs.exists(destination).pipe(Effect.orElseSucceed(() => false)); + yield* moveStagingToVersionDir(stage); + if (!preexisted) orphanedVersionDir = destination; + const installedAt = DateTime.formatIso(yield* DateTime.now); + yield* store + .updatePlugin(stage.pluginId, ({ lockfile }) => + // Re-run the id-collision check INSIDE the lockfile single-writer: + // beginInstall validated against a snapshot, and a concurrent + // install may have committed a colliding entry since then — + // blindly writing here would silently overwrite it. + Effect.try({ + try: () => + assertNoPluginIdCollision(stage.pluginId, Object.keys(lockfile.plugins), false), + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("manifest-invalid", "Plugin id collision check failed.", { + cause, + }), + }).pipe( + Effect.as({ + version: stage.version, + sha256: stage.sha256, + sourceId: stage.sourceId, + enabled: true, + state: "active" as const, + activation: { activatingSince: null, crashCount: 0 }, + installedAt, + lastError: null, + }), + ), + ) + .pipe( + Effect.mapError((cause) => + isPluginManagementError(cause) ? cause : lockfileError(cause), + ), + ); + orphanedVersionDir = null; + yield* dropStage(stageToken); + yield* host + .activatePlugin(stage.pluginId) + .pipe( + Effect.mapError((cause) => + managementError("activation-failed", "Plugin activation failed.", { cause }), + ), + ); + return { plugin: yield* pluginInfo(stage.pluginId) }; + }).pipe( + Effect.tapError(() => + cleanupStage(stageToken).pipe( + Effect.andThen( + orphanedVersionDir === null + ? Effect.void + : removePath(orphanedVersionDir).pipe(Effect.ignore), + ), + ), + ), + ); + }); const abortInstall: PluginInstaller["Service"]["abortInstall"] = (stageToken) => Effect.gen(function* () { @@ -783,7 +863,13 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { ) .pipe(Effect.mapError(lockfileError)); if (input.enabled) { - yield* host.activatePlugin(input.pluginId); + yield* host + .activatePlugin(input.pluginId) + .pipe( + Effect.mapError((cause) => + managementError("activation-failed", "Plugin activation failed.", { cause }), + ), + ); } else { yield* host.deactivatePlugin(input.pluginId); } @@ -812,12 +898,26 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { }), ) .pipe(Effect.mapError(lockfileError)); + // Tear down the live runtime (scope, HTTP routes) now instead of leaving it + // running until the next server restart applies pending-remove. + yield* host.deactivatePlugin(input.pluginId); }); const beginUpgrade: PluginInstaller["Service"]["beginUpgrade"] = (input) => Effect.gen(function* () { const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); const current = installedEntry(lockfile.plugins[input.pluginId]); + // Reject upgrading to the already-installed version. Staging + confirming a + // same-version upgrade would move the new files onto the LIVE version dir + // (moveStagingToVersionDir removes the destination first), destroying the + // running plugin's files with no safe rollback. Reject before any staging. + if (input.version === current.version) { + return yield* managementError( + "manifest-invalid", + "Plugin is already installed at this version.", + { pluginId: input.pluginId, version: input.version }, + ); + } const source = lockfile.sources.find((candidate) => candidate.id === current.sourceId); if (!source) { return yield* managementError( @@ -845,25 +945,50 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { const confirmUpgrade: PluginInstaller["Service"]["confirmUpgrade"] = (stageToken) => Effect.gen(function* () { - const stage = yield* getStage(stageToken, "upgrade"); - yield* moveStagingToVersionDir(stage); - const stagedAt = DateTime.formatIso(yield* DateTime.now); - yield* store - .updatePlugin(stage.pluginId, ({ current }) => - Effect.succeed({ - ...installedEntry(current), - state: "pending-upgrade", - staged: { - version: stage.version, - sha256: stage.sha256, - stagedAt, - }, - }), - ) - .pipe(Effect.mapError(lockfileError)); - yield* dropStage(stageToken); - return { plugin: yield* pluginInfo(stage.pluginId) }; - }).pipe(Effect.tapError(() => cleanupStage(stageToken))); + // See confirmInstall: only the moved-but-not-yet-recorded window can orphan + // the new version dir. The staged version dir is distinct from the running + // version, so removing it on failure never touches the live plugin. + let orphanedVersionDir: string | null = null; + return yield* Effect.gen(function* () { + const stage = yield* getStage(stageToken, "upgrade"); + const destination = pluginVersionDir( + config.pluginsDir, + stage.pluginId, + stage.version, + path.join, + ); + const preexisted = yield* fs.exists(destination).pipe(Effect.orElseSucceed(() => false)); + yield* moveStagingToVersionDir(stage); + if (!preexisted) orphanedVersionDir = destination; + const stagedAt = DateTime.formatIso(yield* DateTime.now); + yield* store + .updatePlugin(stage.pluginId, ({ current }) => + Effect.succeed({ + ...installedEntry(current), + state: "pending-upgrade", + staged: { + version: stage.version, + sha256: stage.sha256, + stagedAt, + }, + }), + ) + .pipe(Effect.mapError(lockfileError)); + orphanedVersionDir = null; + yield* dropStage(stageToken); + return { plugin: yield* pluginInfo(stage.pluginId) }; + }).pipe( + Effect.tapError(() => + cleanupStage(stageToken).pipe( + Effect.andThen( + orphanedVersionDir === null + ? Effect.void + : removePath(orphanedVersionDir).pipe(Effect.ignore), + ), + ), + ), + ); + }); const checkUpdates = Effect.gen(function* () { const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); @@ -880,9 +1005,13 @@ export const make = Effect.fn("PluginInstaller.make")(function* () { if (index === null) return null; const marketplaceEntry = index.plugins.find((candidate) => candidate.id === pluginId); if (!marketplaceEntry) return null; - const latest = marketplaceEntry.versions.toSorted((left, right) => - compareSemver(right.version, left.version), - )[0]; + // Prereleases are only offered as updates when the installed version + // is itself a prerelease; a stable install must not be prompted onto + // an rc/beta channel. + const includePrereleases = isPrereleaseVersion(entry.version); + const latest = marketplaceEntry.versions + .filter((candidate) => includePrereleases || !isPrereleaseVersion(candidate.version)) + .toSorted((left, right) => compareSemver(right.version, left.version))[0]; if (!latest || compareSemver(latest.version, entry.version) <= 0) return null; return { pluginId, diff --git a/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts b/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts index 0e54f1de93b..44b2c606822 100644 --- a/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts +++ b/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts @@ -5,9 +5,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import { PluginInstaller } from "./PluginInstaller.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; @@ -17,10 +19,17 @@ import * as PluginMarketplace from "./PluginMarketplace.ts"; const pluginId = PluginId.make("test-plugin"); -const TestHttpClientLive = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), +const TestOutboundDepsLive = Layer.mergeAll( + Layer.succeed(OutboundUrlLookup, () => + Effect.succeed([{ address: "93.184.216.34", family: 4 as const }]), + ), + Layer.succeed(PluginHttpClientTransportService, (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get(request.url.toString()), + new Response("{}", { status: 404 }), + ), + ), ), ); @@ -43,7 +52,7 @@ const managementTest = it.layer( Layer.provideMerge(PluginLockfileStoreLayer.layer), Layer.provideMerge(PluginMarketplace.layer), Layer.provideMerge(InstallerMockLive), - Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestOutboundDepsLive), Layer.provideMerge(TestClock.layer()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-management-" })), Layer.provideMerge(NodeServices.layer), @@ -69,6 +78,44 @@ managementTest("PluginManagementRpcHandlers", (it) => { }), ); + it.effect("dedupes a re-added source against a stored credentialed row", () => + Effect.gen(function* () { + const handlers = yield* PluginManagementRpcHandlers; + const store = yield* PluginLockfileStore; + // Simulate a source persisted before credentials were stripped from the + // stored URL. Re-adding the same marketplace (now credential-stripped) + // must reuse this row rather than register a second source. + yield* store.updateSources((sources) => + Effect.succeed([ + ...sources, + { + id: "src-legacy", + url: "https://user:secret@example.test/legacy-marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }, + ]), + ); + + const added = yield* handlers.addSource({ + url: "https://user:secret@example.test/legacy-marketplace.json", + }); + const listed = yield* handlers.listSources; + + assert.equal(added.source.id, "src-legacy"); + assert.equal( + listed.sources.filter((entry) => entry.url.includes("legacy-marketplace.json")).length, + 1, + ); + // The stored (previously credentialed) URL is rewritten to its + // credential-stripped canonical form so it stops leaking via listSources, + // while the opaque legacy sourceId is preserved. + assert.equal(added.source.url, "https://example.test/legacy-marketplace.json"); + const legacyEntry = listed.sources.find((entry) => entry.id === "src-legacy"); + assert.equal(legacyEntry?.url, "https://example.test/legacy-marketplace.json"); + assert.isFalse(legacyEntry?.url.includes("secret")); + }), + ); + it.effect("rejects non-HTTPS sources", () => Effect.gen(function* () { const handlers = yield* PluginManagementRpcHandlers; @@ -104,4 +151,22 @@ managementTest("PluginManagementRpcHandlers", (it) => { if (Result.isFailure(result)) assert.equal(result.failure.code, "invalid-source"); }), ); + + it.effect("removes an unused source and reports missing sources", () => + Effect.gen(function* () { + const handlers = yield* PluginManagementRpcHandlers; + // Unique URL so no plugin installed by a sibling test references it. + const source = yield* handlers.addSource({ + url: "https://example.test/removable-marketplace.json", + }); + + yield* handlers.removeSource({ sourceId: source.source.id }); + const listed = yield* handlers.listSources; + assert.isFalse(listed.sources.some((entry) => entry.id === source.source.id)); + + const missing = yield* Effect.result(handlers.removeSource({ sourceId: "src-missing" })); + assert.isTrue(Result.isFailure(missing)); + if (Result.isFailure(missing)) assert.equal(missing.failure.code, "source-not-found"); + }), + ); }); diff --git a/apps/server/src/plugins/PluginManagementRpcHandlers.ts b/apps/server/src/plugins/PluginManagementRpcHandlers.ts index 52f7de01ffe..390cb1310bb 100644 --- a/apps/server/src/plugins/PluginManagementRpcHandlers.ts +++ b/apps/server/src/plugins/PluginManagementRpcHandlers.ts @@ -23,7 +23,7 @@ import * as Schema from "effect/Schema"; import { PluginInstaller } from "./PluginInstaller.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; -import { PluginMarketplace, sourceIdForUrl } from "./PluginMarketplace.ts"; +import { isSameMarketplaceSource, PluginMarketplace, sourceIdForUrl } from "./PluginMarketplace.ts"; const managementError = (code: PluginManagementError["code"], message: string, data?: unknown) => new PluginManagementError({ @@ -95,12 +95,30 @@ export const make = Effect.fn("PluginManagementRpcHandlers.make")(function* () { const now = DateTime.formatIso(yield* DateTime.now); const source = yield* store .updateSources((sources) => { - const existing = sources.find((candidate) => candidate.url === normalized); - if (existing) return Effect.succeed(sources); + // Dedupe on the canonical form so a source persisted before credential + // stripping (or otherwise differing only by strippable parts) is not + // registered a second time under a different sourceId. + const existing = sources.find((candidate) => + isSameMarketplaceSource(candidate.url, normalized), + ); + if (existing) { + // Rewrite a legacy credentialed (or otherwise non-canonical) URL to + // its credential-stripped canonical form so it stops leaking via + // listSources / error payloads. Keep the existing (opaque) sourceId + // so installed plugins that reference it are unaffected. + if (existing.url === normalized) return Effect.succeed(sources); + return Effect.succeed( + sources.map((candidate) => + candidate === existing ? { ...candidate, url: normalized } : candidate, + ), + ); + } return Effect.succeed([...sources, { id, url: normalized, addedAt: now }]); }) .pipe(Effect.mapError(toManagementError)); - const entry = source.sources.find((candidate) => candidate.url === normalized); + const entry = source.sources.find((candidate) => + isSameMarketplaceSource(candidate.url, normalized), + ); if (!entry) { return yield* managementError("invalid-source", "Failed to add plugin source.", { url: normalized, @@ -111,30 +129,40 @@ export const make = Effect.fn("PluginManagementRpcHandlers.make")(function* () { const removeSource: PluginManagementRpcHandlers["Service"]["removeSource"] = (input) => Effect.gen(function* () { - const lockfile = yield* store.readLockfile.pipe(Effect.mapError(lockfileError)); - if (!lockfile.sources.some((source) => source.id === input.sourceId)) { - return yield* managementError("source-not-found", "Plugin source was not found.", { - sourceId: input.sourceId, - }); - } - const usedBy = Object.entries(lockfile.plugins).find( - ([, plugin]) => plugin.sourceId === input.sourceId, - )?.[0]; - if (usedBy) { - return yield* managementError( - "invalid-source", - "Plugin source is still used by an installed plugin.", - { - sourceId: input.sourceId, - pluginId: usedBy, - }, - ); - } + // Validate "source exists" and "source not used by an installed plugin" + // INSIDE updateSources' critical section, against the lockfile read under + // the advisory lock. Doing the check outside (via a separate readLockfile) + // races a concurrent install that could reference this source between the + // check and the removal, leaving a dangling sourceId. + let rejection: PluginManagementError | null = null; yield* store - .updateSources((sources) => - Effect.succeed(sources.filter((source) => source.id !== input.sourceId)), - ) + .updateSources((sources, lockfile) => { + if (!sources.some((source) => source.id === input.sourceId)) { + rejection = managementError("source-not-found", "Plugin source was not found.", { + sourceId: input.sourceId, + }); + return Effect.succeed(sources); + } + const usedBy = Object.entries(lockfile.plugins).find( + ([, plugin]) => plugin.sourceId === input.sourceId, + )?.[0]; + if (usedBy) { + rejection = managementError( + "invalid-source", + "Plugin source is still used by an installed plugin.", + { + sourceId: input.sourceId, + pluginId: usedBy, + }, + ); + return Effect.succeed(sources); + } + return Effect.succeed(sources.filter((source) => source.id !== input.sourceId)); + }) .pipe(Effect.asVoid, Effect.mapError(toManagementError)); + if (rejection !== null) { + return yield* rejection as PluginManagementError; + } }); const catalog: PluginManagementRpcHandlers["Service"]["catalog"] = (input) => diff --git a/apps/server/src/plugins/PluginMarketplace.test.ts b/apps/server/src/plugins/PluginMarketplace.test.ts index b023427e26f..42d46286e51 100644 --- a/apps/server/src/plugins/PluginMarketplace.test.ts +++ b/apps/server/src/plugins/PluginMarketplace.test.ts @@ -8,9 +8,11 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as NodeURL from "node:url"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { OutboundUrlError, OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import { MarketplaceIndex, PluginMarketplace, @@ -41,18 +43,35 @@ const validMarketplace = { ], }; -const TestHttpClientLive = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(validMarketplace))), - ), +// example.test resolves publicly; internal.test resolves into RFC1918 space so +// the SSRF guard must refuse to fetch it. +const TestOutboundLookupLive = Layer.succeed(OutboundUrlLookup, (host: string) => { + if (host === "example.test") { + return Effect.succeed([{ address: "93.184.216.34", family: 4 as const }]); + } + if (host === "internal.test") { + return Effect.succeed([{ address: "192.168.1.10", family: 4 as const }]); + } + return Effect.fail(new OutboundUrlError({ reason: `unexpected lookup ${host}` })); +}); + +const TestPluginHttpClientTransportLive = Layer.succeed( + PluginHttpClientTransportService, + (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get(request.url.toString()), + Response.json(validMarketplace), + ), + ), ); const marketplaceTest = it.layer( PluginMarketplaceLayer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(TestClock.layer()), - Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestOutboundLookupLive), + Layer.provideMerge(TestPluginHttpClientTransportLive), ), ); @@ -80,6 +99,12 @@ marketplaceTest("PluginMarketplace", (it) => { resolveMarketplaceUrl("https://example.test/marketplace.json#ignored"), "https://example.test/marketplace.json", ); + // Embedded credentials must be stripped so they are never persisted in + // the lockfile or echoed back through listSources / error payloads. + assert.equal( + resolveMarketplaceUrl("https://user:secret@example.test/marketplace.json"), + "https://example.test/marketplace.json", + ); assert.equal( resolveMarketplaceUrl("owner/repo"), "https://raw.githubusercontent.com/owner/repo/HEAD/marketplace.json", @@ -149,6 +174,85 @@ marketplaceTest("PluginMarketplace", (it) => { ), ); + it.effect("surfaces a non-HTTPS tarball as a typed failure, not a defect", () => + withPluginDev( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const marketplace = yield* PluginMarketplace; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-marketplace-" }); + const filePath = path.join(dir, "marketplace.json"); + yield* fs.writeFileString( + filePath, + encodeMarketplaceJson({ + plugins: [ + { + ...validMarketplace.plugins[0]!, + versions: [ + { + ...validMarketplace.plugins[0]!.versions[0]!, + tarball: "http://insecure.test/test-plugin-1.0.0.tgz", + }, + ], + }, + ], + }), + ); + const url = NodeURL.pathToFileURL(filePath).toString(); + const source: PluginSource = { + id: sourceIdForUrl(url), + url, + addedAt: "2026-07-03T00:00:00.000Z", + }; + + const result = yield* Effect.result( + marketplace.findVersion({ + source, + pluginId: PluginId.make("test-plugin"), + version: "1.0.0", + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure.code, "invalid-source"); + }), + ), + ); + + it.effect("refuses to fetch marketplace hosts that resolve to private addresses", () => + Effect.gen(function* () { + const marketplace = yield* PluginMarketplace; + + const result = yield* Effect.result( + marketplace.fetchSource({ + id: "internal", + url: "https://internal.test/marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.code, "catalog-fetch-failed"); + assert.include(result.failure.message, "not allowed"); + } + }), + ); + + it.effect("fetches marketplace json over the guarded HTTP path", () => + Effect.gen(function* () { + const marketplace = yield* PluginMarketplace; + + const index = yield* marketplace.fetchSource({ + id: "https", + url: "https://example.test/marketplace.json", + addedAt: "2026-07-03T00:00:00.000Z", + }); + + assert.equal(index.plugins[0]?.id, PluginId.make("test-plugin")); + }), + ); + it.effect("rejects marketplace responses over the byte cap", () => withPluginDev( Effect.gen(function* () { diff --git a/apps/server/src/plugins/PluginMarketplace.ts b/apps/server/src/plugins/PluginMarketplace.ts index b1c7f1f880d..6227023c797 100644 --- a/apps/server/src/plugins/PluginMarketplace.ts +++ b/apps/server/src/plugins/PluginMarketplace.ts @@ -13,13 +13,17 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { HttpClientResponse } from "effect/unstable/http"; import * as NodeCrypto from "node:crypto"; import * as NodeURL from "node:url"; +import { PluginHttpClientTransportService } from "./capabilities/HttpClientCapability.ts"; +import { guardedOutboundHttpGet } from "./guardedOutboundHttpGet.ts"; +import { OutboundUrlLookup } from "./OutboundUrlValidator.ts"; import { readHttpResponseBytesCapped } from "./readHttpResponseBytesCapped.ts"; const MARKETPLACE_RESPONSE_MAX_BYTES = 2 * 1024 * 1024; +const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000; const CATALOG_CACHE_TTL_MS = 30_000; const OWNER_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; @@ -49,12 +53,31 @@ function canonicalHttpsUrl(input: string): string | null { const url = new URL(input); if (url.protocol !== "https:") return null; url.hash = ""; + // Strip any embedded credentials: this URL is persisted in the lockfile and + // echoed back via listSources / error payloads, so `https://user:pw@host` + // would leak the secret. Credentialed marketplace URLs are not supported. + url.username = ""; + url.password = ""; return url.toString(); } catch { return null; } } +/** + * True when a stored source URL refers to the same marketplace as + * `canonicalUrl` (an already-normalized value from {@link resolveMarketplaceUrl}). + * Stored URLs are normally already canonical, but a row persisted before + * credentials were stripped may still embed them; compare on the canonical + * (credential-stripped) form so such a row still dedupes against a freshly + * normalized add instead of registering the same marketplace twice. + */ +export function isSameMarketplaceSource(storedUrl: string, canonicalUrl: string): boolean { + if (storedUrl === canonicalUrl) return true; + const canonicalStored = canonicalHttpsUrl(storedUrl); + return canonicalStored !== null && canonicalStored === canonicalUrl; +} + export function resolveMarketplaceUrl(input: string): string { const trimmed = input.trim(); if (OWNER_REPO_PATTERN.test(trimmed)) { @@ -126,9 +149,10 @@ interface CachedIndex { } export const make = Effect.fn("PluginMarketplace.make")(function* () { - const httpClient = yield* HttpClient.HttpClient; const clock = yield* Clock.Clock; const fs = yield* FileSystem.FileSystem; + const lookup = yield* OutboundUrlLookup; + const transport = yield* PluginHttpClientTransportService; const cache = yield* Ref.make(new Map()); const normalizeSourceUrl = (url: string) => @@ -150,20 +174,42 @@ export const make = Effect.fn("PluginMarketplace.make")(function* () { ), ); + // Marketplace URLs are untrusted input: fetch them through the SSRF guard + // (per-hop URL validation + DNS-pinned transport, redirects re-validated) + // instead of the raw host HttpClient, which would happily follow a 30x into + // loopback/private/metadata addresses. const readHttpUrl = (url: string) => - httpClient.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson)).pipe( + guardedOutboundHttpGet({ + url, + lookup, + transport, + headers: { accept: "application/json" }, + timeoutMs: MARKETPLACE_FETCH_TIMEOUT_MS, + }).pipe( Effect.mapError((cause) => - managementError("catalog-fetch-failed", "Failed to fetch plugin marketplace.", { - url, - cause, - }), + cause._tag === "OutboundUrlError" + ? managementError( + "catalog-fetch-failed", + `Plugin marketplace URL is not allowed: ${cause.reason}.`, + { url }, + ) + : managementError("catalog-fetch-failed", "Failed to fetch plugin marketplace.", { + url, + cause, + }), ), Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError((cause) => - managementError("catalog-fetch-failed", "Plugin marketplace returned a non-OK response.", { - url, - cause, - }), + isPluginManagementError(cause) + ? cause + : managementError( + "catalog-fetch-failed", + "Plugin marketplace returned a non-OK response.", + { + url, + cause, + }, + ), ), Effect.flatMap((response) => readHttpResponseBytesCapped({ @@ -286,11 +332,21 @@ export const make = Effect.fn("PluginMarketplace.make")(function* () { sourceId: input.source.id, }); } + // resolveTarballUrl throws synchronously (PluginManagementError for a + // non-HTTPS URL, or a TypeError for a malformed one). Wrap it so a bad + // marketplace entry surfaces as a typed failure instead of a defect. + const tarballUrl = yield* Effect.try({ + try: () => resolveTarballUrl({ tarball: version.tarball, marketplaceUrl }), + catch: (cause) => + isPluginManagementError(cause) + ? cause + : managementError("invalid-source", "Plugin tarball URL is invalid.", { cause }), + }); return { entry, version, marketplaceUrl, - tarballUrl: resolveTarballUrl({ tarball: version.tarball, marketplaceUrl }), + tarballUrl, }; }); diff --git a/apps/server/src/plugins/PluginRpcDispatcher.test.ts b/apps/server/src/plugins/PluginRpcDispatcher.test.ts index ad5486236a7..92aaa1a5fda 100644 --- a/apps/server/src/plugins/PluginRpcDispatcher.test.ts +++ b/apps/server/src/plugins/PluginRpcDispatcher.test.ts @@ -215,6 +215,39 @@ dispatcherTest("PluginRpcDispatcher", (it) => { }), ); + it.effect("fails closed when a descriptor declares an unrecognized scope", () => + Effect.gen(function* () { + // Plugin modules are untrusted, dynamically loaded JS, so a descriptor can + // carry a scope outside the SDK's `"read" | "operate"` type (here a casing + // typo). Such a descriptor must be REJECTED, not silently downgraded to the + // weaker plugin read requirement. + const badRegistration = { + rpc: [{ method: "mystery", scope: "Operate", handler: () => Effect.succeed("nope") }], + streams: [ + { method: "mystery-stream", scope: "Operate", handler: () => Stream.make("nope") }, + ], + } as unknown as PluginRegistration; + yield* putRuntime({ ready: true, registration: badRegistration }); + const dispatcher = yield* PluginRpcDispatcher; + + // Even a full standard client (which implicitly holds every plugin scope) + // is denied — proving the rejection is on the descriptor, not the session. + const call = yield* Effect.result( + dispatcher.call(pluginId, "mystery", null, session(AuthStandardClientScopes)), + ); + const stream = yield* Effect.result( + dispatcher + .subscribe(pluginId, "mystery-stream", null, session(AuthStandardClientScopes)) + .pipe(Stream.runCollect), + ); + + assert.isTrue(Result.isFailure(call)); + if (Result.isFailure(call)) assert.equal(call.failure.code, "unauthorized"); + assert.isTrue(Result.isFailure(stream)); + if (Result.isFailure(stream)) assert.equal(stream.failure.code, "unauthorized"); + }), + ); + it.effect("maps handler defects to internal errors and continues serving calls", () => Effect.gen(function* () { yield* putRuntime({ ready: true }); @@ -292,6 +325,7 @@ catalogTest("PluginCatalog", (it) => { id: plugin.id, state: plugin.state, hasWeb: plugin.hasWeb, + hasStyles: plugin.hasStyles, lastError: plugin.lastError, })), [ @@ -299,12 +333,14 @@ catalogTest("PluginCatalog", (it) => { id: failedPluginId, state: "failed", hasWeb: true, + hasStyles: false, lastError: "activation failed", }, { id: pluginId, state: "active", hasWeb: true, + hasStyles: false, lastError: null, }, ], diff --git a/apps/server/src/plugins/PluginWebRoutes.test.ts b/apps/server/src/plugins/PluginWebRoutes.test.ts index f4c9194e18d..9fda4e000fb 100644 --- a/apps/server/src/plugins/PluginWebRoutes.test.ts +++ b/apps/server/src/plugins/PluginWebRoutes.test.ts @@ -1,7 +1,11 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { PluginId, type PluginLockfilePlugin } from "@t3tools/contracts/plugin"; +import { + EMPTY_PLUGIN_LOCKFILE, + PluginId, + type PluginLockfilePlugin, +} from "@t3tools/contracts/plugin"; import { PLUGIN_WEB_BUNDLE_CACHE_CONTROL, PLUGIN_WEB_SHIM_CACHE_CONTROL, @@ -10,13 +14,14 @@ 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 TestClock from "effect/testing/TestClock"; import { FetchHttpClient, HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; import { PluginLockfileStore } from "./PluginLockfileStore.ts"; import * as PluginLockfileStoreLayer from "./PluginLockfileStore.ts"; import { pluginVersionDir } from "./PluginPaths.ts"; -import { pluginWebRouteLayer } from "./PluginWebRoutes.ts"; +import { pluginWebRouteLayer, readLockfileCached } from "./PluginWebRoutes.ts"; const pluginId = PluginId.make("web-plugin"); @@ -189,3 +194,50 @@ if (loopbackAvailable) { it("skips live router assertions when local TCP bind is unavailable", () => {}); }); } + +describe("readLockfileCached", () => { + it.effect("serves cached reads within the TTL and re-reads after it expires", () => + Effect.gen(function* () { + const cacheKey = {}; + let reads = 0; + const read = Effect.sync(() => { + reads += 1; + return EMPTY_PLUGIN_LOCKFILE; + }); + + const first = yield* readLockfileCached(cacheKey, read); + const second = yield* readLockfileCached(cacheKey, read); + assert.strictEqual(first, EMPTY_PLUGIN_LOCKFILE); + assert.strictEqual(second, EMPTY_PLUGIN_LOCKFILE); + assert.strictEqual(reads, 1); + + yield* TestClock.adjust(2_000); + yield* readLockfileCached(cacheKey, read); + assert.strictEqual(reads, 2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps caches for distinct stores isolated and does not cache failures", () => + Effect.gen(function* () { + const cacheKey = {}; + const otherKey = {}; + let reads = 0; + const read = Effect.sync(() => { + reads += 1; + return EMPTY_PLUGIN_LOCKFILE; + }); + + const failure = yield* readLockfileCached(cacheKey, Effect.fail("boom" as const)).pipe( + Effect.flip, + ); + assert.strictEqual(failure, "boom"); + + // The failed read left nothing behind for this key… + yield* readLockfileCached(cacheKey, read); + assert.strictEqual(reads, 1); + // …and a different store gets its own fresh read. + yield* readLockfileCached(otherKey, read); + assert.strictEqual(reads, 2); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/plugins/PluginWebRoutes.ts b/apps/server/src/plugins/PluginWebRoutes.ts index e9b4652e728..b5c8214d7d9 100644 --- a/apps/server/src/plugins/PluginWebRoutes.ts +++ b/apps/server/src/plugins/PluginWebRoutes.ts @@ -1,10 +1,16 @@ -import { PLUGIN_ID_PATTERN_SOURCE, type PluginId } from "@t3tools/contracts/plugin"; +import { + PLUGIN_ID_PATTERN_SOURCE, + type PluginId, + type PluginLockfile, +} from "@t3tools/contracts/plugin"; import { getPluginHostShimSource, pluginHostModuleFromPath, PLUGIN_WEB_BUNDLE_CACHE_CONTROL, + PLUGIN_WEB_DEV_CACHE_CONTROL, PLUGIN_WEB_SHIM_CACHE_CONTROL, } from "@t3tools/shared/pluginHostWeb"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -20,6 +26,16 @@ const PLUGIN_WEB_ROUTE_PREFIX = "/plugins/"; const PLUGIN_HOST_ROUTE_PREFIX = "/plugin-host/"; const PLUGIN_ID_PATTERN = new RegExp(`^${PLUGIN_ID_PATTERN_SOURCE}$`, "u"); +// In plugin dev mode, bundles/shims are rebuilt in place at the same version, so +// serve them uncacheable; otherwise use the long-lived immutable/short caches. +const pluginDevMode = process.env.T3_PLUGIN_DEV === "1"; +const pluginBundleCacheControl = pluginDevMode + ? PLUGIN_WEB_DEV_CACHE_CONTROL + : PLUGIN_WEB_BUNDLE_CACHE_CONTROL; +const pluginShimCacheControl = pluginDevMode + ? PLUGIN_WEB_DEV_CACHE_CONTROL + : PLUGIN_WEB_SHIM_CACHE_CONTROL; + const notFound = () => HttpServerResponse.text("Not Found", { status: 404 }); function decodeSegment(segment: string): string | null { @@ -77,6 +93,37 @@ function parsePluginWebPath(pathname: string): { }; } +// The lockfile gates EVERY asset request, and one plugin page load fetches +// many assets back-to-back. Cache the parsed lockfile briefly instead of +// re-reading + re-decoding it per request. The cache is keyed by the store +// instance (WeakMap) so independent layers — e.g. test fixtures — stay +// isolated. Staleness only delays visibility of a brand-new install/upgrade +// by up to the TTL; the web host's registry sync retries failed imports, so +// a transient 404 self-heals. Read failures are not cached. +const LOCKFILE_CACHE_TTL_MS = 1_000; + +interface CachedLockfile { + readonly at: number; + readonly lockfile: PluginLockfile; +} + +const lockfileCacheByStore = new WeakMap(); + +export const readLockfileCached = ( + cacheKey: object, + readLockfile: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cached = lockfileCacheByStore.get(cacheKey); + if (cached && now - cached.at < LOCKFILE_CACHE_TTL_MS) { + return cached.lockfile; + } + const lockfile = yield* readLockfile; + lockfileCacheByStore.set(cacheKey, { at: now, lockfile }); + return lockfile; + }); + function isWithinRoot(root: string, candidate: string, separator: string): boolean { return ( candidate === root || @@ -132,13 +179,19 @@ const pluginBundleRouteLayer = HttpRouter.add( } const lockfileStore = yield* PluginLockfileStore; - const lockfile = yield* lockfileStore.readLockfile.pipe( + const lockfile = yield* readLockfileCached(lockfileStore, lockfileStore.readLockfile).pipe( Effect.catch((cause) => Effect.logWarning("Could not read plugin lockfile for web bundle route", { cause }).pipe( Effect.as(null), ), ), ); + // Plugin web bundles are served WITHOUT auth, like the host's own static + // assets: they are public-by-URL (id + version), contain no secrets, and + // the lockfile pin below gates them to installed versions. That + // intentionally includes DISABLED plugins' assets — disablement gates + // execution (the web host never imports a disabled plugin's bundle), not + // asset availability. const lockfileEntry = lockfile?.plugins[parsed.pluginId]; if (!lockfileEntry || lockfileEntry.version !== parsed.version) { return notFound(); @@ -184,7 +237,7 @@ const pluginBundleRouteLayer = HttpRouter.add( status: 200, contentType: contentTypeFor(candidateRealPath, path.extname), headers: { - "Cache-Control": PLUGIN_WEB_BUNDLE_CACHE_CONTROL, + "Cache-Control": pluginBundleCacheControl, "X-Content-Type-Options": "nosniff", }, }); @@ -214,7 +267,7 @@ const pluginHostShimRouteLayer = HttpRouter.add( status: 200, contentType: "text/javascript; charset=utf-8", headers: { - "Cache-Control": PLUGIN_WEB_SHIM_CACHE_CONTROL, + "Cache-Control": pluginShimCacheControl, "X-Content-Type-Options": "nosniff", }, }); diff --git a/apps/server/src/plugins/PluginWorkspaceGrants.ts b/apps/server/src/plugins/PluginWorkspaceGrants.ts new file mode 100644 index 00000000000..b15b93d3d4f --- /dev/null +++ b/apps/server/src/plugins/PluginWorkspaceGrants.ts @@ -0,0 +1,32 @@ +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +export interface PluginWorkspaceGrants { + readonly grant: (root: string) => Effect.Effect; + readonly revoke: (root: string) => Effect.Effect; + readonly clear: Effect.Effect; + readonly snapshot: () => Effect.Effect>; +} + +export const makePluginWorkspaceGrants: Effect.Effect = Effect.gen( + function* () { + const roots = yield* Ref.make(new Set()); + + return { + grant: (root) => + Ref.update(roots, (current) => { + const next = new Set(current); + next.add(root); + return next; + }), + revoke: (root) => + Ref.update(roots, (current) => { + const next = new Set(current); + next.delete(root); + return next; + }), + clear: Ref.set(roots, new Set()), + snapshot: () => Ref.get(roots).pipe(Effect.map((current) => new Set(current))), + }; + }, +); diff --git a/apps/server/src/plugins/capabilities/AgentsCapability.test.ts b/apps/server/src/plugins/capabilities/AgentsCapability.test.ts index 8fa27aa125a..18fc38e83cf 100644 --- a/apps/server/src/plugins/capabilities/AgentsCapability.test.ts +++ b/apps/server/src/plugins/capabilities/AgentsCapability.test.ts @@ -37,6 +37,9 @@ import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionT import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { + AgentsBootstrapUnsupportedError, + AgentsInvalidTimeoutError, + AgentsThreadNotFoundError, AgentsThreadOwnershipError, AgentsTurnAwaitTimeoutError, makeAgentsCapability, @@ -290,7 +293,181 @@ agentsIt("AgentsCapability", (it) => { }), ); - it.effect("observeThread emits the owned snapshot followed by thread-detail events", () => + it.effect("startTurn forwards caller-supplied ids and generates defaults when omitted", () => + Effect.gen(function* () { + const dispatched: OrchestrationCommand[] = []; + const agents = makeAgentsCapability({ + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }, + 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-caller-ids"); + const callerMessageId = MessageId.make("message-caller"); + const callerCommandId = CommandId.make("cmd-caller-turn-start"); + + const callerResult = yield* agents.startTurn({ + threadId, + text: "caller ids", + messageId: callerMessageId, + commandId: callerCommandId, + }); + const generatedResult = yield* agents.startTurn({ + threadId, + text: "generated ids", + }); + + const turnStarts = dispatched.filter((command) => command.type === "thread.turn.start"); + expect(callerResult.messageId).toBe(callerMessageId); + expect(turnStarts[0]?.type).toBe("thread.turn.start"); + if (turnStarts[0]?.type === "thread.turn.start") { + expect(turnStarts[0].commandId).toBe(callerCommandId); + expect(turnStarts[0].message.messageId).toBe(callerMessageId); + } + expect(generatedResult.messageId).not.toBe(callerMessageId); + expect(String(generatedResult.messageId)).toMatch(/^plugin-message:/); + expect(turnStarts[1]?.type).toBe("thread.turn.start"); + if (turnStarts[1]?.type === "thread.turn.start") { + expect(String(turnStarts[1].commandId)).toMatch(/^plugin:turn-start:/); + expect(turnStarts[1].message.messageId).toBe(generatedResult.messageId); + } + }), + ); + + it.effect("startTurn re-dispatch with the same caller commandId is receipt-deduplicated", () => + Effect.gen(function* () { + const { agents, engine, messages, turns } = yield* makeCapability; + yield* createProject(engine); + const { threadId } = yield* agents.createThread({ + projectId: ProjectId.make("project-agents"), + title: "Dedup", + modelSelection, + }); + const messageId = MessageId.make("message-dedup"); + const commandId = CommandId.make("cmd-dedup-turn-start"); + + yield* agents.startTurn({ threadId, text: "first", messageId, commandId }); + yield* agents.startTurn({ threadId, text: "second", messageId, commandId }); + + const projectedMessages = yield* messages.listByThreadId({ threadId }); + expect(projectedMessages.filter((message) => message.messageId === messageId)).toHaveLength( + 1, + ); + const projectedTurns = yield* turns.listByThreadId({ threadId }); + expect(projectedTurns.filter((turn) => turn.pendingMessageId === messageId)).toHaveLength(1); + }), + ); + + it.effect( + "startTurn derives stable turnId/messageId from a repeated commandId and random ids without one", + () => + Effect.gen(function* () { + const dispatched: OrchestrationCommand[] = []; + const turnAliases = new Map< + string, + { readonly threadId: ThreadId; readonly messageId: MessageId; readonly terminal: boolean } + >(); + const agents = makeAgentsCapability( + { + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + 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(), + }, + turnAliases, + ); + const threadId = ThreadId.make("thread-idempotent-ids"); + const commandId = CommandId.make("cmd-idempotent-turn-start"); + + // Same commandId, NO messageId: both turnId and messageId must be derived + // deterministically from the commandId, so a retry (which the engine + // receipt-dedups back to the first turn) returns the SAME identifiers the + // first dispatch persisted instead of a fresh, never-persisted pair. + const first = yield* agents.startTurn({ threadId, text: "first", commandId }); + const second = yield* agents.startTurn({ threadId, text: "second", commandId }); + expect(second.turnId).toBe(first.turnId); + expect(second.messageId).toBe(first.messageId); + + // The alias registered under that stable turnId points at the same + // messageId the turn.start actually dispatched, so a later readTerminalTurn + // correlates (turnId -> alias.messageId -> pendingMessageId) rather than + // dangling. + expect(turnAliases.get(String(first.turnId))?.messageId).toBe(first.messageId); + const firstTurnStart = dispatched.find((command) => command.type === "thread.turn.start"); + expect( + firstTurnStart?.type === "thread.turn.start" ? firstTurnStart.message.messageId : null, + ).toBe(first.messageId); + + // No commandId: ids are freshly minted, so two calls differ. + const withoutA = yield* agents.startTurn({ threadId, text: "a" }); + const withoutB = yield* agents.startTurn({ threadId, text: "b" }); + expect(withoutB.turnId).not.toBe(withoutA.turnId); + expect(withoutB.messageId).not.toBe(withoutA.messageId); + }), + ); + + it.effect( + "a retried startTurn keeps one persisted turn whose pendingMessageId matches the returned ids", + () => + Effect.gen(function* () { + const { agents, engine, turns } = yield* makeCapability; + yield* createProject(engine); + const { threadId } = yield* agents.createThread({ + projectId: ProjectId.make("project-agents"), + title: "Idempotent", + modelSelection, + }); + const commandId = CommandId.make("cmd-idempotent-await-turn-start"); + + // Retry with the same caller commandId and no messageId: stable ids across + // the receipt-deduped retry. + const first = yield* agents.startTurn({ threadId, text: "first", commandId }); + const second = yield* agents.startTurn({ threadId, text: "second", commandId }); + expect(second.turnId).toBe(first.turnId); + expect(second.messageId).toBe(first.messageId); + + // The engine deduped the retry, so exactly one turn is persisted, and its + // pendingMessageId equals the returned (derived) messageId. That equality + // is precisely what readTerminalTurn correlates on (turnId -> alias + // messageId -> pendingMessageId), so the retry's alias resolves rather than + // dangling. With random ids the retry would have returned a fresh messageId + // absent from this row, leaving awaitTurn to time out. + const persisted = (yield* turns.listByThreadId({ threadId })).filter( + (row) => row.pendingMessageId === first.messageId, + ); + expect(persisted).toHaveLength(1); + }), + ); + + it.effect("observeThread emits the owned snapshot followed by newer thread-detail events", () => Effect.gen(function* () { const { agents, engine } = yield* makeCapability; yield* createProject(engine); @@ -300,12 +477,19 @@ agentsIt("AgentsCapability", (it) => { modelSelection, }); - const collected = yield* Effect.scoped( + const [snapshotItem, eventItem] = yield* Effect.scoped( Effect.gen(function* () { - const fiber = yield* agents - .observeThread(threadId) - .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped); - yield* Effect.yieldNow; + const items = yield* Queue.unbounded(); + yield* agents.observeThread(threadId).pipe( + Stream.runForEach((item) => Queue.offer(items, item)), + Effect.forkScoped, + ); + // The snapshot is emitted first. observeThread subscribes to the live + // stream BEFORE reading the snapshot, so an activity dispatched only + // AFTER we have received the snapshot is guaranteed to be strictly + // newer than snapshotSequence and delivered (not filtered as a + // snapshot-duplicate). + const snapshot = yield* Queue.take(items); yield* engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-plugin-observe-activity"), @@ -321,18 +505,88 @@ agentsIt("AgentsCapability", (it) => { }, createdAt, }); - return yield* Fiber.join(fiber); + const event = yield* Queue.take(items); + return [snapshot, event] as const; }), ); - expect(collected[0]?.kind).toBe("snapshot"); - expect(collected[1]?.kind).toBe("event"); - expect(collected[1]?.kind === "event" ? collected[1].event.type : null).toBe( + expect(snapshotItem?.kind).toBe("snapshot"); + expect(eventItem?.kind).toBe("event"); + expect(eventItem?.kind === "event" ? eventItem.event.type : null).toBe( "thread.activity-appended", ); }), ); + it.effect("observeThread does not re-emit events already contained in the snapshot", () => + Effect.gen(function* () { + const { agents, engine } = yield* makeCapability; + yield* createProject(engine); + const { threadId } = yield* agents.createThread({ + projectId: ProjectId.make("project-agents"), + title: "Observed", + modelSelection, + }); + // Append an activity and let it project BEFORE observing, so the snapshot + // already contains it. It must NOT be re-emitted as a live event. + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-plugin-observe-preexisting"), + threadId, + activity: { + id: "event-plugin-observe-preexisting" as any, + tone: "info", + kind: "note", + summary: "Already in snapshot", + payload: {}, + turnId: null, + createdAt, + }, + createdAt, + }); + + const [first, second] = yield* Effect.scoped( + Effect.gen(function* () { + const items = yield* Queue.unbounded(); + yield* agents.observeThread(threadId).pipe( + Stream.runForEach((item) => Queue.offer(items, item)), + Effect.forkScoped, + ); + const snapshot = yield* Queue.take(items); + // Dispatch a strictly-newer activity; the observed live element must be + // this one, proving the pre-existing (snapshot) activity was deduped. + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-plugin-observe-newer"), + threadId, + activity: { + id: "event-plugin-observe-newer" as any, + tone: "info", + kind: "note", + summary: "Newer", + payload: {}, + turnId: null, + createdAt, + }, + createdAt, + }); + const live = yield* Queue.take(items); + return [snapshot, live] as const; + }), + ); + + expect(first?.kind).toBe("snapshot"); + // The only live event delivered is the newer one; the pre-existing + // activity (sequence <= snapshotSequence) was filtered out as a duplicate. + expect(second?.kind).toBe("event"); + const liveActivityId = + second?.kind === "event" && second.event.type === "thread.activity-appended" + ? second.event.payload.activity.id + : null; + expect(liveActivityId).toBe("event-plugin-observe-newer"); + }), + ); + it.effect( "awaitTurn returns already-terminal and streamed terminal turns with assistant text", () => @@ -474,4 +728,586 @@ agentsIt("AgentsCapability", (it) => { expect(instances.unavailable[0]?.instanceId).toBe("missing"); }), ); + + it.effect( + "guarded methods fail not-found for a missing thread and ownership for another plugin's thread", + () => + Effect.gen(function* () { + const { agents, engine } = yield* makeCapability; + yield* createProject(engine); + const otherThreadId = ThreadId.make("thread-owned-by-other"); + yield* dispatchThreadCreate(engine, { + threadId: otherThreadId, + owner: "plugin:other-plugin", + commandId: "cmd-m4-other-thread", + }); + + // A thread that does not exist fails not-found, NOT ownership: the + // not-found branch in the guard must be reachable. + const missingExit = yield* Effect.exit( + agents.observeThread(ThreadId.make("thread-m4-missing")).pipe(Stream.runCollect), + ); + expect(missingExit._tag).toBe("Failure"); + if (missingExit._tag === "Failure") { + expect(String(missingExit.cause)).toContain(AgentsThreadNotFoundError.name); + expect(String(missingExit.cause)).not.toContain(AgentsThreadOwnershipError.name); + } + + // A thread owned by a different plugin still fails ownership (guard intact). + const ownedExit = yield* Effect.exit( + agents.awaitTurn({ + threadId: otherThreadId, + turnId: TurnId.make("turn-m4"), + timeout: "10 millis", + }), + ); + expect(ownedExit._tag).toBe("Failure"); + if (ownedExit._tag === "Failure") { + expect(String(ownedExit.cause)).toContain(AgentsThreadOwnershipError.name); + } + }), + ); + + it.effect("startTurn rejects the inert prepareWorktree/runSetupScript bootstrap prep", () => + Effect.gen(function* () { + const dispatched: OrchestrationCommand[] = []; + const agents = makeAgentsCapability({ + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }, + snapshots: { + // Thread does not exist yet — the path that used to forward prep. + getThreadOwnerById: () => Effect.succeed(Option.none()), + 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-bootstrap-prep"); + + // The engine command plane ignores prepareWorktree/runSetupScript (only + // ws.ts honors them), so forwarding them would be a silent no-op. The + // capability now fails typed BEFORE dispatching anything — including the + // thread.create it would otherwise emit for a new thread. + const exit = yield* Effect.exit( + agents.startTurn({ + threadId, + text: "bootstrap prep", + bootstrap: { + createThread: { + projectId: ProjectId.make("project-agents"), + title: "Bootstrap", + modelSelection, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + }, + prepareWorktree: { projectCwd: "/tmp/project-agents", baseBranch: "main" }, + runSetupScript: true, + }, + }), + ); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(String(exit.cause)).toContain(AgentsBootstrapUnsupportedError.name); + } + // Nothing was dispatched — the rejection precedes the thread.create. + expect(dispatched).toHaveLength(0); + }), + ); + + it.effect("startTurn still accepts a bootstrap that only creates the thread", () => + Effect.gen(function* () { + const { agents, engine, snapshots } = yield* makeCapability; + yield* createProject(engine); + const threadId = ThreadId.make("thread-bootstrap-create-only"); + + 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("startTurn deletes the pending alias when a start on an existing thread fails", () => + Effect.gen(function* () { + const turnAliases = new Map< + string, + { readonly threadId: ThreadId; readonly messageId: MessageId; readonly terminal: boolean } + >(); + const agents = makeAgentsCapability( + { + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: (command: OrchestrationCommand) => + command.type === "thread.turn.start" + ? Effect.fail({ _tag: "SimulatedDispatchFailure" as const }) + : Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + snapshots: { + // Existing, owned thread -> createdThread is false; a failed turn + // start must still remove the alias set before dispatch. + 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(), + }, + turnAliases, + ); + const threadId = ThreadId.make("thread-existing-owned"); + + const exit = yield* Effect.exit(agents.startTurn({ threadId, text: "will fail" })); + expect(exit._tag).toBe("Failure"); + // A failed start on an existing thread must not leak its pending alias; + // repeated failed starts would otherwise grow this map for the plugin + // process lifetime. + expect(turnAliases.size).toBe(0); + }), + ); + + it.effect("startTurn evicts the oldest alias when turnAliases reaches its cap", () => + Effect.gen(function* () { + const turnAliases = new Map< + string, + { readonly threadId: ThreadId; readonly messageId: MessageId; readonly terminal: boolean } + >(); + const agents = makeAgentsCapability( + { + pluginId, + engine: { + readEvents: () => Stream.empty, + // Every start succeeds, so the alias is kept (not rolled back), and + // three un-awaited turns accumulate against the cap of 2. + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + snapshots: { + // Existing, owned thread -> startTurn dispatches turn.start directly. + 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(), + }, + turnAliases, + 2, + ); + const threadId = ThreadId.make("thread-cap"); + + // Three un-awaited starts; none is pruned by a terminal read, so the FIFO + // cap is what bounds the map. + const first = yield* agents.startTurn({ threadId, text: "one" }); + const second = yield* agents.startTurn({ threadId, text: "two" }); + const third = yield* agents.startTurn({ threadId, text: "three" }); + + // The map is bounded at the cap; the oldest alias was evicted and the two + // most-recent turns are retained. + expect(turnAliases.size).toBeLessThanOrEqual(2); + expect(turnAliases.has(String(first.turnId))).toBe(false); + expect(turnAliases.has(String(second.turnId))).toBe(true); + expect(turnAliases.has(String(third.turnId))).toBe(true); + }), + ); + + it.effect("the cap evicts an already-terminal alias before a still-pending one", () => + Effect.gen(function* () { + const turnAliases = new Map< + string, + { readonly threadId: ThreadId; readonly messageId: MessageId; readonly terminal: boolean } + >(); + const threadId = ThreadId.make("thread-cap-terminal"); + // getByTurnId resolves terminal ONLY for turnIds we mark below; every + // start otherwise leaves its alias pending. + const terminalTurnIds = new Set(); + const agents = makeAgentsCapability( + { + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + snapshots: { + getThreadOwnerById: () => Effect.succeed(Option.some("plugin:agent-plugin" as any)), + getThreadDetailById: () => Effect.succeed(Option.none()), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + } as any, + turns: { + getByTurnId: ({ turnId }: { readonly turnId: TurnId }) => + Effect.succeed( + terminalTurnIds.has(String(turnId)) + ? Option.some({ + threadId, + turnId, + pendingMessageId: null, + state: "completed", + assistantMessageId: null, + } as any) + : Option.none(), + ), + listByThreadId: () => Effect.succeed([]), + } as any, + messages: { + getByMessageId: () => Effect.succeed(Option.none()), + } as any, + providerInstances: makeProviderRegistry(), + }, + turnAliases, + 2, + ); + + // A is pending and recorded FIRST (oldest). B is recorded second and then + // awaited to completion, which marks B's alias terminal. + const a = yield* agents.startTurn({ threadId, text: "pending-A" }); + const b = yield* agents.startTurn({ threadId, text: "will-complete-B" }); + terminalTurnIds.add(String(b.turnId)); + yield* agents.awaitTurn({ threadId, turnId: b.turnId, timeout: "1 second" }); + expect(turnAliases.get(String(b.turnId))?.terminal).toBe(true); + + // C hits the cap. Eviction must reclaim the TERMINAL entry (B), never the + // still-pending A, even though A is older by insertion order. + const c = yield* agents.startTurn({ threadId, text: "pending-C" }); + expect(turnAliases.size).toBeLessThanOrEqual(2); + expect(turnAliases.has(String(a.turnId))).toBe(true); + expect(turnAliases.has(String(b.turnId))).toBe(false); + expect(turnAliases.has(String(c.turnId))).toBe(true); + }), + ); + + it.effect("a second awaitTurn on a completed turn resolves instead of timing out", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-reawait"); + const turnAliases = new Map< + string, + { readonly threadId: ThreadId; readonly messageId: MessageId; readonly terminal: boolean } + >(); + // Synthetic plugin turnId never matches getByTurnId directly (the engine + // assigns its own turnId), so correlation is ONLY via the alias's + // messageId -> the projected row's pendingMessageId. `pendingMessageId` + // is wired to the alias the startTurn below records. + let pendingMessageId: string | null = null; + const agents = makeAgentsCapability( + { + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + snapshots: { + getThreadOwnerById: () => Effect.succeed(Option.some("plugin:agent-plugin" as any)), + getThreadDetailById: () => Effect.succeed(Option.none()), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + } as any, + turns: { + getByTurnId: () => Effect.succeed(Option.none()), + listByThreadId: () => + Effect.succeed( + pendingMessageId === null + ? [] + : [ + { + threadId, + turnId: TurnId.make("engine-assigned-turn"), + pendingMessageId, + state: "completed", + assistantMessageId: null, + } as any, + ], + ), + } as any, + messages: { + getByMessageId: () => Effect.succeed(Option.none()), + } as any, + providerInstances: makeProviderRegistry(), + }, + turnAliases, + ); + + const started = yield* agents.startTurn({ threadId, text: "await twice" }); + pendingMessageId = String(started.messageId); + + const first = yield* agents.awaitTurn({ + threadId, + turnId: started.turnId, + timeout: "1 second", + }); + expect(first).toEqual({ state: "completed", assistantText: null }); + + // The prune-on-terminal-read bug deleted the alias here, so this second + // await found nothing on every path and polled to a timeout. Keeping the + // alias (marked terminal) lets the re-await resolve immediately. + const second = yield* agents.awaitTurn({ + threadId, + turnId: started.turnId, + timeout: "1 second", + }); + expect(second).toEqual({ state: "completed", assistantText: null }); + expect(turnAliases.get(String(started.turnId))?.terminal).toBe(true); + }), + ); + + it.effect("awaitTurn fails typed on a malformed timeout instead of defecting", () => + Effect.gen(function* () { + const agents = makeAgentsCapability({ + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + snapshots: { + getThreadOwnerById: () => Effect.succeed(Option.some("plugin:agent-plugin" as any)), + getThreadDetailById: () => Effect.succeed(Option.none()), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + } as any, + turns: { + getByTurnId: () => Effect.succeed(Option.none()), + listByThreadId: () => Effect.succeed([]), + } as any, + messages: {} as any, + providerInstances: makeProviderRegistry(), + }); + + const exit = yield* Effect.exit( + agents.awaitTurn({ + threadId: ThreadId.make("thread-bad-timeout"), + turnId: TurnId.make("turn-bad-timeout"), + timeout: "soon", + }), + ); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + // A typed failure, NOT a defect (Duration.fromInputUnsafe would throw). + expect(String(exit.cause)).toContain(AgentsInvalidTimeoutError.name); + expect(String(exit.cause)).not.toContain("Die"); + } + }), + ); + + it.effect("listPendingRequests drops requests that already have a resolution activity", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-pending-requests"); + const activities = [ + { + id: "act-1", + tone: "approval", + kind: "approval.requested", + summary: "Approval requested", + payload: { requestId: "req-resolved" }, + turnId: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "act-2", + tone: "approval", + kind: "approval.resolved", + summary: "Approval resolved", + payload: { requestId: "req-resolved", decision: "accept" }, + turnId: null, + createdAt: "2026-01-01T00:00:01.000Z", + }, + { + id: "act-3", + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { requestId: "req-open" }, + turnId: null, + createdAt: "2026-01-01T00:00:02.000Z", + }, + { + id: "act-4", + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { requestId: "req-stale" }, + turnId: null, + createdAt: "2026-01-01T00:00:03.000Z", + }, + { + id: "act-5", + tone: "error", + kind: "provider.user-input.respond.failed", + summary: "User input respond failed", + payload: { requestId: "req-stale", detail: "Unknown pending user-input request" }, + turnId: null, + createdAt: "2026-01-01T00:00:04.000Z", + }, + ]; + const agents = makeAgentsCapability({ + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + snapshots: { + getThreadOwnerById: () => Effect.succeed(Option.some("plugin:agent-plugin" as any)), + getThreadDetailById: () => Effect.succeed(Option.some({ activities } as any)), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + } as any, + turns: {} as any, + messages: {} as any, + providerInstances: makeProviderRegistry(), + }); + + const pending = yield* agents.listPendingRequests(threadId); + // Only the still-open request survives: the resolved approval and the + // stale-failed user-input request are both filtered out. + expect(pending.map((request) => request.requestId)).toEqual(["req-open"]); + }), + ); + + it.effect( + "startTurn retry with the same commandId but no messageId reuses the first call's messageId", + () => + Effect.gen(function* () { + const dispatched: OrchestrationCommand[] = []; + const turnAliases = new Map< + string, + { readonly threadId: ThreadId; readonly messageId: MessageId; readonly terminal: boolean } + >(); + const agents = makeAgentsCapability( + { + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + 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(), + }, + turnAliases, + ); + const threadId = ThreadId.make("thread-retry-explicit-message"); + const commandId = CommandId.make("cmd-retry-explicit-turn-start"); + const explicitMessageId = MessageId.make("message-retry-explicit"); + + // First call establishes the alias with the EXPLICIT messageId M1 — the id + // the engine persists as pendingMessageId for the (derived) turnId. + const first = yield* agents.startTurn({ + threadId, + text: "first", + messageId: explicitMessageId, + commandId, + }); + expect(first.messageId).toBe(explicitMessageId); + + // Retry: SAME commandId, NO messageId. The engine receipt-dedups this back + // to the first turn (pendingMessageId still M1). A retry that derived + // messageId=hash(commandId) here would overwrite the alias with an id the + // engine never persisted, so awaitTurn(turnId) would correlate on the wrong + // messageId and time out. The fix reuses the first call's messageId. + const second = yield* agents.startTurn({ threadId, text: "second", commandId }); + expect(second.turnId).toBe(first.turnId); + expect(second.messageId).toBe(explicitMessageId); + // The alias for that turnId still maps to M1, so readTerminalTurn/awaitTurn + // correlates (turnId -> alias.messageId -> pendingMessageId). + expect(turnAliases.get(String(second.turnId))?.messageId).toBe(explicitMessageId); + }), + ); + + it.effect("awaitTerminalTurn resolves via the re-poll when no waking event is delivered", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-poll-fallback"); + const turnId = TurnId.make("turn-poll-fallback"); + // The turn becomes terminal AFTER awaitTurn has subscribed and parked, and + // NO domain event is emitted to wake the deferred (streamDomainEvents is + // empty). Only the bounded re-poll can make progress; the OLD event-only + // path would hang until the outer timeout. + let terminal = false; + const terminalRow = { + threadId, + turnId, + pendingMessageId: null, + state: "completed", + assistantMessageId: null, + } as any; + const agents = makeAgentsCapability({ + pluginId, + engine: { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.empty, + } as unknown as OrchestrationEngineService["Service"], + snapshots: { + getThreadOwnerById: () => Effect.succeed(Option.some("plugin:agent-plugin" as any)), + getThreadDetailById: () => Effect.succeed(Option.none()), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), + } as any, + turns: { + getByTurnId: () => + Effect.sync(() => (terminal ? Option.some(terminalRow) : Option.none())), + listByThreadId: () => Effect.succeed([]), + } as any, + messages: { + getByMessageId: () => Effect.succeed(Option.none()), + } as any, + providerInstances: makeProviderRegistry(), + }); + + const result = yield* Effect.scoped( + Effect.gen(function* () { + const fiber = yield* agents + .awaitTurn({ threadId, turnId, timeout: 60_000 }) + .pipe(Effect.forkScoped); + // Let the fiber subscribe and park on the race; its first poll read sees + // no terminal row. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + terminal = true; + // Advance past the poll interval. The deferred never fires (empty + // stream), so resolution can only come from the re-poll re-reading the + // projection. + yield* TestClock.adjust("300 millis"); + return yield* Fiber.join(fiber); + }), + ); + + expect(result).toEqual({ state: "completed", assistantText: null }); + }), + ); }); diff --git a/apps/server/src/plugins/capabilities/AgentsCapability.ts b/apps/server/src/plugins/capabilities/AgentsCapability.ts index fa87972eabd..1a8e219b024 100644 --- a/apps/server/src/plugins/capabilities/AgentsCapability.ts +++ b/apps/server/src/plugins/capabilities/AgentsCapability.ts @@ -22,9 +22,11 @@ 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 Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; 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"; @@ -32,6 +34,12 @@ import type * as ProjectionTurns from "../../persistence/Services/ProjectionTurn import type { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; const DEFAULT_AWAIT_TURN_TIMEOUT = Duration.minutes(30); +// Re-poll cadence for the awaitTerminalTurn fallback below. streamDomainEvents has +// no subscription-readiness signal, so a turn that reaches terminal in the gap +// between the post-subscribe read and the watcher going live may emit no later +// event to wake the deferred; the poll re-reads the projection to guarantee +// progress. awaitTurn already bounds the total wait via Effect.timeoutOption. +const TERMINAL_POLL_INTERVAL = Duration.millis(250); export class AgentsThreadOwnershipError extends Schema.TaggedErrorClass()( "AgentsThreadOwnershipError", @@ -70,12 +78,50 @@ export class AgentsTurnAwaitTimeoutError extends Schema.TaggedErrorClass()( + "AgentsInvalidTimeoutError", + { + timeout: Schema.String, + }, +) { + override get message(): string { + return `Invalid awaitTurn timeout ${JSON.stringify(this.timeout)}; expected milliseconds or a Duration input like "30 seconds".`; + } +} + +export class AgentsBootstrapUnsupportedError extends Schema.TaggedErrorClass()( + "AgentsBootstrapUnsupportedError", + { + threadId: Schema.String, + fields: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `startTurn bootstrap field(s) ${this.fields.join(", ")} are not supported on the plugin capability path for thread ${this.threadId}; create the worktree via the vcs capability and run setup via the terminals capability instead.`; + } +} + 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()}`); +// Deterministic id derivations keyed on the caller-supplied commandId. The engine +// dedups dispatch by commandId, so a retry with the same commandId does NOT +// persist a new turn — the original one stays. The ids startTurn returns (and +// registers in turnAliases) must therefore be STABLE across retries of the same +// commandId; otherwise a retry would hand back a fresh messageId that was never +// persisted and a later awaitTurn(turnId) — which correlates via the alias's +// messageId — could never match and would time out. Hashing keeps the derived id +// opaque and bounded rather than echoing the raw commandId. +const stableIdSuffix = (commandId: CommandId) => + NodeCrypto.createHash("sha256").update(String(commandId)).digest("hex").slice(0, 32); +const messageIdForCommand = (commandId: CommandId) => + MessageId.make(`plugin-message:${stableIdSuffix(commandId)}`); +const turnIdForCommand = (commandId: CommandId) => + TurnId.make(`plugin-turn:${stableIdSuffix(commandId)}`); + function isThreadDetailEvent(event: OrchestrationEvent): boolean { return ( event.type === "thread.message-sent" || @@ -87,10 +133,19 @@ function isThreadDetailEvent(event: OrchestrationEvent): boolean { ); } -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); +function toTimeoutDuration( + input: string | number | undefined, +): Effect.Effect { + if (input === undefined) return Effect.succeed(DEFAULT_AWAIT_TURN_TIMEOUT); + if (typeof input === "number") return Effect.succeed(Duration.millis(input)); + // The timeout is plugin-provided DATA: parse it safely and fail typed instead + // of defecting the fiber on a malformed string like "soon" + // (Duration.fromInputUnsafe throws, which would surface as an internal RPC + // failure and bypass normal error handling). + const parsed = Duration.fromInput(input as Duration.Input); + return Option.isSome(parsed) + ? Effect.succeed(parsed.value) + : Effect.fail(new AgentsInvalidTimeoutError({ timeout: input })); } type TerminalProjectionTurn = ProjectionTurns.ProjectionTurnById & { @@ -109,6 +164,18 @@ function isTerminalTurn( return row !== null && terminalState(row.state); } +function activityRequestId(payload: unknown): string | null { + if ( + typeof payload !== "object" || + payload === null || + !("requestId" in payload) || + typeof (payload as { requestId?: unknown }).requestId !== "string" + ) { + return null; + } + return (payload as { requestId: string }).requestId; +} + function pendingRequestFromActivity(activity: { readonly kind: string; readonly payload: unknown; @@ -116,21 +183,83 @@ function pendingRequestFromActivity(activity: { 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" - ) { + const requestId = activityRequestId(activity.payload); + if (requestId === null) { return null; } return { kind: activity.kind, - requestId: (activity.payload as { requestId: string }).requestId, + requestId, activity: activity as AgentsPendingRequest["activity"], }; } +// Details that mark a respond failure as "the provider no longer knows this +// request", mirroring the stale-failure detail matching in +// ProjectionPipeline.ts (isStalePendingApprovalFailureDetail + +// derivePendingUserInputCountFromActivities). Such a request is closed for all +// practical purposes and must not be re-surfaced as pending. +const STALE_REQUEST_FAILURE_DETAILS = [ + "stale pending approval request", + "unknown pending approval request", + "unknown pending permission request", + "stale pending user-input request", + "unknown pending user-input request", + "unknown pending user input request", + "unknown pending codex user input request", +]; + +function isStaleRequestFailure(kind: string, payload: unknown): boolean { + if ( + kind !== "provider.approval.respond.failed" && + kind !== "provider.user-input.respond.failed" + ) { + return false; + } + const detail = + typeof payload === "object" && payload !== null && "detail" in payload + ? (payload as { detail?: unknown }).detail + : null; + if (typeof detail !== "string") return false; + const lowered = detail.toLowerCase(); + return STALE_REQUEST_FAILURE_DETAILS.some((marker) => lowered.includes(marker)); +} + +// Reduce a thread's activity log to the requests that are STILL open: a +// `*.requested` opens, a matching `*.resolved` (or a stale/unknown respond +// failure) closes. Without this, every historical request would be returned +// forever and an already-answered approval could be re-surfaced and +// double-submitted. Mirrors the fail-closed resolution accounting the +// projection pipeline applies for pending-approval rows and the user-input +// counter. +function pendingRequestsFromActivities( + activities: ReadonlyArray, +): ReadonlyArray { + const ordered = [...activities].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); + const open = new Map(); + for (const activity of ordered) { + const pending = pendingRequestFromActivity(activity); + if (pending) { + open.set(pending.requestId, pending); + continue; + } + if ( + activity.kind === "approval.resolved" || + activity.kind === "user-input.resolved" || + isStaleRequestFailure(activity.kind, activity.payload) + ) { + const requestId = activityRequestId(activity.payload); + if (requestId !== null) { + open.delete(requestId); + } + } + } + return [...open.values()]; +} + function normalizeBootstrapForTurnStart( bootstrap: AgentsStartTurnBootstrapInput | undefined, ): AgentsStartTurnBootstrapInput | undefined { @@ -148,35 +277,84 @@ function normalizeBootstrapForTurnStart( }; } -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< +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"]; + }, + // Session-local turnId -> {threadId, messageId} bridge used by readTerminalTurn. + // Injectable (defaulting to a fresh map) so tests can assert that a failed + // start does not leak entries; production always uses the default. `terminal` + // is an eviction hint only (see rememberTurnAlias) — terminal-ness itself is + // always re-derived from the projection on every read. + turnAliases: Map< string, - { readonly threadId: ThreadId; readonly messageId: MessageId } - >(); + { readonly threadId: ThreadId; readonly messageId: MessageId; readonly terminal: boolean } + > = new Map(), + // Upper bound on live aliases. Injectable (defaulting generously) so tests can + // drive a small value; see rememberTurnAlias for the eviction policy. + maxTurnAliases: number = 4096, +): AgentsCapability { + const owner = `plugin:${input.pluginId}` as `plugin:${string}`; + + // Record a pending turn alias under a bounded cap. An alias is KEPT (marked + // terminal, not deleted) once its turn completes so that a re-await or a + // concurrent second awaiter still resolves; eviction therefore reclaims the + // oldest TERMINAL entry first and only falls back to evicting the oldest + // pending entry when every entry is still pending — the cap must hold, but + // that fallback is only reachable under pathological (> cap) concurrent + // un-awaited turns, where the evicted turn's later awaitTurn falls through + // to the not-found path (degraded, not a crash). + const rememberTurnAlias = ( + turnId: TurnId, + entry: { readonly threadId: ThreadId; readonly messageId: MessageId }, + ) => { + const key = String(turnId); + if (!turnAliases.has(key) && turnAliases.size >= maxTurnAliases) { + let evict: string | undefined; + for (const [candidateKey, candidate] of turnAliases) { + if (candidate.terminal) { + evict = candidateKey; + break; + } + // Oldest overall as a fallback, used only when nothing is terminal. + evict ??= candidateKey; + } + if (evict !== undefined) turnAliases.delete(evict); + } + turnAliases.set(key, { ...entry, terminal: false }); + }; 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), - }), - ); - }), + Effect.flatMap( + ( + actualOwner, + ): Effect.Effect => { + // Distinguish "thread does not exist" from "owned by another plugin": a + // missing owner is a not-found (otherwise the AgentsThreadNotFoundError + // branches in callers are unreachable), while a real owner mismatch is + // an ownership failure. A thread owned by us still passes. + if (Option.isNone(actualOwner)) { + return Effect.fail(new AgentsThreadNotFoundError({ threadId })); + } + if (actualOwner.value === owner) { + return Effect.void; + } + return Effect.fail( + new AgentsThreadOwnershipError({ + pluginId: input.pluginId, + threadId, + expectedOwner: owner, + actualOwner: actualOwner.value, + }), + ); + }, + ), ); const readTerminalTurn = (threadId: ThreadId, turnId: TurnId) => @@ -199,9 +377,19 @@ export function makeAgentsCapability(input: { }).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)); + // Keep the alias but mark it terminal. Deleting it here broke re-await: + // a synthetic plugin turnId correlates ONLY via the alias (getByTurnId + // never matches it), so a second awaitTurn on a completed turn — a + // re-await after done, a concurrent second waiter, or an await after a + // prior internal read — would find nothing and poll to timeout. The + // terminal flag is purely an eviction hint for rememberTurnAlias, which + // reclaims terminal entries first, keeping the map bounded without ever + // dropping a turn a live awaitTurn may still need. + const key = String(turnId); + const alias = turnAliases.get(key); + if (alias && !alias.terminal) { + turnAliases.set(key, { ...alias, terminal: true }); + } return Effect.succeed(row); }), ); @@ -245,7 +433,25 @@ export function makeAgentsCapability(input: { yield* waitForEvent.pipe(Effect.forkScoped); const afterSubscribe = yield* readTerminalTurn(threadId, turnId); if (afterSubscribe) return afterSubscribe; - return yield* Deferred.await(terminalDeferred); + // Race the event-driven wake against a bounded re-poll. forkScoped returns + // before the watcher has actually subscribed, so a turn that goes terminal + // in that gap may produce no later event to wake terminalDeferred; the poll + // guarantees progress by re-reading the projection. The event path keeps the + // common case low-latency, and awaitTurn's outer Effect.timeoutOption bounds + // the total wait so the poll needs no independent cap. + const pollForTerminal: Effect.Effect = + Effect.suspend(() => + readTerminalTurn(threadId, turnId).pipe( + Effect.flatMap((row) => + row + ? Effect.succeed(row) + : Effect.sleep(TERMINAL_POLL_INTERVAL).pipe( + Effect.flatMap(() => pollForTerminal), + ), + ), + ), + ); + return yield* Effect.race(Deferred.await(terminalDeferred), pollForTerminal); }), ); }); @@ -288,6 +494,23 @@ export function makeAgentsCapability(input: { startTurn: (request) => Effect.gen(function* () { const bootstrap = normalizeBootstrapForTurnStart(request.bootstrap); + // The plugin path dispatches thread.turn.start straight to the engine, + // whose decider ignores bootstrap prep — the atomic prepareWorktree / + // runSetupScript handling lives only in the WS entrypoint + // (dispatchBootstrapTurnStart in ws.ts). Forwarding those fields here + // would be a silent no-op that misleads the caller into believing the + // prep ran, so reject them loudly instead: plugins create worktrees via + // the vcs capability and run setup via the terminals capability. + const unsupportedBootstrapFields = [ + ...(bootstrap?.prepareWorktree !== undefined ? ["prepareWorktree"] : []), + ...(bootstrap?.runSetupScript ? ["runSetupScript"] : []), + ]; + if (unsupportedBootstrapFields.length > 0) { + return yield* new AgentsBootstrapUnsupportedError({ + threadId: request.threadId, + fields: unsupportedBootstrapFields, + }); + } const actualOwner = yield* input.snapshots.getThreadOwnerById(request.threadId); if (Option.isSome(actualOwner) && actualOwner.value !== owner) { return yield* new AgentsThreadOwnershipError({ @@ -329,16 +552,35 @@ export function makeAgentsCapability(input: { 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; + // A caller-supplied commandId makes the turn idempotent (the engine dedups + // dispatch by commandId), so the returned turnId/messageId must be stable + // across retries — derive them deterministically from the commandId so a + // retry resolves the same alias the first call persisted. Without a + // commandId, mint fresh random ids as before. + const turnId = + request.commandId !== undefined ? turnIdForCommand(request.commandId) : nextTurnId(); + // Idempotent retry: same commandId → same derived turnId, so an existing + // alias holds the messageId the first call actually persisted + // (pendingMessageId). Reuse it so a retry that omits messageId — or supplies + // a different one — doesn't overwrite the alias with a fresh derived id the + // engine never persisted, which would leave awaitTurn(turnId) correlating on + // the wrong messageId and timing out. + const existingAlias = + request.commandId !== undefined ? turnAliases.get(String(turnId)) : undefined; + const messageId = + existingAlias?.messageId ?? + request.messageId ?? + (request.commandId !== undefined + ? messageIdForCommand(request.commandId) + : nextMessageId()); + rememberTurnAlias(turnId, { threadId: request.threadId, messageId }); + // No bootstrap is forwarded to the engine: createThread was handled + // explicitly above, and the unsupported prep fields were rejected at + // the top of this method (the decider would ignore them anyway). yield* input.engine .dispatch({ type: "thread.turn.start", - commandId: nextCommandId("turn-start"), + commandId: request.commandId ?? nextCommandId("turn-start"), threadId: request.threadId, message: { messageId, @@ -351,65 +593,96 @@ export function makeAgentsCapability(input: { : {}), 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, + // Always drop the pending alias on failure so a failed start never + // leaks a turnAliases entry (the map would otherwise grow for the + // plugin process lifetime on repeated failed starts). Additionally + // roll back the thread we just created, but only when this start + // created it. + Effect.sync(() => turnAliases.delete(String(turnId))).pipe( + Effect.andThen( + createdThread + ? input.engine + .dispatch({ + type: "thread.delete", + commandId: nextCommandId("thread-create-rollback"), + threadId: request.threadId, + }) + .pipe(Effect.ignore) + : Effect.void, + ), + ), ), ); return { turnId, messageId }; }), observeThread: (threadId) => - Stream.fromEffect( + Stream.unwrap( 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( + // Subscribe to live thread-detail events into a bounded, back-pressured + // queue BEFORE reading the snapshot. The previous Stream.concat only + // subscribed AFTER the snapshot was emitted, so any event committed in + // that window was dropped. The engine commits the projection update + // before publishing to the domain-event PubSub, so once we are + // subscribed here every event committed after this point is captured in + // the buffer. A hard, scheduler-independent guarantee would additionally + // require a subscription-readiness signal from streamDomainEvents (an + // engine-level change); ws.ts carries the same residual assumption. + const buffer = yield* Queue.bounded(256); + yield* input.engine.streamDomainEvents.pipe( Stream.filter( (event) => event.aggregateKind === "thread" && event.aggregateId === threadId && isThreadDetailEvent(event), ), - Stream.map((event) => ({ kind: "event" as const, event })), - ), - ), + Stream.runForEach((event) => Queue.offer(buffer, event)), + Effect.forkScoped, + ); + + // Read snapshotSequence FIRST, then the thread detail, sequentially + // (not concurrently). getThreadDetailById returns only + // Option with no per-thread sequence, so the replay + // is deduped against the GLOBAL snapshotSequence. Reading that threshold + // before the detail guarantees it never exceeds the state the detail + // reflects, so no committed update is ever filtered out (no dropped + // events); a bounded duplicate within the read window is idempotent on + // the revision-gated client. Being simultaneously dup-free AND gap-free + // is unattainable without a per-thread snapshot sequence + // (getThreadDetailById does not expose one), so we deliberately choose + // at-least-once: a lost update is worse than an idempotent re-render. + const snapshotSequence = yield* input.snapshots + .getSnapshotSequence() + .pipe(Effect.map((snapshot) => snapshot.snapshotSequence)); + const threadDetail = yield* input.snapshots.getThreadDetailById(threadId); + if (Option.isNone(threadDetail)) { + return yield* new AgentsThreadNotFoundError({ threadId }); + } + + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot: { snapshotSequence, thread: threadDetail.value }, + }), + Stream.fromQueue(buffer).pipe( + // Skip events already reflected in the snapshot to avoid emitting a + // duplicate of an event the snapshot already contains. + Stream.filter((event) => event.sequence > snapshotSequence), + Stream.map((event) => ({ kind: "event" as const, event })), + ), + ); + }), ), awaitTurn: (request) => Effect.gen(function* () { yield* requireOwnedThread(request.threadId); - const timeout = toTimeoutDuration(request.timeout); + const timeout = yield* toTimeoutDuration(request.timeout); const terminal = yield* awaitTerminalTurn(request.threadId, request.turnId).pipe( Effect.timeoutOption(timeout), Effect.flatMap( @@ -435,10 +708,7 @@ export function makeAgentsCapability(input: { if (Option.isNone(thread)) { return yield* new AgentsThreadNotFoundError({ threadId }); } - return thread.value.activities.flatMap((activity) => { - const pending = pendingRequestFromActivity(activity); - return pending ? [pending] : []; - }); + return pendingRequestsFromActivities(thread.value.activities); }), respondToApproval: (request) => diff --git a/apps/server/src/plugins/capabilities/DatabaseCapability.test.ts b/apps/server/src/plugins/capabilities/DatabaseCapability.test.ts new file mode 100644 index 00000000000..7cec565da57 --- /dev/null +++ b/apps/server/src/plugins/capabilities/DatabaseCapability.test.ts @@ -0,0 +1,19 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "../../persistence/NodeSqliteClient.ts"; +import { makeDatabaseCapability } from "./DatabaseCapability.ts"; + +it.effect("database.client runs a tagged-template query", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cap = makeDatabaseCapability(sql); + + yield* cap.client`CREATE TABLE t_probe (x INTEGER)`.unprepared; + yield* cap.client`INSERT INTO t_probe (x) VALUES (1)`.unprepared; + const rows = yield* cap.client<{ x: number }>`SELECT x FROM t_probe`; + + assert.equal(rows[0]?.x, 1); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), +); diff --git a/apps/server/src/plugins/capabilities/DatabaseCapability.ts b/apps/server/src/plugins/capabilities/DatabaseCapability.ts index 94744860579..c1635cc6384 100644 --- a/apps/server/src/plugins/capabilities/DatabaseCapability.ts +++ b/apps/server/src/plugins/capabilities/DatabaseCapability.ts @@ -1,8 +1,17 @@ import type { DatabaseCapability } from "@t3tools/plugin-sdk"; import type * as SqlClient from "effect/unstable/sql/SqlClient"; +// INTENTIONAL, by design under the full-trust-in-process model: a plugin granted +// the `database` capability already runs with full in-process trust, so exposing +// the raw `SqlClient` plus `sql.unsafe` grants it nothing beyond what `execute` +// already would. The `p__*` table prefix is a migration-time authoring +// convention, NOT a runtime sandbox — the façade deliberately does not police +// runtime statements, namespace tables, or enforce a read-only path. Do not +// "harden" this into a restricted client; that would break the ported plugin +// code this capability exists to run without adding any real isolation. export function makeDatabaseCapability(sql: SqlClient.SqlClient): DatabaseCapability { return { + client: sql, execute: (statement, params = []) => sql.unsafe>(statement, params).unprepared, withTransaction: (effect) => sql.withTransaction(effect), diff --git a/apps/server/src/plugins/capabilities/FilesystemCapability.test.ts b/apps/server/src/plugins/capabilities/FilesystemCapability.test.ts new file mode 100644 index 00000000000..b1861eca324 --- /dev/null +++ b/apps/server/src/plugins/capabilities/FilesystemCapability.test.ts @@ -0,0 +1,400 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { writeFileAtomic } from "@t3tools/plugin-sdk"; + +import { FilesystemPathError, makeFilesystemCapability } from "./FilesystemCapability.ts"; +import { makePluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; + +const TestLayer = NodeServices.layer; +const layer = it.layer(TestLayer); + +const projectShell = (workspaceRoot: string, id = "project-1") => + ({ + id, + title: id, + workspaceRoot, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-03T00:00:00.000Z", + updatedAt: "2026-07-03T00:00:00.000Z", + }) as any; + +function makeCapability(input: { + readonly projectRoots: ReadonlyArray; + readonly grants: any; +}) { + return makeFilesystemCapability({ + snapshots: { + getShellSnapshot: () => + Effect.succeed({ + projects: input.projectRoots.map((root, index) => projectShell(root, `project-${index}`)), + threads: [], + } as any), + } as any, + grants: input.grants, + }); +} + +const makeTempDir = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix }); + }); + +const expectPathFailure = (effect: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* Effect.exit(effect); + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + assert.include(String(exit.cause), FilesystemPathError.name); + } + }); + +layer("FilesystemCapability", (it) => { + it.effect("rejects traversal and absolute relative paths before touching the filesystem", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* expectPathFailure(filesystem.readFileString({ root, relativePath: "../secret" })); + yield* expectPathFailure(filesystem.exists({ root, relativePath: "/tmp/secret" })); + }), + ), + ); + + it.effect("round-trips files, directories, stats, lists, roots, and idempotent remove", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.makeDirectory({ root, relativePath: "notes" }); + yield* filesystem.writeFileString({ + root, + relativePath: "notes/today.txt", + contents: "hello", + }); + assert.equal( + yield* filesystem.readFileString({ root, relativePath: "notes/today.txt" }), + "hello", + ); + assert.deepEqual( + Array.from(yield* filesystem.readFile({ root, relativePath: "notes/today.txt" })), + Array.from(new TextEncoder().encode("hello")), + ); + assert.isTrue(yield* filesystem.exists({ root, relativePath: "notes/today.txt" })); + const stat = yield* filesystem.stat({ root, relativePath: "notes/today.txt" }); + assert.equal(stat.type, "file"); + assert.equal(stat.size, 5); + assert.isAtLeast(stat.mtime, 0); + assert.deepEqual(yield* filesystem.listDir({ root, relativePath: "notes" }), [ + { name: "today.txt", relativePath: "notes/today.txt", type: "file" }, + ]); + assert.deepEqual( + (yield* filesystem.listDirRecursive({ root, relativePath: "" })).map( + (entry) => entry.relativePath, + ), + ["notes", "notes/today.txt"], + ); + assert.deepEqual(yield* filesystem.listRoots(), [root]); + + yield* filesystem.remove({ root, relativePath: "notes/today.txt" }); + yield* filesystem.remove({ root, relativePath: "notes/today.txt" }); + assert.isFalse(yield* filesystem.exists({ root, relativePath: "notes/today.txt" })); + }), + ), + ); + + it.effect("rejects symlink leaves and symlinked parents for sensitive operations", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(outside, "secret.txt"), "no")); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(outside, "secret.txt"), NodePath.join(root, "leaf-link")), + ); + yield* Effect.promise(() => + NodeFSP.symlink(outside, NodePath.join(root, "parent-link"), "dir"), + ); + + yield* expectPathFailure(filesystem.readFileString({ root, relativePath: "leaf-link" })); + yield* expectPathFailure( + filesystem.writeFileString({ root, relativePath: "leaf-link", contents: "changed" }), + ); + yield* expectPathFailure( + filesystem.makeDirectory({ root, relativePath: "parent-link/created-outside" }), + ); + assert.isFalse( + yield* Effect.promise(() => + NodeFSP.stat(NodePath.join(outside, "created-outside")) + .then(() => true) + .catch(() => false), + ), + ); + + yield* filesystem.writeFileString({ root, relativePath: "safe.txt", contents: "safe" }); + yield* expectPathFailure( + filesystem.rename({ + root, + fromRelativePath: "safe.txt", + toRelativePath: "parent-link/safe.txt", + }), + ); + }), + ), + ); + + it.effect("removes recursively without following symlinks outside the root", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.makeDirectory({ root, relativePath: "nested" }); + yield* filesystem.writeFileString({ + root, + relativePath: "nested/inside.txt", + contents: "inside", + }); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(outside, "keep.txt"), "keep")); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(outside, "keep.txt"), NodePath.join(root, "nested", "out")), + ); + + yield* filesystem.remove({ root, relativePath: "nested" }); + + assert.isFalse(yield* filesystem.exists({ root, relativePath: "nested" })); + assert.equal( + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(outside, "keep.txt"), "utf8")), + "keep", + ); + }), + ), + ); + + it.effect("fails exclusive create, no-overwrite rename, and read/write caps", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.createFileExclusive({ root, relativePath: "one.txt", contents: "one" }); + assert.equal( + (yield* Effect.exit( + filesystem.createFileExclusive({ root, relativePath: "one.txt", contents: "two" }), + ))._tag, + "Failure", + ); + yield* filesystem.writeFileString({ root, relativePath: "two.txt", contents: "two" }); + assert.equal( + (yield* Effect.exit( + filesystem.rename({ root, fromRelativePath: "one.txt", toRelativePath: "two.txt" }), + ))._tag, + "Failure", + ); + + const tooLarge = "x".repeat(16 * 1024 * 1024 + 1); + assert.equal( + (yield* Effect.exit( + filesystem.writeFileString({ root, relativePath: "large.txt", contents: tooLarge }), + ))._tag, + "Failure", + ); + yield* filesystem.writeFileString({ root, relativePath: "small.txt", contents: "abcdef" }); + assert.equal( + yield* filesystem.readFileStringCapped({ + root, + relativePath: "small.txt", + maxBytes: 3, + }), + "abc", + ); + }), + ), + ); + + it.effect( + "rename overwrite replaces an existing destination and writeFileAtomic updates in place", + () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + // With overwrite:true, a rename atomically replaces an existing file + // instead of being rejected as "destination already exists". + yield* filesystem.writeFileString({ root, relativePath: "src.txt", contents: "src" }); + yield* filesystem.writeFileString({ root, relativePath: "dst.txt", contents: "old" }); + yield* filesystem.rename({ + root, + fromRelativePath: "src.txt", + toRelativePath: "dst.txt", + overwrite: true, + }); + assert.equal(yield* filesystem.readFileString({ root, relativePath: "dst.txt" }), "src"); + assert.isFalse(yield* filesystem.exists({ root, relativePath: "src.txt" })); + + // writeFileAtomic must succeed MORE THAN ONCE for the same path: the + // second call renames its temp file over the now-existing target. Without + // overwrite support the helper worked exactly once per path and every + // subsequent atomic update failed (the M3 regression). + yield* writeFileAtomic(filesystem, { root, relativePath: "notes.md", contents: "first" }); + assert.equal( + yield* filesystem.readFileString({ root, relativePath: "notes.md" }), + "first", + ); + yield* writeFileAtomic(filesystem, { + root, + relativePath: "notes.md", + contents: "second", + }); + assert.equal( + yield* filesystem.readFileString({ root, relativePath: "notes.md" }), + "second", + ); + }), + ), + ); + + it.effect("grants worktree roots after VCS creation and rejects them after removal", () => + Effect.scoped( + Effect.gen(function* () { + const projectRoot = yield* makeTempDir("plugin-fs-project-"); + const worktreeRoot = yield* makeTempDir("plugin-fs-worktree-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [projectRoot], grants }); + + yield* filesystem.writeFileString({ + root: projectRoot, + relativePath: "project.txt", + contents: "project", + }); + yield* expectPathFailure( + filesystem.writeFileString({ + root: worktreeRoot, + relativePath: "worktree.txt", + contents: "denied", + }), + ); + + yield* grants.grant(worktreeRoot); + yield* filesystem.writeFileString({ + root: worktreeRoot, + relativePath: "worktree.txt", + contents: "allowed", + }); + assert.deepEqual( + (yield* filesystem.listRoots()).toSorted(), + [projectRoot, worktreeRoot].toSorted(), + ); + + yield* grants.revoke(worktreeRoot); + yield* expectPathFailure( + filesystem.readFileString({ root: worktreeRoot, relativePath: "worktree.txt" }), + ); + }), + ), + ); + + it.effect("keeps resolved out-of-root paths out of plugin-facing messages AND error data", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + const outsideFile = NodePath.join(outside, "secret.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(outsideFile, "no")); + yield* Effect.promise(() => NodeFSP.symlink(outsideFile, NodePath.join(root, "link"))); + + const error = yield* filesystem + .readFileString({ root, relativePath: "link" }) + .pipe(Effect.flip); + const realOutsideFile = yield* Effect.promise(() => NodeFSP.realpath(outsideFile)); + + assert.equal((error as any).reason, "path resolves outside root"); + assert.notInclude(error.message, outsideFile); + // The structured error payload must not leak the symlink-escape + // target (host filesystem topology) either: `data` is omitted for + // outside-root denials. + assert.isUndefined((error as any).data); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.notInclude(JSON.stringify(error), realOutsideFile); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.notInclude(JSON.stringify(error), outside); + }), + ), + ); + + it.effect("treats dangling and out-of-root symlinks as non-existent in exists", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + // Dangling symlink: the target never existed. `exists` must report + // plain non-existence, not throw a path error. + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(root, "missing-target"), NodePath.join(root, "dangling")), + ); + assert.isFalse(yield* filesystem.exists({ root, relativePath: "dangling" })); + + // Out-of-root symlink: the live target must read as absent — exists + // must not confirm host paths outside the granted root. + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(outside, "real.txt"), "x")); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(outside, "real.txt"), NodePath.join(root, "escape")), + ); + assert.isFalse(yield* filesystem.exists({ root, relativePath: "escape" })); + + // A live in-root symlink still reads as existing. + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(root, "target.txt"), "x")); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(root, "target.txt"), NodePath.join(root, "alias")), + ); + assert.isTrue(yield* filesystem.exists({ root, relativePath: "alias" })); + }), + ), + ); + + it.effect("coerces a NaN readFileStringCapped maxBytes to zero", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.writeFileString({ root, relativePath: "data.txt", contents: "abcdef" }); + assert.equal( + yield* filesystem.readFileStringCapped({ + root, + relativePath: "data.txt", + maxBytes: Number.NaN, + }), + "", + ); + }), + ), + ); +}); diff --git a/apps/server/src/plugins/capabilities/FilesystemCapability.ts b/apps/server/src/plugins/capabilities/FilesystemCapability.ts new file mode 100644 index 00000000000..c7ba595a211 --- /dev/null +++ b/apps/server/src/plugins/capabilities/FilesystemCapability.ts @@ -0,0 +1,946 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { DirEntry, FileStat, FilesystemCapability } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { PluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; + +const FILE_MAX_BYTES = 16 * 1024 * 1024; +const LIST_RECURSIVE_MAX_ENTRIES = 500; +const READ_CHUNK_BYTES = 64 * 1024; + +type FilesystemOperation = + | "list-roots" + | "read-file" + | "read-file-string" + | "read-file-string-capped" + | "write-file" + | "create-file-exclusive" + | "exists" + | "stat" + | "list-dir" + | "list-dir-recursive" + | "make-directory" + | "remove" + | "rename"; + +interface FilesystemPathContext { + readonly root: string; + readonly relativePath: string; +} + +export class FilesystemPathError extends Schema.TaggedErrorClass()( + "FilesystemPathError", + { + root: Schema.String, + relativePath: Schema.String, + operation: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `Filesystem path '${this.relativePath}' in root '${this.root}' is not allowed: ${this.reason}`; + } +} + +export class FilesystemIoError extends Schema.TaggedErrorClass()( + "FilesystemIoError", + { + root: Schema.String, + relativePath: Schema.String, + operation: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `Filesystem operation '${this.operation}' failed for '${this.relativePath}' in root '${this.root}': ${this.reason}`; + } +} + +type FilesystemError = FilesystemPathError | FilesystemIoError; + +class NodePathNotFound extends Error { + readonly _tag = "NodePathNotFound"; +} + +const isFilesystemIoError = Schema.is(FilesystemIoError); + +const isNodeNotFound = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause as { readonly code?: unknown }).code === "ENOENT"; + +const isNodeSymlinkLoop = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause as { readonly code?: unknown }).code === "ELOOP"; + +const pathError = ( + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, + data?: unknown, +) => + new FilesystemPathError({ + ...context, + operation, + reason, + ...(data === undefined ? {} : { data }), + }); + +const ioError = ( + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, + data?: unknown, +) => + new FilesystemIoError({ + ...context, + operation, + reason, + ...(data === undefined ? {} : { data }), + }); + +const containsRealPath = (realRoot: string, realTarget: string): boolean => { + const relative = NodePath.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !NodePath.isAbsolute(relative)); +}; + +const isAbsoluteRelativePath = (relativePath: string): boolean => + NodePath.isAbsolute(relativePath) || + relativePath.startsWith("\\") || + /^[a-zA-Z]:[\\/]/u.test(relativePath); + +function parseRelativePath( + context: FilesystemPathContext, + operation: FilesystemOperation, +): Effect.Effect, FilesystemPathError> { + if (context.relativePath.includes("\0")) { + return Effect.fail(pathError(context, operation, "path contains a NUL byte")); + } + if (isAbsoluteRelativePath(context.relativePath)) { + return Effect.fail(pathError(context, operation, "relativePath must not be absolute")); + } + const normalized = context.relativePath.replace(/\\/gu, "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + if (segments.includes("..")) { + return Effect.fail(pathError(context, operation, "relativePath must not contain '..'")); + } + return Effect.succeed(segments); +} + +const outputRelativePath = (base: string, name: string) => + [...base.replace(/\\/gu, "/").split("/").filter(Boolean), name].join("/"); + +const statType = (stat: NodeFS.Stats): FileStat["type"] => { + if (stat.isFile()) return "file"; + if (stat.isDirectory()) return "directory"; + return "other"; +}; + +const lstatOrNull = ( + absPath: string, + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, +) => + Effect.tryPromise({ + try: () => NodeFSP.lstat(absPath), + catch: (cause) => + isNodeNotFound(cause) + ? new NodePathNotFound() + : ioError(context, operation, reason, { + resolvedPath: absPath, + cause, + }), + }).pipe( + Effect.catch((error) => + error instanceof NodePathNotFound ? Effect.succeed(null) : Effect.fail(error), + ), + ); + +const readProjectRoots = (snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]) => + snapshots + .getShellSnapshot() + .pipe(Effect.map((snapshot) => snapshot.projects.map((p) => p.workspaceRoot))); + +function snapshotGrantedRoots(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; +}): Effect.Effect, Error> { + return Effect.gen(function* () { + const projectRoots = yield* readProjectRoots(input.snapshots); + const worktreeRoots = [...(yield* input.grants.snapshot())]; + return [...new Set([...projectRoots, ...worktreeRoots])]; + }); +} + +function requireRoot(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; +}): Effect.Effect< + { readonly roots: ReadonlyArray; readonly realRoot: string }, + FilesystemError +> { + return Effect.gen(function* () { + const roots = yield* snapshotGrantedRoots(input).pipe( + Effect.mapError((cause) => + ioError(input.context, input.operation, "failed to read granted roots", { cause }), + ), + ); + if (!roots.includes(input.context.root)) { + return yield* pathError(input.context, input.operation, "root is not granted", { + roots, + }); + } + const realRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.context.root), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve root", { cause }), + }); + return { roots, realRoot }; + }); +} + +function realpathExistingTarget(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly segments: ReadonlyArray; +}): Effect.Effect { + return Effect.gen(function* () { + const logicalTarget = NodePath.join(input.context.root, ...input.segments); + const realTarget = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(logicalTarget), + catch: (cause) => + isNodeNotFound(cause) + ? pathError(input.context, input.operation, "path does not exist", { + resolvedPath: logicalTarget, + cause, + }) + : ioError(input.context, input.operation, "failed to resolve path", { + resolvedPath: logicalTarget, + cause, + }), + }); + if (!containsRealPath(input.realRoot, realTarget)) { + // Deliberately no `data`: the resolved target is an out-of-root host + // path (e.g. a symlink-escape destination) and must not leak host + // filesystem topology through plugin-visible error payloads. + return yield* pathError(input.context, input.operation, "path resolves outside root"); + } + return realTarget; + }); +} + +/** + * Resolve (optionally creating) each parent segment to its real path, + * verifying containment at every step. + * + * KNOWN LIMITATION (full-trust TOCTOU): each segment is realpath-resolved and + * containment-checked, but the mutating open happens afterwards against + * `join(realParent, leaf)` with `O_NOFOLLOW` on the LEAF only. An attacker + * with local write access who swaps an already-resolved real parent directory + * for a symlink between resolution and the open wins that race. Node exposes + * no `openat`-style per-segment `O_NOFOLLOW` walk, so this is accepted under + * the plugin full-trust model: the boundary defends against malicious PATH + * STRINGS handled by well-behaved in-process plugins, not against a + * concurrent local attacker mutating the workspace. The realpath+containment + * walk here remains the backstop that stops every non-racing escape. + */ +function resolveParent(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly parentSegments: ReadonlyArray; + readonly create: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + let current = input.realRoot; + for (const segment of input.parentSegments) { + if (segment === ".") continue; + const candidate = NodePath.join(current, segment); + let lstat = yield* lstatOrNull( + candidate, + input.context, + input.operation, + "failed to inspect parent path", + ); + if (lstat === null) { + if (!input.create) { + return yield* ioError(input.context, input.operation, "parent path does not exist", { + resolvedPath: candidate, + }); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.mkdir(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to create directory", { + resolvedPath: candidate, + cause, + }), + }); + lstat = yield* Effect.tryPromise({ + try: () => NodeFSP.lstat(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to inspect created directory", { + resolvedPath: candidate, + cause, + }), + }); + } + const realCandidate = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve parent path", { + resolvedPath: candidate, + cause, + }), + }); + if (!containsRealPath(input.realRoot, realCandidate)) { + // No `data`: the resolved candidate is an out-of-root host path and + // must not leak through plugin-visible error payloads. + return yield* pathError(input.context, input.operation, "parent resolves outside root"); + } + const stat = lstat.isSymbolicLink() + ? yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realCandidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to inspect resolved parent", { + resolvedPath: realCandidate, + cause, + }), + }) + : lstat; + if (!stat.isDirectory()) { + return yield* ioError(input.context, input.operation, "parent path is not a directory", { + resolvedPath: realCandidate, + }); + } + current = realCandidate; + } + return current; + }); +} + +function resolveLeafParent(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly segments: ReadonlyArray; + readonly createParent: boolean; +}): Effect.Effect<{ readonly realParent: string; readonly leaf: string }, FilesystemError> { + return Effect.gen(function* () { + const leaf = input.segments.at(-1); + if (!leaf || leaf === ".") { + return yield* pathError(input.context, input.operation, "path must include a final entry"); + } + const realParent = yield* resolveParent({ + context: input.context, + operation: input.operation, + realRoot: input.realRoot, + parentSegments: input.segments.slice(0, -1), + create: input.createParent, + }); + return { realParent, leaf }; + }); +} + +function ensureNoSymlinkLeaf(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; +}): Effect.Effect { + return Effect.gen(function* () { + const lstat = yield* lstatOrNull( + input.target, + input.context, + input.operation, + "failed to inspect target", + ); + if (lstat?.isSymbolicLink()) { + return yield* pathError(input.context, input.operation, "symlink leaf is not allowed", { + resolvedPath: input.target, + }); + } + }); +} + +function openNoFollow(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; + readonly flags: number; +}): Effect.Effect { + return Effect.tryPromise({ + try: () => NodeFSP.open(input.target, input.flags | NodeFS.constants.O_NOFOLLOW, 0o666), + catch: (cause) => + isNodeSymlinkLoop(cause) + ? pathError(input.context, input.operation, "symlink leaf is not allowed", { + resolvedPath: input.target, + cause, + }) + : ioError(input.context, input.operation, "failed to open file", { + resolvedPath: input.target, + cause, + }), + }); +} + +function closeHandle(handle: NodeFSP.FileHandle) { + return Effect.promise(() => handle.close()).pipe(Effect.ignore); +} + +function readBytesFromHandle(input: { + readonly handle: NodeFSP.FileHandle; + readonly maxBytes: number; + readonly failOnOverflow: boolean; + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; +}): Effect.Effect { + return Effect.gen(function* () { + const chunks: Buffer[] = []; + let total = 0; + const readLimit = input.failOnOverflow ? input.maxBytes + 1 : input.maxBytes; + while (total < readLimit) { + const buffer = Buffer.alloc(Math.min(READ_CHUNK_BYTES, readLimit - total)); + const result = yield* Effect.tryPromise({ + try: () => input.handle.read(buffer, 0, buffer.byteLength, null), + catch: (cause) => ioError(input.context, input.operation, "failed to read file", { cause }), + }); + if (result.bytesRead === 0) break; + chunks.push(buffer.subarray(0, result.bytesRead)); + total += result.bytesRead; + } + if (input.failOnOverflow && total > input.maxBytes) { + return yield* ioError(input.context, input.operation, "file exceeds the size limit", { + limit: input.maxBytes, + actual: total, + }); + } + return new Uint8Array(Buffer.concat(chunks, total)); + }); +} + +function readExistingFile(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realPath: string; + readonly maxBytes: number; + readonly failOnOverflow: boolean; +}): Effect.Effect { + return Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => NodeFSP.open(input.realPath, NodeFS.constants.O_RDONLY), + catch: (cause) => + ioError(input.context, input.operation, "failed to open file", { + resolvedPath: input.realPath, + cause, + }), + }), + (handle) => + Effect.gen(function* () { + const stat = yield* Effect.tryPromise({ + try: () => handle.stat(), + catch: (cause) => + ioError(input.context, input.operation, "failed to stat file", { + resolvedPath: input.realPath, + cause, + }), + }); + if (!stat.isFile()) { + return yield* ioError(input.context, input.operation, "path is not a file", { + resolvedPath: input.realPath, + }); + } + return yield* readBytesFromHandle({ ...input, handle }); + }), + closeHandle, + ); +} + +function writeBytesToTarget(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; + readonly contents: Uint8Array; + readonly exclusive: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.contents.byteLength > FILE_MAX_BYTES) { + return yield* ioError(input.context, input.operation, "file exceeds the size limit", { + limit: FILE_MAX_BYTES, + actual: input.contents.byteLength, + }); + } + yield* ensureNoSymlinkLeaf(input); + const flags = + NodeFS.constants.O_WRONLY | + NodeFS.constants.O_CREAT | + (input.exclusive ? NodeFS.constants.O_EXCL : NodeFS.constants.O_TRUNC); + yield* Effect.acquireUseRelease( + openNoFollow({ ...input, flags }), + (handle) => + Effect.tryPromise({ + try: () => handle.writeFile(input.contents), + catch: (cause) => + ioError(input.context, input.operation, "failed to write file", { + resolvedPath: input.target, + cause, + }), + }), + closeHandle, + ); + }); +} + +function dirEntryFor(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly absEntry: string; + readonly relativePath: string; + readonly name: string; +}): Effect.Effect { + return Effect.gen(function* () { + const realEntry = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.absEntry), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve directory entry", { + resolvedPath: input.absEntry, + cause, + }), + }).pipe(Effect.orElseSucceed(() => null)); + if (realEntry === null || !containsRealPath(input.realRoot, realEntry)) { + return null; + } + const stat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realEntry), + catch: (cause) => + ioError(input.context, input.operation, "failed to stat directory entry", { + resolvedPath: realEntry, + cause, + }), + }).pipe(Effect.orElseSucceed(() => null)); + if (stat === null) return null; + return { + name: input.name, + relativePath: input.relativePath, + type: statType(stat), + }; + }); +} + +function makeCapability(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; +}): FilesystemCapability { + const prepare = (context: FilesystemPathContext, operation: FilesystemOperation) => + Effect.gen(function* () { + const segments = yield* parseRelativePath(context, operation); + const root = yield* requireRoot({ ...input, context, operation }); + return { ...root, segments }; + }); + + const writeFileBytes = ( + context: FilesystemPathContext, + contents: Uint8Array, + exclusive: boolean, + operation: "write-file" | "create-file-exclusive", + ) => + Effect.gen(function* () { + const { realRoot, segments } = yield* prepare(context, operation); + const { realParent, leaf } = yield* resolveLeafParent({ + context, + operation, + realRoot, + segments, + createParent: true, + }); + yield* writeBytesToTarget({ + context, + operation, + target: NodePath.join(realParent, leaf), + contents, + exclusive, + }); + }); + + const removePath = ( + context: FilesystemPathContext, + realRoot: string, + absPath: string, + operation: FilesystemOperation, + ): Effect.Effect => + Effect.gen(function* () { + const lstat = yield* lstatOrNull(absPath, context, operation, "failed to inspect path"); + if (lstat === null) return; + if (lstat.isSymbolicLink()) { + yield* Effect.tryPromise({ + try: () => NodeFSP.unlink(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove symlink", { + resolvedPath: absPath, + cause, + }), + }); + return; + } + const realPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(absPath), + catch: (cause) => + ioError(context, operation, "failed to resolve path", { resolvedPath: absPath, cause }), + }); + if (!containsRealPath(realRoot, realPath)) { + // No `data`: the resolved path is an out-of-root host path and must + // not leak through plugin-visible error payloads. + return yield* pathError(context, operation, "path resolves outside root"); + } + if (lstat.isDirectory()) { + const entries = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(absPath), + catch: (cause) => + ioError(context, operation, "failed to read directory", { + resolvedPath: absPath, + cause, + }), + }); + for (const entry of entries) { + yield* removePath(context, realRoot, NodePath.join(absPath, entry), operation); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.rmdir(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove directory", { + resolvedPath: absPath, + cause, + }), + }); + return; + } + yield* Effect.tryPromise({ + try: () => NodeFSP.unlink(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove file", { resolvedPath: absPath, cause }), + }); + }); + + const readFile: FilesystemCapability["readFile"] = (context) => + Effect.gen(function* () { + const operation = "read-file" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + return yield* readExistingFile({ + context, + operation, + realPath, + maxBytes: FILE_MAX_BYTES, + failOnOverflow: true, + }); + }); + + return { + listRoots: () => + snapshotGrantedRoots(input).pipe( + Effect.map((roots) => [...roots].sort((left, right) => left.localeCompare(right))), + Effect.mapError((cause) => + ioError({ root: "", relativePath: "" }, "list-roots", "failed to read roots", { + cause, + }), + ), + ), + readFile, + readFileString: (context) => + readFile(context).pipe(Effect.map((bytes) => new TextDecoder().decode(bytes))), + readFileStringCapped: (context) => + Effect.gen(function* () { + const operation = "read-file-string-capped" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + // NaN would otherwise poison the read-loop bound (`total < NaN` is + // always false); coerce it to 0 explicitly. +/-Infinity are already + // handled by the clamp below. + const requestedMaxBytes = Number.isNaN(context.maxBytes) ? 0 : context.maxBytes; + const maxBytes = Math.max(0, Math.min(Math.floor(requestedMaxBytes), FILE_MAX_BYTES)); + const bytes = yield* readExistingFile({ + context, + operation, + realPath, + maxBytes, + failOnOverflow: false, + }); + return new TextDecoder().decode(bytes); + }), + writeFile: (request) => writeFileBytes(request, request.contents, false, "write-file"), + writeFileString: (request) => + writeFileBytes(request, new TextEncoder().encode(request.contents), false, "write-file"), + createFileExclusive: (request) => + writeFileBytes( + request, + typeof request.contents === "string" + ? new TextEncoder().encode(request.contents) + : request.contents, + true, + "create-file-exclusive", + ), + exists: (context) => + Effect.gen(function* () { + const operation = "exists" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const logicalTarget = NodePath.join(context.root, ...segments); + const lstat = yield* lstatOrNull( + logicalTarget, + context, + operation, + "failed to inspect path", + ); + if (lstat === null) return false; + // A dangling symlink (or a target removed mid-check) realpaths to + // ENOENT: report plain non-existence instead of failing. An + // out-of-root symlink target likewise reads as absent — `exists` + // must not confirm that a host path outside the root exists. + const realPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(logicalTarget), + catch: (cause) => + isNodeNotFound(cause) + ? new NodePathNotFound() + : ioError(context, operation, "failed to resolve path", { + resolvedPath: logicalTarget, + cause, + }), + }).pipe( + Effect.catch((error) => + error instanceof NodePathNotFound ? Effect.succeed(null) : Effect.fail(error), + ), + ); + if (realPath === null) return false; + return containsRealPath(realRoot, realPath); + }), + stat: (context) => + Effect.gen(function* () { + const operation = "stat" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const stat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realPath), + catch: (cause) => + ioError(context, operation, "failed to stat path", { resolvedPath: realPath, cause }), + }); + return { + type: statType(stat), + size: stat.size, + mtime: stat.mtimeMs, + realPath, + }; + }), + listDir: (context) => + Effect.gen(function* () { + const operation = "list-dir" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const stat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realPath), + catch: (cause) => + ioError(context, operation, "failed to stat directory", { + resolvedPath: realPath, + cause, + }), + }); + if (!stat.isDirectory()) { + return yield* ioError(context, operation, "path is not a directory", { + resolvedPath: realPath, + }); + } + const entries = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(realPath), + catch: (cause) => + ioError(context, operation, "failed to read directory", { + resolvedPath: realPath, + cause, + }), + }); + const results: DirEntry[] = []; + for (const name of entries.sort((left, right) => left.localeCompare(right))) { + const entry = yield* dirEntryFor({ + context, + operation, + realRoot, + absEntry: NodePath.join(realPath, name), + relativePath: outputRelativePath(context.relativePath, name), + name, + }); + if (entry) results.push(entry); + } + return results; + }), + listDirRecursive: (context) => + Effect.gen(function* () { + const operation = "list-dir-recursive" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const results: DirEntry[] = []; + const walk = (absDir: string, relativeDir: string): Effect.Effect => + Effect.gen(function* () { + if (results.length >= LIST_RECURSIVE_MAX_ENTRIES) return; + const entries = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(absDir), + catch: (cause) => + ioError(context, operation, "failed to read directory", { + resolvedPath: absDir, + cause, + }), + }); + for (const name of entries.sort((left, right) => left.localeCompare(right))) { + if (results.length >= LIST_RECURSIVE_MAX_ENTRIES) return; + const relPath = outputRelativePath(relativeDir, name); + const entry = yield* dirEntryFor({ + context, + operation, + realRoot, + absEntry: NodePath.join(absDir, name), + relativePath: relPath, + name, + }); + if (!entry) continue; + results.push(entry); + if (entry.type === "directory") { + const childRealPath = yield* realpathExistingTarget({ + context, + operation, + realRoot, + segments: relPath.split("/").filter(Boolean), + }); + yield* walk(childRealPath, relPath); + } + } + }); + yield* walk(realPath, context.relativePath); + return results; + }), + makeDirectory: (context) => + Effect.gen(function* () { + const operation = "make-directory" as const; + const { realRoot, segments } = yield* prepare(context, operation); + yield* resolveParent({ + context, + operation, + realRoot, + parentSegments: segments, + create: true, + }); + }), + remove: (context) => + Effect.gen(function* () { + const operation = "remove" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const { realParent, leaf } = yield* resolveLeafParent({ + context, + operation, + realRoot, + segments, + createParent: false, + }).pipe( + Effect.catch((error) => + isFilesystemIoError(error) && error.reason === "parent path does not exist" + ? Effect.succeed({ realParent: "", leaf: "" }) + : Effect.fail(error), + ), + ); + if (!leaf) return; + yield* removePath(context, realRoot, NodePath.join(realParent, leaf), operation); + }), + rename: (request) => + Effect.gen(function* () { + const operation = "rename" as const; + const fromContext = { root: request.root, relativePath: request.fromRelativePath }; + const toContext = { root: request.root, relativePath: request.toRelativePath }; + const { realRoot, segments: fromSegments } = yield* prepare(fromContext, operation); + const toSegments = yield* parseRelativePath(toContext, operation); + const from = yield* resolveLeafParent({ + context: fromContext, + operation, + realRoot, + segments: fromSegments, + createParent: false, + }); + const to = yield* resolveLeafParent({ + context: toContext, + operation, + realRoot, + segments: toSegments, + createParent: false, + }); + const fromAbs = NodePath.join(from.realParent, from.leaf); + const toAbs = NodePath.join(to.realParent, to.leaf); + const fromLstat = yield* Effect.tryPromise({ + try: () => NodeFSP.lstat(fromAbs), + catch: (cause) => + ioError(fromContext, operation, "failed to inspect source", { + resolvedPath: fromAbs, + cause, + }), + }); + if (!fromLstat.isSymbolicLink()) { + const fromReal = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(fromAbs), + catch: (cause) => + ioError(fromContext, operation, "failed to resolve source", { + resolvedPath: fromAbs, + cause, + }), + }); + if (!containsRealPath(realRoot, fromReal)) { + // No `data`: the resolved source is an out-of-root host path and + // must not leak through plugin-visible error payloads. + return yield* pathError(fromContext, operation, "source resolves outside root"); + } + } + // Reject an existing destination unless the caller explicitly opts into + // atomic replace (e.g. writeFileAtomic renaming a temp file over an + // already-present target). NodeFSP.rename performs the replace atomically + // on POSIX and Windows; the destination stays inside the granted root + // (its parent was resolved above), and rename swaps the directory entry + // rather than following a symlink's target, so this cannot escape the root. + // + // KNOWN LIMITATION (full-trust TOCTOU): this no-overwrite guard is + // check-then-act — a destination created between the lstat below and + // the rename gets silently replaced. Node exposes no + // renameat2(RENAME_NOREPLACE)/renamex_np(RENAME_EXCL), so a truly + // atomic no-replace rename is not available; accepted under the + // full-trust model (the race requires a concurrent local writer, and + // the replace still cannot escape the granted root). + if (!request.overwrite) { + const toExists = + (yield* lstatOrNull(toAbs, toContext, operation, "failed to inspect destination")) !== + null; + if (toExists) { + return yield* ioError(toContext, operation, "destination already exists", { + resolvedPath: toAbs, + }); + } + } + yield* Effect.tryPromise({ + try: () => NodeFSP.rename(fromAbs, toAbs), + catch: (cause) => + ioError(fromContext, operation, "failed to rename path", { + fromResolvedPath: fromAbs, + toResolvedPath: toAbs, + cause, + }), + }); + }), + }; +} + +export const makeFilesystemCapability = makeCapability; diff --git a/apps/server/src/plugins/capabilities/HttpClientCapability.test.ts b/apps/server/src/plugins/capabilities/HttpClientCapability.test.ts new file mode 100644 index 00000000000..a70f86d77ed --- /dev/null +++ b/apps/server/src/plugins/capabilities/HttpClientCapability.test.ts @@ -0,0 +1,351 @@ +// @effect-diagnostics nodeBuiltinImport:off -- tests stand up raw local Node +// servers to exercise the real pinned transport end-to-end. +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; + +import { + HttpClientError, + HttpEgressBlockedError, + makeHttpClientCapability, + type PluginHttpClientTransport, +} from "./HttpClientCapability.ts"; + +const encoder = new TextEncoder(); + +function responseFor(input: { + readonly url: URL; + readonly method: string; + readonly status?: number; + readonly headers?: Record; + readonly body?: string | Uint8Array | ArrayBuffer | null; +}) { + const request = HttpClientRequest.make(input.method as "GET")(input.url.toString()); + return HttpClientResponse.fromWeb( + request, + new Response(input.body ?? "", { + status: input.status ?? 200, + headers: input.headers ?? {}, + }), + ); +} + +function makeClient(input: { + readonly lookup?: (host: string) => Effect.Effect, Error>; + readonly transport?: PluginHttpClientTransport; + readonly calls?: Array<{ readonly host: string; readonly address: string }>; +}) { + return makeHttpClientCapability({ + lookup: input.lookup ?? (() => Effect.succeed(["140.82.112.3"])), + transport: + input.transport ?? + ((request) => + Effect.sync(() => { + input.calls?.push({ + host: request.url.hostname, + address: request.address.address, + }); + return responseFor({ + url: request.url, + method: request.method, + headers: { "x-transport": "stub" }, + body: "ok", + }); + })), + }); +} + +describe("HttpClientCapability", () => { + it.effect("rejects non-https and private egress before transport", () => + Effect.gen(function* () { + const calls: unknown[] = []; + const client = makeClient({ + lookup: () => Effect.succeed(["10.0.0.1"]), + transport: () => + Effect.sync(() => { + calls.push("transport"); + return responseFor({ url: new URL("https://never.test"), method: "GET" }); + }), + }); + + const httpError = yield* client + .request({ method: "GET", url: "http://example.test" }) + .pipe(Effect.flip); + const privateError = yield* client + .request({ method: "GET", url: "https://internal.test" }) + .pipe(Effect.flip); + + assert.instanceOf(httpError, HttpEgressBlockedError); + assert.instanceOf(privateError, HttpEgressBlockedError); + assert.deepEqual(calls, []); + }), + ); + + it.effect("pins the transport to the validated resolved address", () => + Effect.gen(function* () { + const calls: Array<{ readonly host: string; readonly address: string }> = []; + const client = makeClient({ + calls, + lookup: () => Effect.succeed(["140.82.112.3", "140.82.113.4"]), + }); + + const result = yield* client.request({ method: "GET", url: "https://github.com/api" }); + + assert.equal(result.status, 200); + assert.equal(new TextDecoder().decode(result.body), "ok"); + assert.deepEqual(calls, [{ host: "github.com", address: "140.82.112.3" }]); + }), + ); + + it.effect("rejects headers with control characters (CRLF injection) before transport", () => + Effect.gen(function* () { + const calls: Array<{ readonly host: string; readonly address: string }> = []; + const client = makeClient({ calls }); + + const result = yield* Effect.exit( + client.request({ + method: "GET", + url: "https://github.com/api", + headers: { "x-evil": "value\r\nx-injected: 1" }, + }), + ); + + assert.isTrue(result._tag === "Failure"); + assert.deepEqual(calls, []); + }), + ); + + it.effect("rejects an oversized request body before transport", () => + Effect.gen(function* () { + const calls: Array<{ readonly host: string; readonly address: string }> = []; + const client = makeClient({ calls }); + + const result = yield* Effect.exit( + client.request({ + method: "POST", + url: "https://github.com/api", + body: new Uint8Array(33 * 1024 * 1024), + }), + ); + + assert.isTrue(result._tag === "Failure"); + assert.deepEqual(calls, []); + }), + ); + + it.effect("surfaces redirects without following them", () => + Effect.gen(function* () { + const client = makeClient({ + transport: (request) => + Effect.succeed( + responseFor({ + url: request.url, + method: request.method, + status: 302, + headers: { location: "https://example.test/next" }, + }), + ), + }); + + const result = yield* client.request({ method: "GET", url: "https://example.test/start" }); + + assert.equal(result.status, 302); + assert.equal(result.headers.location, "https://example.test/next"); + }), + ); + + it.effect("enforces response caps and maps timeout/transport failures", () => + Effect.gen(function* () { + const tooLargeClient = makeClient({ + transport: (request) => + Effect.succeed( + responseFor({ + url: request.url, + method: request.method, + body: encoder.encode("abcdef").buffer, + }), + ), + }); + const timeoutClient = makeClient({ + transport: () => + Effect.fail(new HttpClientError({ host: "example.test", reason: "timeout" })), + }); + + const tooLarge = yield* tooLargeClient + .request({ + method: "GET", + url: "https://example.test/large", + maxResponseBytes: 3, + }) + .pipe(Effect.flip); + const timeout = yield* timeoutClient + .request({ method: "GET", url: "https://example.test/timeout", timeoutMs: 1 }) + .pipe(Effect.flip); + + assert.instanceOf(tooLarge, HttpClientError); + assert.include(tooLarge.message, "example.test"); + assert.instanceOf(timeout, HttpClientError); + }), + ); + + it.effect( + "transport failures surface a stable reason that does not echo the underlying cause text", + () => + Effect.gen(function* () { + // Bind then immediately release a loopback port so a connect attempt is + // deterministically refused, driving the real transport's catch branch. + const port = yield* Effect.promise( + () => + new Promise((resolve) => { + const server = NodeNet.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const assigned = typeof address === "object" && address !== null ? address.port : 0; + server.close(() => resolve(assigned)); + }); + }), + ); + const previous = process.env.T3_PLUGIN_DEV; + process.env.T3_PLUGIN_DEV = "1"; + try { + // Call makeHttpClientCapability directly with NO transport so the real + // nodePinnedTransport runs and its catch maps the connection failure into + // an HttpClientError (makeClient injects a stub transport instead). + const client = makeHttpClientCapability({ lookup: () => Effect.succeed(["127.0.0.1"]) }); + const error = yield* client + .request({ method: "GET", url: `http://127.0.0.1:${port}/`, timeoutMs: 2000 }) + .pipe(Effect.flip); + + assert.instanceOf(error, HttpClientError); + // The wrapper reason/message derive only from stable structural attributes; + // they must NOT echo the underlying transport error text (e.g. ECONNREFUSED). + assert.equal(error.reason, "transport request failed"); + assert.notInclude(error.message, "ECONNREFUSED"); + assert.notInclude(error.message, "connect"); + } finally { + if (previous === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous; + } + } + }), + ); + + it.live("enforces a wall-clock deadline against a slow-drip response", () => + Effect.gen(function* () { + // The server sends headers then drips one byte per 50ms. Every byte + // resets Node's socket-inactivity `timeout`, so without the end-to-end + // deadline this request would stream forever below the byte cap. + const server = NodeHttp.createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/octet-stream" }); + // Raw Node timer on the test-server side, outside any Effect runtime. + // @effect-diagnostics-next-line globalTimers:off + const timer = setInterval(() => { + response.write("x"); + }, 50); + response.on("close", () => clearInterval(timer)); + }); + const port = yield* Effect.callback((resume) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resume( + Effect.succeed(typeof address === "object" && address !== null ? address.port : 0), + ); + }); + }); + const previous = process.env.T3_PLUGIN_DEV; + process.env.T3_PLUGIN_DEV = "1"; + try { + // Real nodePinnedTransport (no transport override) so the deadline is + // proven against the actual socket, not a stub. + const client = makeHttpClientCapability({ lookup: () => Effect.succeed(["127.0.0.1"]) }); + const error = yield* client + .request({ method: "GET", url: `http://127.0.0.1:${port}/drip`, timeoutMs: 300 }) + .pipe(Effect.flip); + + assert.instanceOf(error, HttpClientError); + assert.equal(error.reason, "request exceeded the time limit"); + } finally { + if (previous === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous; + } + server.closeAllConnections(); + server.close(); + } + }), + ); + + it.live("pins the real Node transport to the validated address, not system DNS", () => + Effect.gen(function* () { + // `.invalid` never resolves through system DNS (RFC 2606): the request + // can only reach the local server if the validator-resolved address is + // pinned through the transport's `lookup` override. + const requests: Array = []; + const server = NodeHttp.createServer((request, response) => { + requests.push(request.headers.host); + response.writeHead(200, { "content-type": "text/plain" }); + response.end("pinned"); + }); + const port = yield* Effect.callback((resume) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resume( + Effect.succeed(typeof address === "object" && address !== null ? address.port : 0), + ); + }); + }); + const previous = process.env.T3_PLUGIN_DEV; + process.env.T3_PLUGIN_DEV = "1"; + try { + const client = makeHttpClientCapability({ lookup: () => Effect.succeed(["127.0.0.1"]) }); + const result = yield* client.request({ + method: "GET", + url: `http://t3code-pin-test.invalid:${port}/`, + timeoutMs: 2000, + }); + + assert.equal(result.status, 200); + assert.equal(new TextDecoder().decode(result.body), "pinned"); + // The connection landed on the pinned loopback address while the + // request kept the original hostname (Host header intact). + assert.deepEqual(requests, [`t3code-pin-test.invalid:${port}`]); + } finally { + if (previous === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous; + } + server.closeAllConnections(); + server.close(); + } + }), + ); + + it.effect("allows http loopback only under T3_PLUGIN_DEV", () => + Effect.gen(function* () { + const previous = process.env.T3_PLUGIN_DEV; + const client = makeClient({ lookup: () => Effect.succeed(["127.0.0.1"]) }); + try { + delete process.env.T3_PLUGIN_DEV; + assert.instanceOf( + yield* client.request({ method: "GET", url: "http://localhost:5173" }).pipe(Effect.flip), + HttpEgressBlockedError, + ); + process.env.T3_PLUGIN_DEV = "1"; + const result = yield* client.request({ method: "GET", url: "http://localhost:5173" }); + assert.equal(result.status, 200); + } finally { + if (previous === undefined) { + delete process.env.T3_PLUGIN_DEV; + } else { + process.env.T3_PLUGIN_DEV = previous; + } + } + }), + ); +}); diff --git a/apps/server/src/plugins/capabilities/HttpClientCapability.ts b/apps/server/src/plugins/capabilities/HttpClientCapability.ts new file mode 100644 index 00000000000..a355cc49873 --- /dev/null +++ b/apps/server/src/plugins/capabilities/HttpClientCapability.ts @@ -0,0 +1,349 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeHttp from "node:http"; +import * as NodeHttps from "node:https"; +import * as NodeStream from "node:stream"; + +import type { HttpClientCapability, HttpClientRequestInput } from "@t3tools/plugin-sdk"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + defaultLookup, + OutboundUrlError, + OutboundUrlValidator, + type ResolvedAddress, + type UrlValidatorDeps, +} from "../OutboundUrlValidator.ts"; +import { readHttpResponseBytesCapped } from "../readHttpResponseBytesCapped.ts"; + +const DEFAULT_RESPONSE_MAX_BYTES = 8 * 1024 * 1024; +const HARD_RESPONSE_MAX_BYTES = 32 * 1024 * 1024; +const REQUEST_BODY_MAX_BYTES = 32 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; +const HARD_TIMEOUT_MS = 120_000; +// Reject header names/values carrying CR/LF or other control chars so a plugin +// forwarding attacker-influenced data cannot inject/smuggle a second header. +const hasControlChars = (value: string): boolean => { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +}; + +export class HttpEgressBlockedError extends Schema.TaggedErrorClass()( + "HttpEgressBlockedError", + { + host: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `HTTP egress to '${this.host}' is blocked: ${this.reason}`; + } +} + +export class HttpClientError extends Schema.TaggedErrorClass()("HttpClientError", { + host: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), +}) { + override get message(): string { + return `HTTP request to '${this.host}' failed: ${this.reason}`; + } +} + +const isHttpClientError = Schema.is(HttpClientError); + +export interface PluginPinnedHttpRequest { + readonly url: URL; + readonly method: string; + readonly headers: Readonly>; + readonly body: Uint8Array | null; + readonly timeoutMs: number; + readonly address: ResolvedAddress; +} + +export type PluginHttpClientTransport = ( + request: PluginPinnedHttpRequest, +) => Effect.Effect; + +type HttpClientJsonRequestInput = Omit & { + readonly body?: unknown; +}; +type HttpClientGetJsonInput = Omit; + +export class PluginHttpClientTransportService extends Context.Service< + PluginHttpClientTransportService, + PluginHttpClientTransport +>()("t3/plugins/capabilities/HttpClientCapability/PluginHttpClientTransportService") {} + +const bodyToBytes = (body: HttpClientRequestInput["body"]): Uint8Array | null => { + if (body === undefined) return null; + return typeof body === "string" ? new TextEncoder().encode(body) : body; +}; + +const clampPositiveInteger = (value: number | undefined, fallback: number, hardMax: number) => { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.min(Math.floor(value), hardMax); +}; + +const hostForMessage = (rawUrl: string): string => { + try { + return new URL(rawUrl).hostname || rawUrl; + } catch { + return rawUrl; + } +}; + +const validateHeaders = ( + headers: Readonly> | undefined, + host: string, +): Effect.Effect>, HttpClientError> => { + if (!headers) return Effect.succeed({}); + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (hasControlChars(name) || hasControlChars(value)) { + return Effect.fail( + new HttpClientError({ host, reason: "request header contains control characters" }), + ); + } + normalized[name] = value; + } + return Effect.succeed(normalized); +}; + +function nodeHeadersToWebHeaders(headers: NodeHttp.IncomingHttpHeaders): Headers { + const webHeaders = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) webHeaders.append(name, item); + } else { + webHeaders.set(name, value); + } + } + return webHeaders; +} + +function makeResponse(input: { + readonly url: URL; + readonly method: string; + readonly response: NodeHttp.IncomingMessage; +}): HttpClientResponse.HttpClientResponse { + const request = HttpClientRequest.make(input.method as "GET")(input.url.toString()); + const body = NodeStream.Readable.toWeb(input.response) as ReadableStream; + return HttpClientResponse.fromWeb( + request, + new Response(body, { + status: input.response.statusCode ?? 0, + headers: nodeHeadersToWebHeaders(input.response.headers), + }), + ); +} + +const nodePinnedTransport: PluginHttpClientTransport = (input) => + Effect.tryPromise({ + try: (signal) => + new Promise((resolve, reject) => { + const client = input.url.protocol === "http:" ? NodeHttp : NodeHttps; + const request = client.request( + input.url, + { + method: input.method, + headers: input.headers, + timeout: input.timeoutMs, + // Pin the connection to the validator-resolved address. Node's + // net layer calls this with `all: true` when autoSelectFamily + // (Happy Eyeballs, default since Node 20) is active and then + // expects an ARRAY of addresses; the scalar form is only used + // when `all` is unset. + lookup: (_hostname, options, callback) => { + if (options.all) { + callback(null, [{ address: input.address.address, family: input.address.family }]); + } else { + callback(null, input.address.address, input.address.family); + } + }, + }, + (response) => { + resolve(makeResponse({ url: input.url, method: input.method, response })); + }, + ); + // Fiber interruption (e.g. the caller's wall-clock deadline in + // `request` below) aborts this signal; tear the socket down so an + // interrupted request cannot keep the connection open. + signal.addEventListener("abort", () => { + request.destroy(new Error("aborted")); + }); + request.on("timeout", () => { + request.destroy(new Error("timeout")); + }); + request.on("error", reject); + if (input.body) { + request.write(Buffer.from(input.body)); + } + request.end(); + }), + catch: (cause) => + // Derive the wrapper message from a stable structural reason only; the real + // transport error is preserved in `data.cause`. Echoing cause.message would + // leak underlying (possibly attacker-influenced) transport text into the + // wrapper message, unlike the other HttpClientError sites in this file which + // already use stable reasons. + new HttpClientError({ + host: input.url.hostname, + reason: "transport request failed", + data: { cause }, + }), + }); + +export const PluginHttpClientTransportLive = Layer.succeed( + PluginHttpClientTransportService, + nodePinnedTransport, +); + +const parseJson = (bytes: Uint8Array, host: string): Effect.Effect => + Effect.try({ + // @effect-diagnostics-next-line preferSchemaOverJson:off -- SDK convenience wrapper returns caller-typed JSON. + try: () => JSON.parse(new TextDecoder().decode(bytes)) as A, + catch: (cause) => + new HttpClientError({ + host, + reason: "response body is not valid JSON", + data: { cause }, + }), + }); + +export function makeHttpClientCapability(input?: { + readonly lookup?: UrlValidatorDeps["lookup"] | undefined; + readonly transport?: PluginHttpClientTransport | undefined; +}): HttpClientCapability { + const lookup = input?.lookup ?? defaultLookup; + const transport = input?.transport ?? nodePinnedTransport; + + const request: HttpClientCapability["request"] = (requestInput) => + Effect.gen(function* () { + const host = hostForMessage(requestInput.url); + const resolved = yield* OutboundUrlValidator.resolve(requestInput.url, { + lookup, + allowHttpLoopback: process.env.T3_PLUGIN_DEV === "1", + }).pipe( + Effect.mapError( + (error: OutboundUrlError) => + new HttpEgressBlockedError({ + host, + reason: error.reason, + data: { cause: error }, + }), + ), + ); + const headers = yield* validateHeaders(requestInput.headers, host); + const requestBody = bodyToBytes(requestInput.body); + if (requestBody !== null && requestBody.byteLength > REQUEST_BODY_MAX_BYTES) { + return yield* new HttpClientError({ + host, + reason: "request body exceeded the size limit", + data: { limit: REQUEST_BODY_MAX_BYTES, actual: requestBody.byteLength }, + }); + } + const maxResponseBytes = clampPositiveInteger( + requestInput.maxResponseBytes, + DEFAULT_RESPONSE_MAX_BYTES, + HARD_RESPONSE_MAX_BYTES, + ); + const timeoutMs = clampPositiveInteger( + requestInput.timeoutMs, + DEFAULT_TIMEOUT_MS, + HARD_TIMEOUT_MS, + ); + // Hard end-to-end deadline around transport + body read. The Node + // `timeout` option passed to the transport only bounds SOCKET + // INACTIVITY (it resets on every byte), so a slow-drip server sending + // one byte per idle window could otherwise hold the socket and buffer + // indefinitely while staying under the byte cap. Interruption from the + // deadline aborts the transport's signal, destroying the socket. + return yield* Effect.gen(function* () { + const response = yield* transport({ + url: resolved.url, + method: requestInput.method.toUpperCase(), + headers, + body: requestBody, + timeoutMs, + address: resolved.addresses[0]!, + }); + const body = yield* readHttpResponseBytesCapped({ + response, + maxBytes: maxResponseBytes, + tooLarge: (actual) => + new HttpClientError({ + host: resolved.url.hostname, + reason: "response body exceeded the size limit", + data: { limit: maxResponseBytes, actual }, + }), + readFailed: (cause) => + isHttpClientError(cause) + ? cause + : new HttpClientError({ + host: resolved.url.hostname, + reason: "failed to read response body", + data: { cause }, + }), + }); + return { + status: response.status, + headers: response.headers, + body, + }; + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => + new HttpClientError({ + host: resolved.url.hostname, + reason: "request exceeded the time limit", + data: { timeoutMs }, + }), + }), + ); + }); + + return { + request, + requestJson: (jsonInput: HttpClientJsonRequestInput) => + Effect.gen(function* () { + const { body: jsonBody, ...requestRest } = jsonInput; + let body: string | undefined; + if (jsonBody !== undefined) { + // @effect-diagnostics-next-line preferSchemaOverJson:off -- SDK convenience wrapper accepts arbitrary JSON payloads. + body = JSON.stringify(jsonBody); + } + const response = yield* request({ + ...requestRest, + ...(body === undefined ? {} : { body }), + headers: { + accept: "application/json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + ...jsonInput.headers, + }, + }); + return yield* parseJson(response.body, hostForMessage(jsonInput.url)); + }), + getJson: (url: string, jsonInput: HttpClientGetJsonInput = {}) => + request({ + ...jsonInput, + method: "GET", + url, + headers: { + accept: "application/json", + ...jsonInput.headers, + }, + }).pipe(Effect.flatMap((response) => parseJson(response.body, hostForMessage(url)))), + }; +} diff --git a/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts index e900c763d45..99113ab2086 100644 --- a/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts +++ b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts @@ -18,8 +18,8 @@ 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 { makeSourceControlCapability, SourceControlPathError } from "./SourceControlCapability.ts"; +import { makeTerminalsCapability, TerminalHandleOwnershipError } from "./TerminalsCapability.ts"; import { makeTextGenerationCapability } from "./TextGenerationCapability.ts"; class RollbackTestError extends Data.TaggedError("RollbackTestError") {} @@ -75,7 +75,15 @@ it.effect("secrets enforce and strip the plugin key prefix", () => 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`))); + assert.isTrue(Option.isSome(yield* store.get(`plugin~${pluginId}~api-key`))); + + // The store key must map to a Windows-safe filename: no ':' (illegal on + // Windows), delimited by '~'. Round-trips through set/get/list above. + const secretFiles = (yield* fileSystem.readDirectory(config.secretsDir)).filter((entry) => + entry.endsWith(".bin"), + ); + assert.deepEqual(secretFiles, [`plugin~${pluginId}~api-key.bin`]); + assert.isFalse(secretFiles[0]?.includes(":") ?? true); // Names outside the safe grammar are rejected: the backing store maps // keys to file paths, so separators/colons/traversal must never reach it. @@ -127,6 +135,7 @@ 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; + let capturedMessagesLimit: number | undefined; const capability = makeProjectionsReadCapability({ snapshots: { getThreadShellById: () => Effect.succeed(Option.some(threadShell)), @@ -154,29 +163,53 @@ it.effect("projections read returns contract-shaped thread data with caps", () = ] 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), + // The cap now pushes `limit` into the repo instead of slicing after the + // fact, so honor it here (and record it) to model the real LIMIT query + // and prove the cap forwards the bound. + listByThreadId: (input: { readonly limit?: number }) => { + capturedMessagesLimit = input.limit; + return 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: "bounded by the repo limit", + isStreaming: false, + createdAt: "2026-07-03T00:00:02.000Z", + updatedAt: "2026-07-03T00:00:03.000Z", + }, + ] as any[] + ).slice(0, input.limit), + ); + }, + getByMessageId: (input: { readonly messageId: string }) => + Effect.succeed( + input.messageId === "message-1" + ? Option.some({ + 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", + } as any) + : Option.none(), + ), } as any, activities: { listByThreadId: () => @@ -215,6 +248,18 @@ it.effect("projections read returns contract-shaped thread data with caps", () = }, ], ); + // The bound is pushed into the repo query, not applied by a post-hoc slice. + assert.equal(capturedMessagesLimit, 1); + assert.deepEqual(yield* capability.getMessageById("message-1" as any), { + 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.equal(yield* capability.getMessageById("message-missing" as any), null); assert.deepEqual(yield* capability.listActivitiesByThreadId({ threadId: "thread-1" as any }), [ { id: "activity-1" as any, @@ -238,6 +283,8 @@ it.effect("text generation delegates the existing one-shot operations", () => 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) }), + generateBoardProposal: (input) => + Effect.succeed({ proposedDefinition: { fromPrompt: input.prompt }, rationale: "test" }), }); const modelSelection = { instanceId: "codex", model: "gpt-test" } as any; @@ -280,7 +327,14 @@ it.effect("text generation delegates the existing one-shot operations", () => it.effect("source control exposes provider detection and existing GitHub CLI PR operations", () => Effect.gen(function* () { + const path = yield* Path.Path; const createInputs: unknown[] = []; + const cloneInputs: unknown[] = []; + const mergeInputs: unknown[] = []; + const detailInputs: unknown[] = []; + const checkInputs: unknown[] = []; + const reviewInputs: unknown[] = []; + const commentInputs: unknown[] = []; const capability = makeSourceControlCapability({ registry: { resolveHandle: () => @@ -313,13 +367,76 @@ it.effect("source control exposes provider detection and existing GitHub CLI PR baseRefName: "main", headRefName: "fix", }), + getRepositoryCloneUrls: (input: any) => + Effect.sync(() => { + cloneInputs.push(input); + return { + nameWithOwner: "o/r", + url: "https://github.com/o/r", + sshUrl: "git@github.com:o/r.git", + }; + }), createPullRequest: (input: any) => Effect.sync(() => { createInputs.push(input); }), + mergePullRequest: (input: any) => + Effect.sync(() => { + mergeInputs.push(input); + }), + getPullRequestDetail: (input: any) => + Effect.sync(() => { + detailInputs.push(input); + return { + state: "OPEN", + mergedAt: null, + reviewDecision: "APPROVED", + headRefOid: "abc", + url: "https://github.com/o/r/pull/2", + }; + }), + listPullRequestChecks: (input: any) => + Effect.sync(() => { + checkInputs.push(input); + return [{ name: "ci", state: "SUCCESS", bucket: "pass", link: "https://checks" }]; + }), + listPullRequestReviews: (input: any) => + Effect.sync(() => { + reviewInputs.push(input); + return [ + { + id: "R_1", + author: "octocat", + state: "APPROVED", + body: "ship it", + submittedAt: "2026-07-03T00:00:00Z", + }, + ]; + }), + listPullRequestReviewComments: (input: any) => + Effect.sync(() => { + commentInputs.push(input); + return [ + { + id: 1, + user: "octocat", + body: "comment", + path: "src/file.ts", + createdAt: "2026-07-03T00:00:00Z", + }, + ]; + }), getDefaultBranch: () => Effect.succeed("main"), checkoutPullRequest: () => Effect.void, } as any, + // Grant "/repo" as a project root; identity realPath keeps the stub off the + // real filesystem so this delegation test stays hermetic. + snapshots: { + getShellSnapshot: () => Effect.succeed({ projects: [{ workspaceRoot: "/repo" }] }), + } as any, + grants: { snapshot: () => Effect.succeed(new Set()) } as any, + fileSystem: { realPath: (candidate: string) => Effect.succeed(candidate) } as any, + path, }); assert.deepEqual(yield* capability.detectProvider({ cwd: "/repo" }), { @@ -334,17 +451,113 @@ it.effect("source control exposes provider detection and existing GitHub CLI PR 1, ); assert.equal((yield* capability.getPullRequest({ cwd: "/repo", reference: "2" })).number, 2); + assert.deepEqual( + yield* capability.getRepositoryCloneUrls({ cwd: "/repo", repository: "o/r" }), + { + nameWithOwner: "o/r", + url: "https://github.com/o/r", + sshUrl: "git@github.com:o/r.git", + }, + ); yield* capability.createPullRequest({ cwd: "/repo", baseBranch: "main", headSelector: "feature", title: "PR", - bodyFile: "/tmp/body.md", + bodyFile: "/repo/body.md", + draft: true, }); assert.equal(createInputs.length, 1); + assert.deepEqual(createInputs[0], { + cwd: "/repo", + baseBranch: "main", + headSelector: "feature", + title: "PR", + bodyFile: "/repo/body.md", + draft: true, + }); + yield* capability.mergePullRequest({ cwd: "/repo", number: 2, strategy: "squash" }); + assert.deepEqual(mergeInputs, [{ cwd: "/repo", number: 2, strategy: "squash" }]); + assert.equal( + (yield* capability.getPullRequestDetail({ cwd: "/repo", number: 2 })).state, + "OPEN", + ); + assert.deepEqual(detailInputs, [{ cwd: "/repo", number: 2 }]); + assert.equal( + (yield* capability.listPullRequestChecks({ cwd: "/repo", number: 2 }))[0]?.name, + "ci", + ); + assert.deepEqual(checkInputs, [{ cwd: "/repo", number: 2 }]); + assert.equal( + (yield* capability.listPullRequestReviews({ cwd: "/repo", number: 2 }))[0]?.author, + "octocat", + ); + assert.deepEqual(reviewInputs, [{ cwd: "/repo", number: 2 }]); + assert.equal( + (yield* capability.listPullRequestReviewComments({ + cwd: "/repo", + repo: "o/r", + number: 2, + }))[0]?.path, + "src/file.ts", + ); + assert.deepEqual(commentInputs, [{ cwd: "/repo", repo: "o/r", number: 2 }]); + assert.deepEqual(cloneInputs, [{ cwd: "/repo", repository: "o/r" }]); assert.equal(yield* capability.getDefaultBranch({ cwd: "/repo" }), "main"); yield* capability.checkoutPullRequest({ cwd: "/repo", reference: "2" }); - }), + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("source control rejects cwd/bodyFile outside the plugin's granted roots", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const createCalls: unknown[] = []; + const listCalls: unknown[] = []; + const capability = makeSourceControlCapability({ + registry: { resolveHandle: () => Effect.die("registry should not be reached") } as any, + github: { + createPullRequest: (input: any) => + Effect.sync(() => { + createCalls.push(input); + }), + listOpenPullRequests: (input: any) => + Effect.sync(() => { + listCalls.push(input); + return []; + }), + } as any, + // Only "/repo" is granted; identity realPath keeps the check hermetic. + snapshots: { + getShellSnapshot: () => Effect.succeed({ projects: [{ workspaceRoot: "/repo" }] }), + } as any, + grants: { snapshot: () => Effect.succeed(new Set()) } as any, + fileSystem: { realPath: (candidate: string) => Effect.succeed(candidate) } as any, + path, + }); + + // bodyFile outside the granted root cannot be exfiltrated through the PR body. + const exfil = yield* Effect.result( + capability.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature", + title: "PR", + bodyFile: "/etc/secret", + }), + ); + assert.isTrue(Result.isFailure(exfil)); + if (Result.isFailure(exfil)) { + assert.instanceOf(exfil.failure, SourceControlPathError); + } + assert.deepEqual(createCalls, []); + + // cwd pointing at an arbitrary repo is likewise rejected before the gh op. + const foreignCwd = yield* Effect.result( + capability.listOpenPullRequests({ cwd: "/other/repo", headSelector: "feature" }), + ); + assert.isTrue(Result.isFailure(foreignCwd)); + assert.deepEqual(listCalls, []); + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect( @@ -432,3 +645,55 @@ it.effect( }); }), ); + +it.effect("terminals reject forged handles for foreign/core sessions", () => + Effect.gen(function* () { + const writes: string[] = []; + const closes: unknown[] = []; + const attaches: unknown[] = []; + const { capability } = makeTerminalsCapability({ + pluginId: PluginId.make("terminal-plugin"), + manager: { + open: () => Effect.die(new Error("open should not be reached")), + attachStream: (input: any) => + Effect.sync(() => { + attaches.push(input); + return () => undefined; + }), + write: (input: any) => + Effect.sync(() => { + writes.push(input.data); + }), + close: (input: any) => + Effect.sync(() => { + closes.push(input); + }), + } as any, + }); + + // A foreign plugin's namespace and a bare core thread id are both off-limits: + // the handle is caller-supplied data, so the prefix guard must reject before + // the manager op runs. + for (const foreign of [ + { threadId: "plugin:other-plugin:run-1", terminalId: "run-1" }, + { threadId: "thread-real-user", terminalId: "shell-1" }, + ]) { + const killed = yield* Effect.result(capability.kill({ ...foreign, deleteHistory: true })); + assert.isTrue(Result.isFailure(killed)); + if (Result.isFailure(killed)) { + assert.instanceOf(killed.failure, TerminalHandleOwnershipError); + } + + const sent = yield* Effect.result(capability.sendInput({ ...foreign, data: "x" })); + assert.isTrue(Result.isFailure(sent)); + + const observed = yield* Effect.result(capability.observe(foreign, () => Effect.void)); + assert.isTrue(Result.isFailure(observed)); + } + + // None of the forged handles reached the manager. + assert.deepEqual(writes, []); + assert.deepEqual(closes, []); + assert.deepEqual(attaches, []); + }), +); diff --git a/apps/server/src/plugins/capabilities/ProjectionsReadCapability.test.ts b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.test.ts new file mode 100644 index 00000000000..efd2febc1cc --- /dev/null +++ b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.test.ts @@ -0,0 +1,92 @@ +import { MessageId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { makeProjectionsReadCapability } from "./ProjectionsReadCapability.ts"; + +const layer = it.layer( + ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionsReadCapability", (it) => { + it.effect("getMessageById returns the projected message or null", () => + Effect.gen(function* () { + const messages = yield* ProjectionThreadMessageRepository; + const projections = makeProjectionsReadCapability({ + snapshots: {} as any, + turns: {} as any, + messages, + activities: {} as any, + }); + const threadId = ThreadId.make("thread-message-by-id"); + const messageId = MessageId.make("message-by-id"); + const createdAt = "2026-03-01T00:00:00.000Z"; + + yield* messages.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "projected text", + isStreaming: false, + createdAt, + updatedAt: createdAt, + }); + + const found = yield* projections.getMessageById(messageId); + const missing = yield* projections.getMessageById(MessageId.make("message-missing")); + + assert.deepEqual(found, { + id: messageId, + role: "assistant", + text: "projected text", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }); + assert.equal(missing, null); + }), + ); + + it.effect("listMessagesByThreadId bounds the query with a SQL LIMIT", () => + Effect.gen(function* () { + const messages = yield* ProjectionThreadMessageRepository; + const projections = makeProjectionsReadCapability({ + snapshots: {} as any, + turns: {} as any, + messages, + activities: {} as any, + }); + const threadId = ThreadId.make("thread-limit"); + + // Seed more rows than we will request. Because the cap pushes the bound + // into the repo query (LIMIT) rather than slicing after materializing all + // rows, only `limit` rows come back — in ascending creation order. + for (let index = 0; index < 5; index += 1) { + const stamp = `2026-03-01T00:00:0${index}.000Z`; + yield* messages.upsert({ + messageId: MessageId.make(`message-limit-${index}`), + threadId, + turnId: null, + role: "assistant", + text: `message ${index}`, + isStreaming: false, + createdAt: stamp, + updatedAt: stamp, + }); + } + + const limited = yield* projections.listMessagesByThreadId({ threadId, limit: 2 }); + assert.equal(limited.length, 2); + assert.deepEqual( + limited.map((message) => message.id), + [MessageId.make("message-limit-0"), MessageId.make("message-limit-1")], + ); + }), + ); +}); diff --git a/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts index a0cfc53c032..bde7800fdce 100644 --- a/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts +++ b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts @@ -68,16 +68,23 @@ export function makeProjectionsReadCapability(input: { ), ), listTurnsByThreadId: ({ threadId, limit }) => - input.turns - .listByThreadId({ threadId }) - .pipe(Effect.map((rows) => rows.slice(0, boundedLimit(limit)))), + input.turns.listByThreadId({ threadId, limit: boundedLimit(limit) }), listMessagesByThreadId: ({ threadId, limit }) => input.messages - .listByThreadId({ threadId }) - .pipe(Effect.map((rows) => rows.slice(0, boundedLimit(limit)).map(toMessage))), + .listByThreadId({ threadId, limit: boundedLimit(limit) }) + .pipe(Effect.map((rows) => rows.map(toMessage))), + getMessageById: (messageId) => + input.messages.getByMessageId({ messageId }).pipe( + Effect.map( + Option.match({ + onNone: () => null, + onSome: toMessage, + }), + ), + ), listActivitiesByThreadId: ({ threadId, limit }) => input.activities - .listByThreadId({ threadId }) - .pipe(Effect.map((rows) => rows.slice(0, boundedLimit(limit)).map(toActivity))), + .listByThreadId({ threadId, limit: boundedLimit(limit) }) + .pipe(Effect.map((rows) => rows.map(toActivity))), }; } diff --git a/apps/server/src/plugins/capabilities/SecretsCapability.ts b/apps/server/src/plugins/capabilities/SecretsCapability.ts index d466e55ea7e..a8f66657173 100644 --- a/apps/server/src/plugins/capabilities/SecretsCapability.ts +++ b/apps/server/src/plugins/capabilities/SecretsCapability.ts @@ -9,11 +9,17 @@ 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 filenames verbatim (`.bin`), and the +// server targets Windows where ':' is illegal in filenames. Delimit with '~', +// which is absent from both the plugin id charset ([a-z][a-z0-9-]{1,40}) and the +// secret name charset (SECRET_NAME_PATTERN below), so keys stay Windows-safe and +// list() can still split the prefix from the name unambiguously. Round-trips: +// set/get/delete build `plugin~~`; list() strips `plugin~~` back. +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). +// be a safe path segment: no separators, no dots-only tricks, no '~' +// (the delimiter of the plugin prefix, which would break list() parsing). const SECRET_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; export class PluginSecretNameError extends Schema.TaggedErrorClass()( @@ -61,7 +67,10 @@ export function makeSecretsCapability(input: { .sort(), ), Effect.catch((cause) => - cause.reason._tag === "NotFound" ? Effect.succeed([]) : Effect.fail(cause), + // Guard `.reason`: a non-SystemError-shaped failure has no `reason`, and + // `undefined._tag` would throw a TypeError (an unhandled defect) inside + // the catch instead of surfacing the original recoverable failure. + 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 index 5303c1998c9..6eec272071c 100644 --- a/apps/server/src/plugins/capabilities/SourceControlCapability.ts +++ b/apps/server/src/plugins/capabilities/SourceControlCapability.ts @@ -1,27 +1,113 @@ import type { SourceControlCapability } from "@t3tools/plugin-sdk"; import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import type * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as GitHubCli from "../../sourceControl/GitHubCli.ts"; import * as SourceControlProviderRegistry from "../../sourceControl/SourceControlProviderRegistry.ts"; +import type { PluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; + +// Source-control ops take plugin-supplied `cwd` (which repo to operate on) and +// `createPullRequest` a `bodyFile` (read into the PR body). Both are DATA a +// well-behaved plugin may accept from a webhook/UI, so both must be contained: +// an unchecked `cwd` runs git/gh against an arbitrary repo, and an unchecked +// `bodyFile: "/path/to/secret"` exfiltrates arbitrary local file contents into +// the PR body. Require each to resolve within the plugin's granted workspace +// roots, mirroring the filesystem capability's real-path containment. +export class SourceControlPathError extends Schema.TaggedErrorClass()( + "SourceControlPathError", + { path: Schema.String, purpose: Schema.String }, +) { + override get message(): string { + return `Source-control ${this.purpose} path ${JSON.stringify(this.path)} is outside the plugin's granted workspace roots.`; + } +} export function makeSourceControlCapability(input: { readonly registry: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]; readonly github: GitHubCli.GitHubCli["Service"]; + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; }): SourceControlCapability { + const { path } = input; + + const contains = (realRoot: string, realTarget: string): boolean => { + const relative = path.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }; + + const assertWithinGrants = (value: string, purpose: string): Effect.Effect => + Effect.gen(function* () { + const shell = yield* input.snapshots.getShellSnapshot(); + const projectRoots = shell.projects.map((project) => project.workspaceRoot); + const grantedRoots = [...new Set([...projectRoots, ...(yield* input.grants.snapshot())])]; + // Real-path the target (also asserts it exists) and each granted root, so + // symlinks/`..` cannot dodge the containment check. + const realTarget = yield* input.fileSystem + .realPath(value) + .pipe(Effect.mapError(() => new SourceControlPathError({ path: value, purpose }))); + for (const root of grantedRoots) { + const realRoot = yield* input.fileSystem.realPath(root).pipe(Effect.option); + if (Option.isSome(realRoot) && contains(realRoot.value, realTarget)) { + return; + } + } + return yield* new SourceControlPathError({ path: value, purpose }); + }); + + const withCwd = (cwd: string, effect: Effect.Effect): Effect.Effect => + assertWithinGrants(cwd, "cwd").pipe(Effect.andThen(effect)); + 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, - })), + withCwd( + 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), + listOpenPullRequests: (request) => + withCwd(request.cwd, input.github.listOpenPullRequests(request)), + getPullRequest: (request) => withCwd(request.cwd, input.github.getPullRequest(request)), + getRepositoryCloneUrls: (request) => + withCwd(request.cwd, input.github.getRepositoryCloneUrls(request)), + createPullRequest: (request) => + Effect.all( + [assertWithinGrants(request.cwd, "cwd"), assertWithinGrants(request.bodyFile, "bodyFile")], + { discard: true }, + ).pipe( + Effect.andThen( + input.github.createPullRequest({ + cwd: request.cwd, + baseBranch: request.baseBranch, + headSelector: request.headSelector, + title: request.title, + bodyFile: request.bodyFile, + ...(request.draft === undefined ? {} : { draft: request.draft }), + }), + ), + ), + mergePullRequest: (request) => withCwd(request.cwd, input.github.mergePullRequest(request)), + getPullRequestDetail: (request) => + withCwd(request.cwd, input.github.getPullRequestDetail(request)), + listPullRequestChecks: (request) => + withCwd(request.cwd, input.github.listPullRequestChecks(request)), + listPullRequestReviews: (request) => + withCwd(request.cwd, input.github.listPullRequestReviews(request)), + listPullRequestReviewComments: (request) => + withCwd(request.cwd, input.github.listPullRequestReviewComments(request)), + getDefaultBranch: (request) => withCwd(request.cwd, input.github.getDefaultBranch(request)), + checkoutPullRequest: (request) => + withCwd(request.cwd, input.github.checkoutPullRequest(request)), }; } diff --git a/apps/server/src/plugins/capabilities/TerminalsCapability.ts b/apps/server/src/plugins/capabilities/TerminalsCapability.ts index 40dfab6cdf0..70e44b7e957 100644 --- a/apps/server/src/plugins/capabilities/TerminalsCapability.ts +++ b/apps/server/src/plugins/capabilities/TerminalsCapability.ts @@ -3,6 +3,7 @@ import type { TerminalSessionHandle, TerminalsCapability } from "@t3tools/plugin import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; import * as TerminalManager from "../../terminal/Manager.ts"; @@ -11,11 +12,30 @@ const quoteShellArg = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const commandLine = (command: string, args: ReadonlyArray | undefined) => [command, ...(args ?? [])].map(quoteShellArg).join(" "); +// A plugin owns exactly the terminal namespace `plugin::*` — every handle +// `spawn` mints uses this thread-id prefix. +const ownedThreadPrefix = (pluginId: PluginId) => `plugin:${pluginId}:`; + const defaultHandle = (pluginId: PluginId, terminalId: string): TerminalSessionHandle => ({ - threadId: `plugin:${pluginId}:${terminalId}`, + threadId: `${ownedThreadPrefix(pluginId)}${terminalId}`, terminalId, }); +// Terminal handles are plain caller-supplied data, so `sendInput`/`kill`/`observe` +// must NOT trust the incoming thread id: a plugin (or one driven by malicious +// webhook/UI data) could otherwise synthesize a handle for a foreign plugin's +// terminal (`plugin:other:run-1`) or a core thread and write to / close / attach +// to it. Enforce the plugin-owned prefix — its documented session boundary — as +// defense-in-depth before forwarding to the manager. +export class TerminalHandleOwnershipError extends Schema.TaggedErrorClass()( + "TerminalHandleOwnershipError", + { threadId: Schema.String, terminalId: Schema.String }, +) { + override get message(): string { + return `Terminal handle for thread ${JSON.stringify(this.threadId)} is not owned by this plugin.`; + } +} + export interface TerminalsCapabilityBundle { readonly capability: TerminalsCapability; /** Closes every terminal the plugin still holds open; run on plugin scope close. */ @@ -29,6 +49,19 @@ export function makeTerminalsCapability(input: { // 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 prefix = ownedThreadPrefix(input.pluginId); + + const requireOwnedHandle = ( + handle: TerminalSessionHandle, + ): Effect.Effect => + handle.threadId.startsWith(prefix) + ? Effect.succeed(handle) + : Effect.fail( + new TerminalHandleOwnershipError({ + threadId: handle.threadId, + terminalId: handle.terminalId, + }), + ); const closeHandle = (handle: TerminalSessionHandle, deleteHistory?: boolean) => input.manager @@ -41,38 +74,57 @@ export function makeTerminalsCapability(input: { 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 }; - }), + // Open + track atomically: mask interruption so that once `open` succeeds + // the terminal is always recorded in `live` before any interruptible work. + // Otherwise an interrupt landing in the gap would leak the PTY/process — + // shutdown snapshots `live` and would never see it. + Effect.uninterruptibleMask((restore) => + 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* restore( + 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* restore( + input.manager.write({ + ...handle, + data: `${commandLine(request.command, request.args)}\n`, + }), + ); + return { handle, snapshot }; + }), + ), observe: (handle, listener) => - input.manager.attachStream( - { - ...handle, - restartIfNotRunning: false, - }, - listener, + requireOwnedHandle(handle).pipe( + Effect.flatMap((owned) => + input.manager.attachStream( + { + ...owned, + restartIfNotRunning: false, + }, + listener, + ), + ), ), - sendInput: (request) => input.manager.write(request), + sendInput: (request) => + requireOwnedHandle(request).pipe(Effect.flatMap(() => input.manager.write(request))), kill: (request) => - closeHandle( - { threadId: request.threadId, terminalId: request.terminalId }, - request.deleteHistory, + requireOwnedHandle(request).pipe( + Effect.flatMap(() => + closeHandle( + { threadId: request.threadId, terminalId: request.terminalId }, + request.deleteHistory, + ), + ), ), }; diff --git a/apps/server/src/plugins/capabilities/TextGenerationCapability.ts b/apps/server/src/plugins/capabilities/TextGenerationCapability.ts index 4411ceb60e5..b485bafaec9 100644 --- a/apps/server/src/plugins/capabilities/TextGenerationCapability.ts +++ b/apps/server/src/plugins/capabilities/TextGenerationCapability.ts @@ -10,5 +10,6 @@ export function makeTextGenerationCapability( generatePrContent: (input) => textGeneration.generatePrContent(input), generateBranchName: (input) => textGeneration.generateBranchName(input), generateThreadTitle: (input) => textGeneration.generateThreadTitle(input), + generateBoardProposal: (input) => textGeneration.generateBoardProposal(input), }; } diff --git a/apps/server/src/plugins/capabilities/VcsCapability.test.ts b/apps/server/src/plugins/capabilities/VcsCapability.test.ts index f00171192ed..1f6f217b0dd 100644 --- a/apps/server/src/plugins/capabilities/VcsCapability.test.ts +++ b/apps/server/src/plugins/capabilities/VcsCapability.test.ts @@ -3,10 +3,11 @@ 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 { CheckpointRef, GitCommandError, 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 Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import { describe, expect } from "vite-plus/test"; @@ -16,7 +17,8 @@ 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"; +import { makePluginWorkspaceGrants, type PluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; +import { makeVcsCapability, PluginVcsPathError, PluginVcsRefError } from "./VcsCapability.ts"; const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "plugin-vcs-capability-test-", @@ -84,6 +86,46 @@ function initRepoWithCommit( }); } +// Construct the capability with real FileSystem/Path, an empty projects shell +// (granted roots come from `grantedRoots` via the grants service), and a +// worktrees dir that defaults to nowhere (individual tests opt in). +function makeVcs(input: { + readonly git: GitVcsDriver.GitVcsDriver["Service"]; + readonly checkpoints: CheckpointStore.CheckpointStore["Service"]; + readonly grantedRoots?: ReadonlyArray; + readonly grants?: PluginWorkspaceGrants; + readonly worktreesDir?: string; +}) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const grants = input.grants ?? (yield* makePluginWorkspaceGrants); + for (const root of input.grantedRoots ?? []) { + yield* grants.grant(root); + } + return makeVcsCapability({ + git: input.git, + checkpoints: input.checkpoints, + snapshots: { + getShellSnapshot: () => Effect.succeed({ projects: [], threads: [] }), + } as any, + grants, + fileSystem, + path, + worktreesDir: input.worktreesDir ?? "/plugin-vcs-test-no-worktrees-dir", + }); + }); +} + +const expectFailureContaining = (effect: Effect.Effect, marker: string) => + Effect.gen(function* () { + const exit = yield* Effect.exit(effect); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(String(exit.cause)).toContain(marker); + } + }); + it.layer(TestLayer)("VcsCapability", (it) => { describe("git operations", () => { it.effect("creates, lists, and removes worktrees with absolute path validation", () => @@ -95,7 +137,15 @@ it.layer(TestLayer)("VcsCapability", (it) => { yield* initRepoWithCommit(repo); const gitDriver = yield* GitVcsDriver.GitVcsDriver; const checkpointStore = yield* CheckpointStore.CheckpointStore; - const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + const grants = yield* makePluginWorkspaceGrants; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grants, + grantedRoots: [repo], + // The new worktree lands under the server-managed worktrees dir. + worktreesDir: worktreeParent, + }); const rejected = yield* Effect.exit(vcs.status({ worktreePath: "relative/path" })); expect(rejected._tag).toBe("Failure"); @@ -110,6 +160,7 @@ it.layer(TestLayer)("VcsCapability", (it) => { newBranch: "feature/worktree", }); expect(created.worktree.path).toBe(worktreePath); + expect([...(yield* grants.snapshot())]).toContain(worktreePath); const listed = yield* vcs.listWorktrees({ repoRoot: repo }); const fileSystem = yield* FileSystem.FileSystem; @@ -120,6 +171,7 @@ it.layer(TestLayer)("VcsCapability", (it) => { expect(canonicalListedPaths.includes(canonicalWorktreePath)).toBe(true); yield* vcs.removeWorktree({ repoRoot: repo, path: worktreePath, force: true }); + expect([...(yield* grants.snapshot())]).not.toContain(worktreePath); const afterRemove = yield* vcs.listWorktrees({ repoRoot: repo }); const canonicalAfterRemovePaths = yield* Effect.forEach( afterRemove.worktrees, @@ -140,7 +192,11 @@ it.layer(TestLayer)("VcsCapability", (it) => { yield* git(repo, ["remote", "add", "origin", remote]); const gitDriver = yield* GitVcsDriver.GitVcsDriver; const checkpointStore = yield* CheckpointStore.CheckpointStore; - const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); yield* vcs.createBranch({ worktreePath: repo, branch: "feature/commit", switch: true }); yield* writeTextFile(NodePath.join(repo, "README.md"), "# changed\n"); @@ -167,6 +223,118 @@ it.layer(TestLayer)("VcsCapability", (it) => { ), ); + it.effect("removes, cleans, reads refs, current branch, and arbitrary ahead counts", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + const fileSystem = yield* FileSystem.FileSystem; + + yield* writeTextFile(NodePath.join(repo, "tracked.txt"), "tracked\n"); + yield* git(repo, ["add", "tracked.txt"]); + yield* git(repo, ["commit", "-m", "add tracked"]); + yield* vcs.removePath({ worktreePath: repo, path: "tracked.txt" }); + expect(yield* fileSystem.exists(NodePath.join(repo, "tracked.txt"))).toBe(false); + expect(yield* git(repo, ["status", "--porcelain"])).toContain("D tracked.txt"); + yield* git(repo, ["reset", "--hard", "HEAD"]); + + yield* fileSystem.makeDirectory(NodePath.join(repo, "scratch"), { recursive: true }); + yield* writeTextFile(NodePath.join(repo, "scratch", "untracked.txt"), "scratch\n"); + yield* vcs.clean({ worktreePath: repo, path: "scratch" }); + expect(yield* fileSystem.exists(NodePath.join(repo, "scratch"))).toBe(false); + + expect(yield* vcs.currentBranch({ worktreePath: repo })).toBe("main"); + yield* vcs.createBranch({ worktreePath: repo, branch: "feature/ahead", switch: true }); + yield* writeTextFile(NodePath.join(repo, "ahead.txt"), "ahead\n"); + yield* vcs.commit({ worktreePath: repo, subject: "ahead", body: "" }); + expect( + yield* vcs.aheadCount({ + worktreePath: repo, + base: "main", + head: "feature/ahead", + }), + ).toBe(1); + + const refs = yield* vcs.listRefs({ repoRoot: repo }); + const canonicalRepo = yield* fileSystem.realPath(repo); + expect(refs).toContainEqual({ + name: "feature/ahead", + isRemote: false, + worktreePath: canonicalRepo, + }); + expect(refs).toContainEqual({ + name: "main", + isRemote: false, + worktreePath: null, + }); + }), + ), + ); + + it.effect("surfaces remove and clean git failures", () => + Effect.scoped( + Effect.gen(function* () { + const nonRepo = yield* makeTmpDir(); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + // Granted, so the failure comes from git (not a repo), not the guard. + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [nonRepo], + }); + + const removeExit = yield* Effect.exit( + vcs.removePath({ worktreePath: nonRepo, path: "missing.txt" }), + ); + expect(removeExit._tag).toBe("Failure"); + + const cleanExit = yield* Effect.exit( + vcs.clean({ worktreePath: nonRepo, path: "missing-dir" }), + ); + expect(cleanExit._tag).toBe("Failure"); + }), + ), + ); + + it.effect("merges with message and no-ff/no-verify options", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + + yield* vcs.createBranch({ worktreePath: repo, branch: "feature/merge", switch: true }); + yield* writeTextFile(NodePath.join(repo, "merge.txt"), "merge\n"); + yield* vcs.commit({ worktreePath: repo, subject: "source", body: "" }); + yield* git(repo, ["checkout", "main"]); + + const result = yield* vcs.merge({ + worktreePath: repo, + ref: "feature/merge", + message: "Merge feature branch", + noFf: true, + noVerify: true, + }); + expect(result.status).toBe("merged"); + expect(yield* git(repo, ["log", "-1", "--pretty=%s"])).toBe("Merge feature branch"); + }), + ), + ); + it.effect("surfaces merge conflicts as a value", () => Effect.scoped( Effect.gen(function* () { @@ -174,7 +342,11 @@ it.layer(TestLayer)("VcsCapability", (it) => { yield* initRepoWithCommit(repo); const gitDriver = yield* GitVcsDriver.GitVcsDriver; const checkpointStore = yield* CheckpointStore.CheckpointStore; - const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); yield* vcs.createBranch({ worktreePath: repo, branch: "left", switch: true }); yield* writeTextFile(NodePath.join(repo, "README.md"), "left\n"); @@ -193,6 +365,158 @@ it.layer(TestLayer)("VcsCapability", (it) => { ), ); + it.effect("aborts merge conflicts back to a clean tree when requested", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + + 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", + message: "Merge left", + noFf: true, + noVerify: true, + abortOnConflict: true, + }); + expect(result.status).toBe("conflict"); + if (result.status === "conflict") { + expect(result.conflictedFiles).toEqual(["README.md"]); + } + expect(yield* git(repo, ["status", "--porcelain"])).toBe(""); + }), + ), + ); + + it.effect("returns the conflict value even when the conflict-path abort fails", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + // Wrap the driver so the conflict-detection diff succeeds but the + // subsequent `merge --abort` exits nonzero: the facade must .ignore + // the abort failure and still return the conflict VALUE (previously + // it re-threw as GitCommandError, losing conflictedFiles). + const failingAbortGit = { + ...gitDriver, + execute: (executeInput: GitVcsDriver.ExecuteGitInput) => + executeInput.operation === "PluginVcsCapability.merge.abort" + ? Effect.fail( + new GitCommandError({ + operation: executeInput.operation, + command: "git", + cwd: executeInput.cwd, + argumentCount: executeInput.args.length, + exitCode: 128, + detail: "simulated merge --abort failure", + }), + ) + : gitDriver.execute(executeInput), + }; + const vcs = yield* makeVcs({ + git: failingAbortGit, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + + 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", + abortOnConflict: true, + }); + expect(result.status).toBe("conflict"); + if (result.status === "conflict") { + expect(result.conflictedFiles).toEqual(["README.md"]); + } + }), + ), + ); + + it.effect("surfaces a real merge failure as an error, not a conflict", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + + // A nonexistent ref makes `git merge` exit nonzero with NO unmerged + // files. This is a genuine error and must NOT be masked as a conflict. + const error = yield* vcs + .merge({ worktreePath: repo, ref: "does-not-exist-ref", abortOnConflict: true }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(GitCommandError); + expect((error as GitCommandError).stderr).toContain("does-not-exist-ref"); + // Best-effort abort keeps the tree clean even on a genuine failure. + expect(yield* git(repo, ["status", "--porcelain"])).toBe(""); + }), + ), + ); + + it.effect("commits with --no-verify when requested", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + const fileSystem = yield* FileSystem.FileSystem; + + const hooksDir = NodePath.join(repo, "hooks"); + yield* fileSystem.makeDirectory(hooksDir, { recursive: true }); + const hookPath = NodePath.join(hooksDir, "pre-commit"); + yield* writeTextFile(hookPath, "#!/bin/sh\nexit 1\n"); + yield* fileSystem.chmod(hookPath, 0o755); + yield* git(repo, ["config", "core.hooksPath", "hooks"]); + + yield* writeTextFile(NodePath.join(repo, "skip-hook.txt"), "skip\n"); + const result = yield* vcs.commit({ + worktreePath: repo, + subject: "Bypass hook", + body: "", + noVerify: true, + }); + expect(result.status).toBe("created"); + expect(yield* git(repo, ["log", "-1", "--pretty=%s"])).toBe("Bypass hook"); + }), + ), + ); + it.effect("round-trips checkpoints through the existing CheckpointStore surface", () => Effect.scoped( Effect.gen(function* () { @@ -200,7 +524,11 @@ it.layer(TestLayer)("VcsCapability", (it) => { yield* initRepoWithCommit(repo); const gitDriver = yield* GitVcsDriver.GitVcsDriver; const checkpointStore = yield* CheckpointStore.CheckpointStore; - const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/plugin-vcs-test/turn/1"); yield* writeTextFile(NodePath.join(repo, "README.md"), "# changed\n"); @@ -221,4 +549,220 @@ it.layer(TestLayer)("VcsCapability", (it) => { ), ); }); + + describe("untrusted-input hardening", () => { + it.effect("scopes every root to the plugin's granted workspace", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + const foreignRepo = yield* makeTmpDir("plugin-vcs-foreign-"); + yield* initRepoWithCommit(repo); + yield* initRepoWithCommit(foreignRepo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + + // A real repo the server can read is still rejected when ungranted — + // for every operation family, not just status. + yield* expectFailureContaining( + vcs.status({ worktreePath: foreignRepo }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.commit({ worktreePath: foreignRepo, subject: "nope", body: "" }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.clean({ worktreePath: foreignRepo, path: "anything" }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.listRefs({ repoRoot: foreignRepo }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.createWorktree({ + repoRoot: foreignRepo, + ref: "HEAD", + path: NodePath.join(foreignRepo, "worktree"), + }), + PluginVcsPathError.name, + ); + + // The granted root keeps working. + const status = yield* vcs.status({ worktreePath: repo }); + expect(status).toBeDefined(); + }), + ), + ); + + it.effect("rejects a new worktree path outside granted roots and the worktrees dir", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + const elsewhere = yield* makeTmpDir("plugin-vcs-elsewhere-"); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + + yield* expectFailureContaining( + vcs.createWorktree({ + repoRoot: repo, + ref: "HEAD", + path: NodePath.join(elsewhere, "escape-worktree"), + newBranch: "feature/escape", + }), + PluginVcsPathError.name, + ); + const fileSystem = yield* FileSystem.FileSystem; + expect(yield* fileSystem.exists(NodePath.join(elsewhere, "escape-worktree"))).toBe(false); + }), + ), + ); + + it.effect("rejects option injection in user-supplied refs before git runs", () => + Effect.scoped( + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + yield* initRepoWithCommit(repo); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + const injected = `--output=${NodePath.join(repo, "pwned")}`; + + // Refs land in git OPTION position; a leading-dash "ref" like + // --output= would otherwise be honored as an option (an + // arbitrary-write primitive — `--` does not help, options parse + // before it). Every ref-accepting operation must reject it typed. + yield* expectFailureContaining( + vcs.merge({ worktreePath: repo, ref: injected }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.diffRefs({ worktreePath: repo, fromRef: injected, toRef: "HEAD" }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.diffRefs({ worktreePath: repo, fromRef: "HEAD", toRef: injected }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.diffRefToWorkingTree({ worktreePath: repo, baseRef: injected }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.aheadCount({ worktreePath: repo, base: injected, head: "HEAD" }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.createBranch({ worktreePath: repo, branch: "--force" }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.switchRef({ worktreePath: repo, ref: "--detach" }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.createWorktree({ + repoRoot: repo, + ref: injected, + path: NodePath.join(repo, "wt"), + }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.push({ worktreePath: repo, remoteName: "--force" }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.createCheckpoint({ + worktreePath: repo, + checkpointRef: CheckpointRef.make("-d"), + }), + PluginVcsRefError.name, + ); + yield* expectFailureContaining( + vcs.deleteCheckpoints({ + worktreePath: repo, + checkpointRefs: [CheckpointRef.make("--stdin")], + }), + PluginVcsRefError.name, + ); + + // Nothing was written through the injected "ref". + const fileSystem = yield* FileSystem.FileSystem; + expect(yield* fileSystem.exists(NodePath.join(repo, "pwned"))).toBe(false); + }), + ), + ); + + it.effect("rejects sub-paths and filePaths that escape the worktree", () => + Effect.scoped( + Effect.gen(function* () { + const parent = yield* makeTmpDir("plugin-vcs-escape-parent-"); + const repo = NodePath.join(parent, "repo"); + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(repo, { recursive: true }); + yield* initRepoWithCommit(repo); + const outsideFile = NodePath.join(parent, "outside.txt"); + yield* writeTextFile(outsideFile, "outside\n"); + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const vcs = yield* makeVcs({ + git: gitDriver, + checkpoints: checkpointStore, + grantedRoots: [repo], + }); + + // `..`, absolute, and NUL-containing pathspecs are rejected typed + // before git runs; the sibling file outside the worktree survives. + yield* expectFailureContaining( + vcs.removePath({ worktreePath: repo, path: "../outside.txt" }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.removePath({ worktreePath: repo, path: outsideFile }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.clean({ worktreePath: repo, path: ".." }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.clean({ worktreePath: repo, path: "nested/../../escape" }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.commit({ + worktreePath: repo, + subject: "escape", + body: "", + filePaths: ["README.md", "../../outside.txt"], + }), + PluginVcsPathError.name, + ); + yield* expectFailureContaining( + vcs.removePath({ worktreePath: repo, path: "with\0nul" }), + PluginVcsPathError.name, + ); + + expect(yield* fileSystem.exists(outsideFile)).toBe(true); + expect(yield* fileSystem.readFileString(outsideFile)).toBe("outside\n"); + }), + ), + ); + }); }); diff --git a/apps/server/src/plugins/capabilities/VcsCapability.ts b/apps/server/src/plugins/capabilities/VcsCapability.ts index c45eb1f7096..1489acebeff 100644 --- a/apps/server/src/plugins/capabilities/VcsCapability.ts +++ b/apps/server/src/plugins/capabilities/VcsCapability.ts @@ -2,29 +2,94 @@ import * as NodePath from "node:path"; import { GitCommandError } from "@t3tools/contracts"; -import type { VcsCapability, VcsWorktreeSummary } from "@t3tools/plugin-sdk"; +import type { VcsCapability, VcsRef, VcsWorktreeSummary } from "@t3tools/plugin-sdk"; import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type { CheckpointStore } from "../../checkpointing/CheckpointStore.ts"; +import type * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import type * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import type { PluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; export class PluginVcsPathError extends Schema.TaggedErrorClass()( "PluginVcsPathError", { field: Schema.String, path: Schema.String, + reason: Schema.String, }, ) { override get message(): string { - return `VCS path '${this.field}' must be absolute: ${this.path}`; + return `VCS path '${this.field}' is not allowed (${this.reason}): ${this.path}`; + } +} + +// Refs, branch names, and remote names are plugin-supplied DATA that end up in +// git *option* position (`git merge `, `git branch `, `rev-list +// ..`, `update-ref ...`). A value like +// `--output=/abs/path` would be honored by git as an option — an arbitrary +// write primitive — because options parse before any `--` pathspec separator. +// Git ref names can never legitimately begin with `-` (check-ref-format +// rejects them), so reject those plus empty/NUL values here, and additionally +// pass `--end-of-options` wherever this facade builds the argv itself. +export class PluginVcsRefError extends Schema.TaggedErrorClass()( + "PluginVcsRefError", + { + field: Schema.String, + ref: Schema.String, + }, +) { + override get message(): string { + return `VCS ref '${this.field}' is not a valid ref name: ${JSON.stringify(this.ref)}`; } } const requireAbsolute = (field: string, path: string): Effect.Effect => NodePath.isAbsolute(path) ? Effect.succeed(path) - : Effect.fail(new PluginVcsPathError({ field, path })); + : Effect.fail(new PluginVcsPathError({ field, path, reason: "must be absolute" })); + +const requireSafeRef = (field: string, ref: string): Effect.Effect => + ref.length === 0 || ref.startsWith("-") || ref.includes("\0") + ? Effect.fail(new PluginVcsRefError({ field, ref })) + : Effect.succeed(ref); + +const isAbsoluteLike = (value: string): boolean => + NodePath.isAbsolute(value) || value.startsWith("\\") || /^[a-zA-Z]:[\\/]/u.test(value); + +// Sub-paths (removePath / clean / commit filePaths) are pathspecs resolved by +// git against the worktree cwd. Reject NUL bytes, absolute paths, and `..` +// segments so a data-controlled path cannot direct the operation at a parent +// or arbitrary location outside the already-contained worktree root. Mirrors +// the filesystem capability's parseRelativePath discipline. +const requireRepoRelative = ( + field: string, + value: string, +): Effect.Effect => { + if (value.includes("\0")) { + return Effect.fail( + new PluginVcsPathError({ field, path: value, reason: "contains a NUL byte" }), + ); + } + if (isAbsoluteLike(value)) { + return Effect.fail( + new PluginVcsPathError({ field, path: value, reason: "must be relative to the worktree" }), + ); + } + const segments = value + .replace(/\\/gu, "/") + .split("/") + .filter((segment) => segment.length > 0); + if (segments.length === 0 || segments.includes("..")) { + return Effect.fail( + new PluginVcsPathError({ field, path: value, reason: "must not escape the worktree" }), + ); + } + return Effect.succeed(value); +}; function parseWorktreeList(stdout: string): ReadonlyArray { const worktrees: VcsWorktreeSummary[] = []; @@ -81,27 +146,123 @@ function gitCommandError(input: { readonly stderr?: string | undefined; readonly detail: string; }) { - return new GitCommandError({ + const props = { 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, - }); + }; + // Raw stderr rides along as a server-side-only property (never serialized + // to the RPC wire) so in-process consumers can still classify the failure. + return input.stderr !== undefined + ? GitCommandError.withStderr(props, input.stderr) + : new GitCommandError(props); } export function makeVcsCapability(input: { readonly git: GitVcsDriver.GitVcsDriver["Service"]; readonly checkpoints: CheckpointStore["Service"]; + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + // Server-managed base directory for worktrees (ServerConfig.worktreesDir): + // the one location outside the granted roots where createWorktree may place + // a NEW worktree (which is then granted, admitting every later operation on + // it). Mirrors the GitVcsDriver default worktree location. + readonly worktreesDir: string; }): VcsCapability { + const { path } = input; + + const contains = (realRoot: string, realTarget: string): boolean => { + const relative = path.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }; + + // Roots the plugin may run git in: projected project workspace roots plus + // worktrees this plugin created through createWorktree. Same containment + // model as the filesystem and sourceControl capabilities — real-path both + // sides so symlinks/`..` cannot dodge the check. + const grantedRealRoots = Effect.gen(function* () { + const shell = yield* input.snapshots.getShellSnapshot(); + const projectRoots = shell.projects.map((project) => project.workspaceRoot); + const roots = [...new Set([...projectRoots, ...(yield* input.grants.snapshot())])]; + const realRoots: string[] = []; + for (const root of roots) { + const realRoot = yield* input.fileSystem.realPath(root).pipe(Effect.option); + if (Option.isSome(realRoot)) realRoots.push(realRoot.value); + } + return realRoots; + }); + + const assertWithinRealRoots = ( + field: string, + value: string, + realRoots: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + const realTarget = yield* input.fileSystem + .realPath(value) + .pipe( + Effect.mapError( + () => new PluginVcsPathError({ field, path: value, reason: "does not resolve" }), + ), + ); + if (realRoots.some((realRoot) => contains(realRoot, realTarget))) { + return; + } + return yield* new PluginVcsPathError({ + field, + path: value, + reason: "outside the plugin's granted workspace roots", + }); + }); + + // Every operation that runs git in an EXISTING repo/worktree goes through + // this: absolute + real-path contained in a granted root. Returns the + // caller-supplied path (not the real path) so git sees the same cwd the + // caller named. + const requireGrantedRoot = (field: string, value: string): Effect.Effect => + Effect.gen(function* () { + yield* requireAbsolute(field, value); + const realRoots = yield* grantedRealRoots; + yield* assertWithinRealRoots(field, value, realRoots); + return value; + }); + + // Real-path the nearest EXISTING ancestor of a not-yet-created path. The + // input is lexically normalized first (no `..` survives), so the un-created + // suffix below the anchor cannot escape it, and realpathing the anchor keeps + // symlinked ancestors from smuggling the target outside an allowed root. + const nearestExistingAncestorRealPath = ( + field: string, + absolutePath: string, + ): Effect.Effect => + Effect.gen(function* () { + let candidate = absolutePath; + for (;;) { + const real = yield* input.fileSystem.realPath(candidate).pipe(Effect.option); + if (Option.isSome(real)) return real.value; + const parent = path.dirname(candidate); + if (parent === candidate) { + return yield* new PluginVcsPathError({ + field, + path: absolutePath, + reason: "does not resolve", + }); + } + candidate = parent; + } + }); + const executeDiff = (request: { readonly worktreePath: string; readonly args: ReadonlyArray; }) => - requireAbsolute("worktreePath", request.worktreePath).pipe( + requireGrantedRoot("worktreePath", request.worktreePath).pipe( Effect.flatMap((cwd) => input.git.execute({ operation: "PluginVcsCapability.diff", @@ -116,12 +277,12 @@ export function makeVcsCapability(input: { return { status: ({ worktreePath }) => - requireAbsolute("worktreePath", worktreePath).pipe( + requireGrantedRoot("worktreePath", worktreePath).pipe( Effect.flatMap((cwd) => input.git.status({ cwd })), ), listWorktrees: ({ repoRoot }) => - requireAbsolute("repoRoot", repoRoot).pipe( + requireGrantedRoot("repoRoot", repoRoot).pipe( Effect.flatMap((cwd) => input.git.execute({ operation: "PluginVcsCapability.listWorktrees", @@ -134,57 +295,176 @@ export function makeVcsCapability(input: { createWorktree: (request) => Effect.gen(function* () { - const cwd = yield* requireAbsolute("repoRoot", request.repoRoot); - const path = yield* requireAbsolute("path", request.path); - return yield* input.git.createWorktree({ + const cwd = yield* requireGrantedRoot("repoRoot", request.repoRoot); + yield* requireSafeRef("ref", request.ref); + if (request.newBranch !== undefined) { + yield* requireSafeRef("newBranch", request.newBranch); + } + if (request.baseRef !== undefined) { + yield* requireSafeRef("baseRef", request.baseRef); + } + const worktreePath = path.normalize(yield* requireAbsolute("path", request.path)); + // The new worktree may not exist yet, so containment is asserted on + // its nearest existing ancestor: inside a granted root, or inside the + // server-managed worktrees base dir (the standard location). Anything + // else would let data-controlled paths write a checkout to an + // arbitrary filesystem location. + const realRoots = yield* grantedRealRoots; + const worktreesRealRoot = yield* input.fileSystem + .realPath(input.worktreesDir) + .pipe(Effect.option); + const allowedRoots = [ + ...realRoots, + ...(Option.isSome(worktreesRealRoot) ? [worktreesRealRoot.value] : []), + ]; + const anchor = yield* nearestExistingAncestorRealPath("path", worktreePath); + if (!allowedRoots.some((root) => contains(root, anchor))) { + return yield* new PluginVcsPathError({ + field: "path", + path: request.path, + reason: "outside the plugin's granted workspace roots and the worktrees directory", + }); + } + const result = yield* input.git.createWorktree({ cwd, refName: request.ref, newRefName: request.newBranch, baseRefName: request.baseRef, - path, + path: worktreePath, }); + yield* input.grants.grant(result.worktree.path ?? worktreePath); + return result; }), removeWorktree: (request) => Effect.gen(function* () { - const cwd = yield* requireAbsolute("repoRoot", request.repoRoot); - const path = yield* requireAbsolute("path", request.path); + const cwd = yield* requireGrantedRoot("repoRoot", request.repoRoot); + // The worktree being removed must itself be granted (it was granted at + // create time, or lives inside a project root). + const worktreePath = yield* requireGrantedRoot("path", request.path); yield* input.git.removeWorktree({ cwd, - path, + path: worktreePath, force: request.force, }); + yield* input.grants.revoke(worktreePath); }), createBranch: (request) => - requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireSafeRef("branch", request.branch); + return yield* input.git.createRef({ + cwd, + refName: request.branch, + switchRef: request.switch, + }); + }), + + switchRef: (request) => + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireSafeRef("ref", request.ref); + return yield* input.git.switchRef({ + cwd, + refName: request.ref, + }); + }), + + removePath: (request) => + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireRepoRelative("path", request.path); + yield* input.git.execute({ + operation: "PluginVcsCapability.removePath", + cwd, + args: ["rm", "-r", "-f", "--ignore-unmatch", "--", request.path], + }); + }), + + clean: (request) => + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireRepoRelative("path", request.path); + yield* input.git.execute({ + operation: "PluginVcsCapability.clean", + cwd, + args: ["clean", "-f", "-d", "--", request.path], + }); + }), + + currentBranch: ({ worktreePath }) => + requireGrantedRoot("worktreePath", worktreePath).pipe( Effect.flatMap((cwd) => - input.git.createRef({ + input.git.execute({ + operation: "PluginVcsCapability.currentBranch", cwd, - refName: request.branch, - switchRef: request.switch, + args: ["rev-parse", "--abbrev-ref", "HEAD"], }), ), + Effect.map((result) => result.stdout.trim()), ), - switchRef: (request) => - requireAbsolute("worktreePath", request.worktreePath).pipe( - Effect.flatMap((cwd) => - input.git.switchRef({ - cwd, - refName: request.ref, - }), + aheadCount: (request) => + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireSafeRef("base", request.base); + yield* requireSafeRef("head", request.head); + const args = [ + "rev-list", + "--count", + "--end-of-options", + `${request.base}..${request.head}`, + ]; + const result = yield* input.git.execute({ + operation: "PluginVcsCapability.aheadCount", + cwd, + args, + }); + const count = Number.parseInt(result.stdout.trim(), 10); + return Number.isFinite(count) + ? count + : yield* gitCommandError({ + operation: "PluginVcsCapability.aheadCount", + cwd: request.worktreePath, + args, + stdout: result.stdout, + stderr: result.stderr, + detail: "git rev-list returned a non-numeric count", + }); + }), + + listRefs: ({ repoRoot }) => + requireGrantedRoot("repoRoot", repoRoot).pipe( + Effect.flatMap((cwd) => input.git.listRefs({ cwd })), + Effect.map( + (result): ReadonlyArray => + result.refs.map((ref) => ({ + name: ref.name, + isRemote: ref.isRemote ?? false, + worktreePath: ref.worktreePath, + })), ), ), commit: (request) => Effect.gen(function* () { - const cwd = yield* requireAbsolute("worktreePath", request.worktreePath); + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + if (request.filePaths !== undefined) { + yield* Effect.forEach(request.filePaths, (filePath) => + requireRepoRelative("filePaths", filePath), + ); + } 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 ?? ""); + const result = yield* input.git.commit( + cwd, + request.subject, + request.body ?? "", + request.noVerify ? { noVerify: true } : {}, + ); return { status: "created" as const, commitSha: result.commitSha, @@ -193,8 +473,16 @@ export function makeVcsCapability(input: { merge: (request) => Effect.gen(function* () { - const cwd = yield* requireAbsolute("worktreePath", request.worktreePath); - const args = ["merge", request.ref]; + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireSafeRef("ref", request.ref); + const args = [ + "merge", + ...(request.noFf ? ["--no-ff"] : []), + ...(request.noVerify ? ["--no-verify"] : []), + ...(request.message ? ["-m", request.message] : []), + "--end-of-options", + request.ref, + ]; const result = yield* input.git.execute({ operation: "PluginVcsCapability.merge", cwd, @@ -230,6 +518,18 @@ export function makeVcsCapability(input: { .map((line) => line.trim()) .filter((line) => line.length > 0); if (conflictedFiles.length > 0) { + if (request.abortOnConflict) { + // Best-effort, like the non-conflict path below: a nonzero abort + // must not turn the conflict VALUE into a GitCommandError — the + // caller would lose the conflictedFiles detail it asked for. + yield* input.git + .execute({ + operation: "PluginVcsCapability.merge.abort", + cwd, + args: ["merge", "--abort"], + }) + .pipe(Effect.ignore); + } return { status: "conflict" as const, conflictedFiles, @@ -238,6 +538,19 @@ export function makeVcsCapability(input: { }; } + if (request.abortOnConflict) { + // A non-conflict merge failure may still have left MERGE_HEAD (fork + // aborts on any nonzero exit). Abort best-effort so the tree returns + // clean; ignore the abort's own error since a precondition failure + // (e.g. a bad ref) may leave no merge in progress to abort. + yield* input.git + .execute({ + operation: "PluginVcsCapability.merge.abort", + cwd, + args: ["merge", "--abort"], + }) + .pipe(Effect.ignore); + } return yield* gitCommandError({ operation: "PluginVcsCapability.merge", cwd, @@ -250,13 +563,18 @@ export function makeVcsCapability(input: { }), push: (request) => - requireAbsolute("worktreePath", request.worktreePath).pipe( - Effect.flatMap((cwd) => - input.git.pushCurrentBranch(cwd, request.fallbackBranch ?? null, { - remoteName: request.remoteName ?? null, - }), - ), - ), + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + if (request.fallbackBranch !== undefined && request.fallbackBranch !== null) { + yield* requireSafeRef("fallbackBranch", request.fallbackBranch); + } + if (request.remoteName !== undefined && request.remoteName !== null) { + yield* requireSafeRef("remoteName", request.remoteName); + } + return yield* input.git.pushCurrentBranch(cwd, request.fallbackBranch ?? null, { + remoteName: request.remoteName ?? null, + }); + }), workingTreeDiff: (request) => executeDiff({ @@ -272,58 +590,143 @@ export function makeVcsCapability(input: { }), diffRefs: (request) => - executeDiff({ - worktreePath: request.worktreePath, - args: [ - "diff", - "--no-ext-diff", - "--patch", - "--minimal", - ...(request.ignoreWhitespace ? ["--ignore-all-space"] : []), - `${request.fromRef}..${request.toRef}`, - ], + Effect.gen(function* () { + yield* requireSafeRef("fromRef", request.fromRef); + yield* requireSafeRef("toRef", request.toRef); + return yield* executeDiff({ + worktreePath: request.worktreePath, + args: [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + ...(request.ignoreWhitespace ? ["--ignore-all-space"] : []), + "--end-of-options", + `${request.fromRef}..${request.toRef}`, + ], + }); + }), + + // Diff a caller-supplied base ref against the live working tree (tracked + // uncommitted state) plus, by default, untracked files as `/dev/null` add + // diffs. Mirrors the fork's WorktreeDiffPort.diffRefToWorktree: three bounded + // git invocations (tracked diff, untracked list, per-untracked add-diff), + // concatenated, with `truncated` OR'd across all segments. + diffRefToWorkingTree: (request) => + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireSafeRef("baseRef", request.baseRef); + const wsArgs = request.ignoreWhitespace ? ["--ignore-all-space"] : []; + const tracked = yield* input.git.execute({ + operation: "PluginVcsCapability.diffRefToWorkingTree.tracked", + cwd, + args: [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + ...wsArgs, + "--end-of-options", + `${request.baseRef}^{commit}`, + "--", + ], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }); + + const includeUntracked = request.includeUntracked !== false; + const untrackedList = includeUntracked + ? yield* input.git + .execute({ + operation: "PluginVcsCapability.diffRefToWorkingTree.untracked.list", + cwd, + args: ["ls-files", "--others", "--exclude-standard", "-z"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }) + .pipe(Effect.orElseSucceed(() => ({ stdout: "", stdoutTruncated: false }))) + : { stdout: "", stdoutTruncated: false }; + const untrackedPaths = untrackedList.stdout.split("\0").filter((p) => p.length > 0); + const untrackedDiffs = yield* Effect.forEach( + untrackedPaths, + (untrackedPath) => + input.git.execute({ + operation: "PluginVcsCapability.diffRefToWorkingTree.untracked.diff", + cwd, + args: [ + "diff", + "--no-ext-diff", + "--no-index", + "--patch", + "--minimal", + ...wsArgs, + "--", + "/dev/null", + untrackedPath, + ], + allowNonZeroExit: true, + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }), + { concurrency: 4 }, + ); + + return { + diff: [ + tracked.stdout.trimEnd(), + ...untrackedDiffs.map((result) => result.stdout.trimEnd()), + ] + .filter((part) => part.length > 0) + .join("\n"), + truncated: + tracked.stdoutTruncated || + untrackedList.stdoutTruncated || + untrackedDiffs.some((result) => result.stdoutTruncated), + }; }), createCheckpoint: (request) => - requireAbsolute("worktreePath", request.worktreePath).pipe( - Effect.flatMap((cwd) => - input.checkpoints.captureCheckpoint({ - cwd, - checkpointRef: request.checkpointRef, - }), - ), - ), + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireSafeRef("checkpointRef", request.checkpointRef); + return yield* input.checkpoints.captureCheckpoint({ + cwd, + checkpointRef: request.checkpointRef, + }); + }), hasCheckpoint: (request) => - requireAbsolute("worktreePath", request.worktreePath).pipe( - Effect.flatMap((cwd) => - input.checkpoints.hasCheckpointRef({ - cwd, - checkpointRef: request.checkpointRef, - }), - ), - ), + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireSafeRef("checkpointRef", request.checkpointRef); + return yield* 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 })), - ), + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* requireSafeRef("checkpointRef", request.checkpointRef); + const restored = yield* input.checkpoints.restoreCheckpoint({ + cwd, + checkpointRef: request.checkpointRef, + fallbackToHead: request.fallbackToHead ?? false, + }); + return { restored }; + }), deleteCheckpoints: (request) => - requireAbsolute("worktreePath", request.worktreePath).pipe( - Effect.flatMap((cwd) => - input.checkpoints.deleteCheckpointRefs({ - cwd, - checkpointRefs: request.checkpointRefs, - }), - ), - ), + Effect.gen(function* () { + const cwd = yield* requireGrantedRoot("worktreePath", request.worktreePath); + yield* Effect.forEach(request.checkpointRefs, (checkpointRef) => + requireSafeRef("checkpointRefs", checkpointRef), + ); + return yield* input.checkpoints.deleteCheckpointRefs({ + cwd, + checkpointRefs: request.checkpointRefs, + }); + }), }; } diff --git a/apps/server/src/plugins/guardedOutboundHttpGet.test.ts b/apps/server/src/plugins/guardedOutboundHttpGet.test.ts new file mode 100644 index 00000000000..daa9ed90915 --- /dev/null +++ b/apps/server/src/plugins/guardedOutboundHttpGet.test.ts @@ -0,0 +1,192 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import type { + PluginHttpClientTransport, + PluginPinnedHttpRequest, +} from "./capabilities/HttpClientCapability.ts"; +import { guardedOutboundHttpGet } from "./guardedOutboundHttpGet.ts"; +import { OutboundUrlError, type UrlValidatorDeps } from "./OutboundUrlValidator.ts"; + +const lookupFor = + (hosts: Record): UrlValidatorDeps["lookup"] => + (host) => { + const address = hosts[host]; + return address === undefined + ? Effect.fail(new OutboundUrlError({ reason: `unexpected lookup ${host}` })) + : Effect.succeed([{ address, family: 4 as const }]); + }; + +const transportFor = (input: { + readonly responses: Record Response>; + readonly requests?: Array; +}): PluginHttpClientTransport => { + return (request) => { + input.requests?.push(request); + const url = request.url.toString(); + const respond = input.responses[url] ?? (() => new Response("", { status: 404 })); + return Effect.succeed(HttpClientResponse.fromWeb(HttpClientRequest.get(url), respond())); + }; +}; + +it.effect("guardedOutboundHttpGet pins requests to the resolved address", () => + Effect.gen(function* () { + const requests: Array = []; + const response = yield* guardedOutboundHttpGet({ + url: "https://public.test/file", + lookup: lookupFor({ "public.test": "93.184.216.34" }), + transport: transportFor({ + responses: { "https://public.test/file": () => new Response("ok") }, + requests, + }), + timeoutMs: 1000, + }); + + assert.equal(response.status, 200); + assert.equal(requests[0]?.address.address, "93.184.216.34"); + }), +); + +it.effect("guardedOutboundHttpGet rejects URLs that resolve to blocked addresses", () => + Effect.gen(function* () { + const requests: Array = []; + const result = yield* Effect.result( + guardedOutboundHttpGet({ + url: "https://internal.test/admin", + lookup: lookupFor({ "internal.test": "169.254.169.254" }), + transport: transportFor({ responses: {}, requests }), + timeoutMs: 1000, + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure._tag, "OutboundUrlError"); + // Rejected during validation: no request may reach the transport. + assert.deepEqual(requests, []); + }), +); + +it.effect("guardedOutboundHttpGet follows redirects only through validated hosts", () => + Effect.gen(function* () { + const response = yield* guardedOutboundHttpGet({ + url: "https://public.test/file", + lookup: lookupFor({ "public.test": "93.184.216.34", "cdn.test": "203.0.114.9" }), + transport: transportFor({ + responses: { + "https://public.test/file": () => + new Response(null, { status: 302, headers: { location: "https://cdn.test/file" } }), + "https://cdn.test/file": () => new Response("real bytes"), + }, + }), + timeoutMs: 1000, + }); + + assert.equal(response.status, 200); + }), +); + +it.effect( + "guardedOutboundHttpGet rejects redirects to blocked addresses without fetching them", + () => + Effect.gen(function* () { + const requests: Array = []; + const result = yield* Effect.result( + guardedOutboundHttpGet({ + url: "https://public.test/file", + lookup: lookupFor({ "public.test": "93.184.216.34", "metadata.test": "169.254.169.254" }), + transport: transportFor({ + responses: { + "https://public.test/file": () => + new Response(null, { + status: 302, + headers: { location: "https://metadata.test/latest/meta-data" }, + }), + }, + requests, + }), + timeoutMs: 1000, + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.equal(result.failure._tag, "OutboundUrlError"); + assert.deepEqual( + requests.map((request) => request.url.hostname), + ["public.test"], + ); + }), +); + +it.effect("guardedOutboundHttpGet rejects redirects that downgrade to http", () => + Effect.gen(function* () { + const result = yield* Effect.result( + guardedOutboundHttpGet({ + url: "https://public.test/file", + lookup: lookupFor({ "public.test": "93.184.216.34", localhost: "127.0.0.1" }), + transport: transportFor({ + responses: { + "https://public.test/file": () => + new Response(null, { + status: 302, + headers: { location: "http://localhost:8080/steal" }, + }), + }, + }), + timeoutMs: 1000, + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure._tag, "OutboundUrlError"); + assert.include(result.failure.reason, "https"); + } + }), +); + +it.effect("guardedOutboundHttpGet gives up after too many redirects", () => + Effect.gen(function* () { + const result = yield* Effect.result( + guardedOutboundHttpGet({ + url: "https://public.test/loop", + lookup: lookupFor({ "public.test": "93.184.216.34" }), + transport: transportFor({ + responses: { + "https://public.test/loop": () => + new Response(null, { + status: 302, + headers: { location: "https://public.test/loop" }, + }), + }, + }), + timeoutMs: 1000, + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure._tag, "OutboundUrlError"); + assert.include(result.failure.reason, "redirects"); + } + }), +); + +it.effect("guardedOutboundHttpGet returns redirect responses without a Location as-is", () => + Effect.gen(function* () { + const response = yield* guardedOutboundHttpGet({ + url: "https://public.test/file", + lookup: lookupFor({ "public.test": "93.184.216.34" }), + transport: transportFor({ + responses: { + "https://public.test/file": () => new Response(null, { status: 302 }), + }, + }), + timeoutMs: 1000, + }); + + // The caller's status filter is responsible for rejecting it. + assert.equal(response.status, 302); + }), +); diff --git a/apps/server/src/plugins/guardedOutboundHttpGet.ts b/apps/server/src/plugins/guardedOutboundHttpGet.ts new file mode 100644 index 00000000000..d22f45d4bd2 --- /dev/null +++ b/apps/server/src/plugins/guardedOutboundHttpGet.ts @@ -0,0 +1,61 @@ +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import type { HttpClientResponse } from "effect/unstable/http"; + +import type { + HttpClientError, + PluginHttpClientTransport, +} from "./capabilities/HttpClientCapability.ts"; +import { + OutboundUrlError, + OutboundUrlValidator, + type UrlValidatorDeps, +} from "./OutboundUrlValidator.ts"; + +const MAX_REDIRECTS = 5; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +/** + * SSRF-guarded GET for host-side ingestion of untrusted marketplace/tarball + * URLs. Every hop — the initial URL AND every redirect Location — is validated + * through {@link OutboundUrlValidator} (https-only, loopback/RFC1918/link-local/ + * metadata ranges blocked) and fetched through the DNS-pinned transport, so a + * malicious-but-https URL cannot 30x-redirect the host into internal services + * and DNS rebinding cannot swap the address between check and connect. + * Redirects are never followed implicitly: the transport does not follow them, + * and this loop re-validates each Location before issuing the next request. + */ +export const guardedOutboundHttpGet = (input: { + readonly url: string; + readonly lookup: UrlValidatorDeps["lookup"]; + readonly transport: PluginHttpClientTransport; + readonly headers?: Readonly>; + readonly timeoutMs: number; +}): Effect.Effect => + Effect.gen(function* () { + let currentUrl = input.url; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const resolved = yield* OutboundUrlValidator.resolve(currentUrl, { lookup: input.lookup }); + const response = yield* input.transport({ + url: resolved.url, + method: "GET", + headers: input.headers ?? {}, + body: null, + timeoutMs: input.timeoutMs, + address: resolved.addresses[0]!, + }); + if (!REDIRECT_STATUSES.has(response.status)) return response; + const location = response.headers["location"]; + // A redirect status without a Location cannot be followed; hand it to the + // caller, whose status filter rejects it as a non-OK response. + if (location === undefined || location.length === 0) return response; + // The redirect body is unused; drain it so a pinned socket is not left + // occupied until its timeout. + yield* response.stream.pipe(Stream.runDrain, Effect.ignore); + currentUrl = yield* Effect.try({ + try: () => new URL(location, resolved.url).toString(), + catch: () => new OutboundUrlError({ reason: "Redirect Location is malformed" }), + }); + } + return yield* new OutboundUrlError({ reason: "Too many redirects" }); + }); diff --git a/apps/server/src/plugins/readHttpResponseBytesCapped.ts b/apps/server/src/plugins/readHttpResponseBytesCapped.ts index 55e1d0e4fd5..b5081c37be7 100644 --- a/apps/server/src/plugins/readHttpResponseBytesCapped.ts +++ b/apps/server/src/plugins/readHttpResponseBytesCapped.ts @@ -1,16 +1,28 @@ -import { PluginManagementError } from "@t3tools/contracts/plugin"; import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import type { HttpClientResponse } from "effect/unstable/http"; -const isPluginManagementError = Schema.is(PluginManagementError); +interface CappedReadExpectedFailure { + readonly _tag: "CappedReadExpectedFailure"; + readonly error: E; +} -export const readHttpResponseBytesCapped = (input: { +const expectedFailure = (error: E): CappedReadExpectedFailure => ({ + _tag: "CappedReadExpectedFailure", + error, +}); + +const isExpectedFailure = (cause: unknown): cause is CappedReadExpectedFailure => + typeof cause === "object" && + cause !== null && + "_tag" in cause && + (cause as { readonly _tag?: unknown })._tag === "CappedReadExpectedFailure"; + +export const readHttpResponseBytesCapped = (input: { readonly response: HttpClientResponse.HttpClientResponse; readonly maxBytes: number; - readonly tooLarge: (observedBytes: number) => PluginManagementError; - readonly readFailed: (cause: unknown) => PluginManagementError; + readonly tooLarge: (observedBytes: number) => E; + readonly readFailed: (cause: unknown) => E; }) => input.response.stream.pipe( Stream.runFoldEffect( @@ -18,7 +30,7 @@ export const readHttpResponseBytesCapped = (input: { (acc, chunk) => { const total = acc.total + chunk.byteLength; if (total > input.maxBytes) { - return Effect.fail(input.tooLarge(total)); + return Effect.fail(expectedFailure(input.tooLarge(total))); } acc.chunks.push(chunk); return Effect.succeed({ chunks: acc.chunks, total }); @@ -33,5 +45,7 @@ export const readHttpResponseBytesCapped = (input: { } return bytes; }), - Effect.mapError((cause) => (isPluginManagementError(cause) ? cause : input.readFailed(cause))), + Effect.mapError((cause) => + isExpectedFailure(cause) ? cause.error : input.readFailed(cause), + ), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 69d720ef7fd..9802497c200 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as PluginHost from "./plugins/PluginHost.ts"; +import { PluginHttpClientTransportLive } from "./plugins/capabilities/HttpClientCapability.ts"; import * as PluginHttpRegistry from "./plugins/PluginHttpRegistry.ts"; import { pluginHttpRouteLayer } from "./plugins/PluginHttpRoutes.ts"; import { pluginWebRouteLayer } from "./plugins/PluginWebRoutes.ts"; @@ -45,6 +46,7 @@ import * as PluginMigrator from "./plugins/PluginMigrator.ts"; import * as PluginModuleLoader from "./plugins/PluginModuleLoader.ts"; import * as PluginRpcDispatcher from "./plugins/PluginRpcDispatcher.ts"; import * as PluginRuntimeRegistry from "./plugins/PluginRuntimeRegistry.ts"; +import { OutboundUrlLookupLive } from "./plugins/OutboundUrlValidator.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; @@ -322,6 +324,8 @@ const PluginHostCapabilityDepsLayerLive = Layer.mergeAll( TerminalLayerLive, ServerSecretStore.layer, ServerEnvironment.layer, + OutboundUrlLookupLive, + PluginHttpClientTransportLive, ); const PluginHostLayerLive = PluginHost.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayerLive), @@ -338,8 +342,18 @@ const PluginCatalogLayerLive = PluginCatalog.layer.pipe( Layer.provideMerge(PluginLockfileStoreLayerLive), Layer.provideMerge(PluginRuntimeRegistryLayerLive), ); -const PluginMarketplaceLayerLive = PluginMarketplace.layer; +// Marketplace + installer ingest untrusted URLs (marketplace.json, tarball) +// and must fetch them through the same SSRF guard the httpClient capability +// uses: OutboundUrlLookup validation plus the DNS-pinned transport. +const PluginOutboundHttpDepsLive = Layer.mergeAll( + OutboundUrlLookupLive, + PluginHttpClientTransportLive, +); +const PluginMarketplaceLayerLive = PluginMarketplace.layer.pipe( + Layer.provide(PluginOutboundHttpDepsLive), +); const PluginInstallerLayerLive = PluginInstaller.layer.pipe( + Layer.provide(PluginOutboundHttpDepsLive), Layer.provideMerge(PluginLockfileStoreLayerLive), Layer.provideMerge(PluginMarketplaceLayerLive), Layer.provideMerge(PluginHostLayerLive), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..99510cbf0ce 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -8,10 +8,10 @@ import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; -const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(0), +const processOutput = (stdout: string, exitCode = 0, stderr = ""): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), stdout, - stderr: "", + stderr, stdoutTruncated: false, stderrTruncated: false, }); @@ -289,6 +289,405 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("creates draft pull requests with the fork gh args", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/pr", + title: "Open PR", + bodyFile: "/tmp/pr.md", + draft: true, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "create", + "--base", + "main", + "--head", + "feature/pr", + "--title", + "Open PR", + "--body-file", + "/tmp/pr.md", + "--draft", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("merges pull requests with the selected strategy flag", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ + cwd: "/repo", + number: 42, + strategy: "rebase", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "42", "--rebase"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("keeps gh merge stderr matchable through the command error cause", () => + Effect.gen(function* () { + const stderr = "Pull request is not mergeable: branch protection rules must be satisfied."; + mockRun.mockReturnValueOnce( + Effect.fail( + VcsProcessExitError.fromProcessExit( + { + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + argumentCount: 5, + }, + { exitCode: 1, stderr, stderrTruncated: false }, + "command-failed", + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .mergePullRequest({ + cwd: "/repo", + number: 42, + strategy: "squash", + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "GitHubCliCommandError"); + // In-process consumers can still classify off the raw stderr... + assert.equal((error.cause as { readonly stderr?: string }).stderr, stderr); + assert.notInclude(error.message, "branch protection"); + // ...but the stderr never reaches serialized output. + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.notInclude(JSON.stringify(error.cause), "branch protection"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("parses pull request detail output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + state: "MERGED", + mergedAt: "2026-07-03T12:00:00Z", + reviewDecision: "APPROVED", + headRefOid: "abc123", + url: "https://github.com/o/r/pull/42", + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const detail = yield* gh.getPullRequestDetail({ cwd: "/repo", number: 42 }); + + assert.deepStrictEqual(detail, { + state: "MERGED", + mergedAt: "2026-07-03T12:00:00Z", + reviewDecision: "APPROVED", + headRefOid: "abc123", + url: "https://github.com/o/r/pull/42", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "view", "42", "--json", "state,mergedAt,reviewDecision,headRefOid,url"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("parses pull request checks while tolerating gh pending exit code", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + name: "test", + state: "PENDING", + bucket: "pending", + link: "https://github.com/o/r/actions/runs/1", + }, + { + name: null, + state: null, + bucket: null, + link: null, + }, + ]), + 8, + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const checks = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 42 }); + + assert.deepStrictEqual(checks, [ + { + name: "test", + state: "PENDING", + bucket: "pending", + link: "https://github.com/o/r/actions/runs/1", + }, + { + name: "", + state: "", + bucket: "", + link: "", + }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "checks", "42", "--json", "name,state,bucket,link"], + cwd: "/repo", + timeoutMs: 30_000, + allowNonZeroExit: true, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("rejects unexpected gh pr checks exit codes", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]", 2, "bad exit"))); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 42 }).pipe(Effect.flip); + + assert.equal(error._tag, "GitHubCliCommandError"); + const cause = error.cause as VcsProcessExitError; + assert.equal(cause._tag, "VcsProcessExitError"); + assert.equal(cause.exitCode, 2); + // Raw stderr is server-side only: readable in-process, never serialized. + assert.equal(cause.stderr, "bad exit"); + assert.notInclude(cause.message, "bad exit"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.notInclude(JSON.stringify(cause), "bad exit"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("fails loudly when gh pr checks exits 1 with only stderr (error, not JSON)", () => + Effect.gen(function* () { + // Auth failure / missing PR / API error: exit 1, empty stdout, stderr + // carries the error text. This must NOT read as "no checks". + mockRun.mockReturnValueOnce( + Effect.succeed(processOutput("", 1, "HTTP 401: Bad credentials (https://api.github.com)")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 42 }).pipe(Effect.flip); + + assert.equal(error._tag, "GitHubCliCommandError"); + const cause = error.cause as VcsProcessExitError; + assert.equal(cause.exitCode, 1); + assert.include(cause.stderr ?? "", "Bad credentials"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.notInclude(JSON.stringify(error), "Bad credentials"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("treats the documented gh 'no checks reported' exit-1 case as an empty list", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed(processOutput("", 1, "no checks reported on the 'feature/x' branch")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const checks = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 42 }); + + assert.deepStrictEqual(checks, []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("parses gh pr checks JSON on exit 1 when some checks are failing", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + name: "test", + state: "FAILURE", + bucket: "fail", + link: "https://github.com/o/r/actions/runs/2", + }, + ]), + 1, + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const checks = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 42 }); + + assert.deepStrictEqual(checks, [ + { + name: "test", + state: "FAILURE", + bucket: "fail", + link: "https://github.com/o/r/actions/runs/2", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("parses pull request review output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + reviews: [ + { + id: "R_1", + author: { login: "octocat" }, + state: "APPROVED", + body: "ship it", + submittedAt: "2026-07-03T12:00:00Z", + }, + { + id: null, + author: null, + state: null, + body: null, + submittedAt: null, + }, + ], + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const reviews = yield* gh.listPullRequestReviews({ cwd: "/repo", number: 42 }); + + assert.deepStrictEqual(reviews, [ + { + id: "R_1", + author: "octocat", + state: "APPROVED", + body: "ship it", + submittedAt: "2026-07-03T12:00:00Z", + }, + { + id: "", + author: "", + state: "", + body: "", + submittedAt: "", + }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "view", "42", "--json", "reviews"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("parses pull request review comments output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + id: 123, + user: { login: "octocat" }, + body: "please fix", + path: "src/file.ts", + created_at: "2026-07-03T12:00:00Z", + }, + { + id: 124, + user: null, + body: null, + path: null, + created_at: null, + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const comments = yield* gh.listPullRequestReviewComments({ + cwd: "/repo", + repo: "o/r", + number: 42, + }); + + assert.deepStrictEqual(comments, [ + { + id: 123, + user: "octocat", + body: "please fix", + path: "src/file.ts", + createdAt: "2026-07-03T12:00:00Z", + }, + { + id: 124, + user: "", + body: "", + path: null, + createdAt: "", + }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["api", "repos/o/r/pulls/42/comments"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("rejects a malformed repo identifier before it reaches the gh api path", () => + Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + + for (const repo of ["o/r/extra", "o", "../o/r", "o/r?per_page=100", "o r/name", ""]) { + const error = yield* gh + .listPullRequestReviewComments({ cwd: "/repo", repo, number: 42 }) + .pipe(Effect.flip); + assert.equal(error._tag, "GitHubCliCommandError"); + } + expect(mockRun).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layer)), + ); + it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..c794adbccb5 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -7,6 +7,7 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString, + VcsProcessExitError, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; @@ -19,6 +20,18 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; +// `owner/name` as accepted by the GitHub REST path `repos/{owner}/{name}`: +// owner is alphanumeric-with-inner-hyphens, name allows dots/underscores/ +// hyphens. Validated before interpolation into a `gh api` path so a +// caller-supplied repo string cannot smuggle extra path segments or query +// syntax into the request. +const GITHUB_REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[A-Za-z0-9._-]+$/; + +// `gh pr checks` reports "no checks" as exit 1 with this message on stderr +// (and nothing on stdout). Match it so a genuine empty check list is not +// misread as a command failure — and vice versa. +const GH_NO_CHECKS_STDERR_PATTERN = /no checks reported/i; + const gitHubCliFailureFields = { command: Schema.Literal("gh"), cwd: Schema.String, @@ -135,6 +148,58 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestDetailDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request detail JSON."; + } + + override get message(): string { + return `GitHub CLI failed in getPullRequestDetail: ${this.detail}`; + } +} + +export class GitHubPullRequestChecksDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestChecksDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request checks JSON."; + } + + override get message(): string { + return `GitHub CLI failed in listPullRequestChecks: ${this.detail}`; + } +} + +export class GitHubPullRequestReviewsDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestReviewsDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request reviews JSON."; + } + + override get message(): string { + return `GitHub CLI failed in listPullRequestReviews: ${this.detail}`; + } +} + +export class GitHubPullRequestReviewCommentsDecodeError extends Schema.TaggedErrorClass()( + "GitHubPullRequestReviewCommentsDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request review comments JSON."; + } + + override get message(): string { + return `GitHub CLI failed in listPullRequestReviewComments: ${this.detail}`; + } +} + export const GitHubCliError = Schema.Union([ GitHubCliUnavailableError, GitHubCliAuthenticationError, @@ -144,6 +209,10 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubPullRequestDetailDecodeError, + GitHubPullRequestChecksDecodeError, + GitHubPullRequestReviewsDecodeError, + GitHubPullRequestReviewCommentsDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -196,6 +265,39 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } +export type GitHubMergeStrategy = "squash" | "merge" | "rebase"; + +export interface GitHubPullRequestDetail { + readonly state: string; + readonly mergedAt: string | null; + readonly reviewDecision: string | null; + readonly headRefOid: string; + readonly url: string; +} + +export interface GitHubPullRequestCheck { + readonly name: string; + readonly state: string; + readonly bucket: string; + readonly link: string; +} + +export interface GitHubPullRequestReview { + readonly id: string; + readonly author: string; + readonly state: string; + readonly body: string; + readonly submittedAt: string; +} + +export interface GitHubPullRequestReviewComment { + readonly id: number; + readonly user: string; + readonly body: string; + readonly path: string | null; + readonly createdAt: string; +} + export class GitHubCli extends Context.Service< GitHubCli, { @@ -233,8 +335,36 @@ export class GitHubCli extends Context.Service< readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly draft?: boolean; + }) => Effect.Effect; + + readonly mergePullRequest: (input: { + readonly cwd: string; + readonly number: number; + readonly strategy: GitHubMergeStrategy; }) => Effect.Effect; + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect; + + readonly listPullRequestChecks: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + + readonly listPullRequestReviews: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + + readonly listPullRequestReviewComments: (input: { + readonly cwd: string; + readonly repo: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + readonly getDefaultBranch: (input: { readonly cwd: string; }) => Effect.Effect; @@ -256,6 +386,59 @@ const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), ); +const RawGitHubPullRequestDetailSchema = Schema.Struct({ + state: Schema.String, + mergedAt: Schema.NullOr(Schema.String), + reviewDecision: Schema.NullOr(Schema.String), + headRefOid: Schema.String, + url: Schema.String, +}); +const decodeRawGitHubPullRequestDetail = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubPullRequestDetailSchema), +); + +const RawGitHubPullRequestCheckSchema = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + bucket: Schema.optional(Schema.NullOr(Schema.String)), + link: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawGitHubPullRequestChecksSchema = Schema.Array(RawGitHubPullRequestCheckSchema); +const decodeRawGitHubPullRequestChecks = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubPullRequestChecksSchema), +); + +const RawGitHubPullRequestReviewsSchema = Schema.Struct({ + reviews: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.String) })), + ), + state: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); +const decodeRawGitHubPullRequestReviews = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubPullRequestReviewsSchema), +); + +const RawGitHubPullRequestReviewCommentsSchema = Schema.Array( + Schema.Struct({ + id: Schema.Number, + user: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.String) }))), + body: Schema.optional(Schema.NullOr(Schema.String)), + path: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.optional(Schema.NullOr(Schema.String)), + }), +); +const decodeRawGitHubPullRequestReviewComments = Schema.decodeEffect( + Schema.fromJsonString(RawGitHubPullRequestReviewCommentsSchema), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -433,8 +616,195 @@ export const make = Effect.gen(function* () { input.title, "--body-file", input.bodyFile, + ...(input.draft ? ["--draft"] : []), ], }).pipe(Effect.asVoid), + mergePullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "merge", + String(input.number), + input.strategy === "merge" + ? "--merge" + : input.strategy === "rebase" + ? "--rebase" + : "--squash", + ], + }).pipe(Effect.asVoid), + getPullRequestDetail: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + "--json", + "state,mergedAt,reviewDecision,headRefOid,url", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeRawGitHubPullRequestDetail(raw).pipe( + Effect.mapError( + (cause) => + new GitHubPullRequestDetailDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ), + ), + Effect.map((raw) => ({ + state: raw.state, + mergedAt: raw.mergedAt, + reviewDecision: raw.reviewDecision, + headRefOid: raw.headRefOid, + url: raw.url, + })), + ), + listPullRequestChecks: (input) => { + const args = ["pr", "checks", String(input.number), "--json", "name,state,bucket,link"]; + // Build a failure whose raw stderr stays a server-side-only property + // (VcsProcessExitError keeps it off the wire) with a stable message. + const checksFailure = (result: VcsProcess.VcsProcessOutput) => + fromVcsError( + { command: "gh", cwd: input.cwd }, + VcsProcessExitError.fromProcessExit( + { + operation: "GitHubCli.listPullRequestChecks", + command: "gh", + cwd: input.cwd, + argumentCount: args.length, + }, + { + exitCode: result.exitCode as number, + stderr: result.stderr, + stderrTruncated: result.stderrTruncated, + }, + "command-failed", + ), + ); + // `gh pr checks` exit codes: 0 = all passing, 8 = checks pending, and + // 1 for THREE distinct situations: some checks failing (stdout still + // carries valid JSON), no checks configured ("no checks reported…" on + // stderr, empty stdout), or a real error (auth failure, missing PR, + // API error — empty stdout, other stderr). Tolerate the first two; + // fail loudly on the error case so downstream merge-readiness logic + // never mistakes a failure for "no checks". + return process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args, + cwd: input.cwd, + timeoutMs: DEFAULT_TIMEOUT_MS, + allowNonZeroExit: true, + }) + .pipe( + Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error)), + Effect.flatMap( + (result): Effect.Effect, GitHubCliError> => { + const exitCode = result.exitCode as number; + if (exitCode !== 0 && exitCode !== 1 && exitCode !== 8) { + return Effect.fail(checksFailure(result)); + } + const raw = result.stdout.trim(); + if (raw.length === 0) { + if (exitCode === 1 && !GH_NO_CHECKS_STDERR_PATTERN.test(result.stderr)) { + return Effect.fail(checksFailure(result)); + } + return Effect.succeed([] as ReadonlyArray); + } + return decodeRawGitHubPullRequestChecks(raw).pipe( + Effect.mapError( + (cause) => + new GitHubPullRequestChecksDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + Effect.map((checks) => + checks.map((check) => ({ + name: check.name ?? "", + state: check.state ?? "", + bucket: check.bucket ?? "", + link: check.link ?? "", + })), + ), + ); + }, + ), + ); + }, + listPullRequestReviews: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "view", String(input.number), "--json", "reviews"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeRawGitHubPullRequestReviews(raw).pipe( + Effect.mapError( + (cause) => + new GitHubPullRequestReviewsDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ), + ), + Effect.map((decoded) => + decoded.reviews.map((review) => ({ + id: review.id ?? "", + author: review.author?.login ?? "", + state: review.state ?? "", + body: review.body ?? "", + submittedAt: review.submittedAt ?? "", + })), + ), + ), + listPullRequestReviewComments: (input) => + Effect.gen(function* () { + if (!GITHUB_REPO_PATTERN.test(input.repo)) { + return yield* new GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error("Invalid repository identifier; expected owner/name."), + }); + } + return yield* execute({ + cwd: input.cwd, + args: ["api", `repos/${input.repo}/pulls/${input.number}/comments`], + }); + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeRawGitHubPullRequestReviewComments(raw).pipe( + Effect.mapError( + (cause) => + new GitHubPullRequestReviewCommentsDecodeError({ + command: "gh", + cwd: input.cwd, + cause, + }), + ), + ), + ), + Effect.map((decoded) => + decoded.map((comment) => ({ + id: comment.id, + user: comment.user?.login ?? "", + body: comment.body ?? "", + path: comment.path ?? null, + createdAt: comment.created_at ?? "", + })), + ), + ), getDefaultBranch: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/textGeneration/BoardProposalNoTool.test.ts b/apps/server/src/textGeneration/BoardProposalNoTool.test.ts new file mode 100644 index 00000000000..8a320e87697 --- /dev/null +++ b/apps/server/src/textGeneration/BoardProposalNoTool.test.ts @@ -0,0 +1,155 @@ +/** + * Architectural-safety tests for the no-tool `generateBoardProposal` op. + * + * The self-improving-boards meta-agent MUST run with tools/filesystem denied so + * it physically cannot write a board definition (only the human-gated + * `saveBoardDefinition` applies a proposal). These tests assert the no-tool + * guarantee at the layer that BUILDS the provider invocation, plus that the + * supported providers return a structured `{ proposedDefinition, rationale }`. + */ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildClaudeProposalArgs } from "./ClaudeTextGeneration.ts"; +import { buildCodexExecArgs } from "./CodexTextGeneration.ts"; +import { buildBoardProposalPrompt } from "./TextGenerationPrompts.ts"; +import { toJsonSchemaObject } from "./TextGenerationUtils.ts"; + +describe("buildCodexExecArgs (Codex no-tool guarantee)", () => { + const base = { + model: "gpt-5.5", + reasoningEffort: "high", + schemaPath: "/tmp/schema.json", + outputPath: "/tmp/out.txt", + } as const; + + it("board-proposal posture ignores the user config and disables shell + web tools", () => { + const args = buildCodexExecArgs({ ...base, noToolPosture: true }); + // `--ignore-user-config` is the Codex analog of Claude's strict-mcp + // suppression: it stops config-driven MCP servers/developer_instructions + // from loading. Auth still uses CODEX_HOME; model + effort are passed + // explicitly on the CLI, so they survive. (Skills and hooks.json load + // from CODEX_HOME independently of config.toml — user-authored local + // config, accepted under full trust.) + expect(args).toContain("--ignore-user-config"); + // `-s read-only` alone still permits command execution with whole-disk + // READS (it blocks writes and network); disabling the shell tool closes + // the prompt-injection read-to-rationale exfil channel — the Codex analog + // of Claude's `--tools ""`. + const disableIndex = args.indexOf("--disable"); + expect(disableIndex).toBeGreaterThanOrEqual(0); + expect(args[disableIndex + 1]).toBe("shell_tool"); + // Provider-side web browsing (`web.run`) is direct network egress that + // the sandbox does not cover; it must be off too. + expect(args.join(" ")).toContain("--config tools.web_search=false"); + // Still sandboxed read-only and pointed at the explicit model/effort. + const sandboxIndex = args.indexOf("-s"); + expect(args[sandboxIndex + 1]).toBe("read-only"); + expect(args).toContain("--skip-git-repo-check"); + const modelIndex = args.indexOf("--model"); + expect(args[modelIndex + 1]).toBe("gpt-5.5"); + }); + + it("git-op posture keeps the user config and tools (unchanged behavior)", () => { + const args = buildCodexExecArgs(base); + expect(args).not.toContain("--ignore-user-config"); + expect(args).not.toContain("--disable"); + expect(args.join(" ")).not.toContain("tools.web_search=false"); + // The read-only sandbox stays on in both postures. + const sandboxIndex = args.indexOf("-s"); + expect(args[sandboxIndex + 1]).toBe("read-only"); + }); +}); + +describe("buildBoardProposalPrompt output schema (provider structured-output validity)", () => { + // Regression: OpenAI/Codex `text.format.schema` rejects any property that lacks + // a `type` key with `invalid_json_schema`. `Schema.Unknown` emitted `{}` (no + // type) for `proposedDefinition`, 400-ing every Codex board proposal. Every + // property in the wire schema MUST declare a `type`. + const { outputSchema } = buildBoardProposalPrompt({ prompt: "x" }); + const wire = toJsonSchemaObject(outputSchema) as { + readonly properties: Record; + }; + + it("gives every top-level property a `type` key", () => { + for (const [key, sub] of Object.entries(wire.properties)) { + expect(sub.type, `property "${key}" must declare a type`).toBeTypeOf("string"); + } + }); + + it("models proposedDefinition as a JSON string that decodes back to an object", () => { + expect(wire.properties.proposedDefinition?.type).toBe("string"); + // The provider returns the whole response as a JSON string; proposedDefinition + // is itself a JSON-encoded string that must decode into the definition object. + const decode = Schema.decodeUnknownSync(Schema.fromJsonString(outputSchema)); + const decoded = decode( + JSON.stringify({ + proposedDefinition: JSON.stringify({ name: "X", lanes: [{ key: "a" }] }), + rationale: "because", + }), + ) as { proposedDefinition: unknown; rationale: string }; + expect(decoded.proposedDefinition).toEqual({ name: "X", lanes: [{ key: "a" }] }); + expect(decoded.rationale).toBe("because"); + }); +}); + +describe("buildClaudeProposalArgs (Claude no-tool guarantee)", () => { + const base = { + jsonSchemaStr: '{"type":"object"}', + model: "claude-opus-4-6", + cliEffort: undefined, + settingsJson: undefined, + } as const; + + it("no-tool posture loads ZERO tools (no built-ins AND no MCP) and never skips permissions", () => { + const args = buildClaudeProposalArgs({ ...base, posture: "no-tool" }); + + // --tools "" disables every BUILT-IN tool (see `claude --help`: `--tools` + // affects "the built-in set" only). + const toolsIndex = args.indexOf("--tools"); + expect(toolsIndex).toBeGreaterThanOrEqual(0); + expect(args[toolsIndex + 1]).toBe(""); + + // --strict-mcp-config + --mcp-config "{}" suppress ALL MCP-server tools + // (which --tools "" does NOT cover) regardless of the machine's config. + expect(args).toContain("--strict-mcp-config"); + const mcpIndex = args.indexOf("--mcp-config"); + expect(mcpIndex).toBeGreaterThanOrEqual(0); + expect(args[mcpIndex + 1]).toBe("{}"); + + // The dangerous tool-granting flag MUST be absent. + expect(args).not.toContain("--dangerously-skip-permissions"); + + // Variadic-safety: `--tools ""` must be the LAST pair so no later flag is + // swallowed by its empty value. `--strict-mcp-config` (boolean) precedes + // `--mcp-config "{}"` which precedes `--tools ""`. + expect(args[args.length - 2]).toBe("--tools"); + expect(args[args.length - 1]).toBe(""); + expect(mcpIndex).toBeLessThan(toolsIndex); + expect(args.indexOf("--strict-mcp-config")).toBeLessThan(mcpIndex); + }); + + it("skip-permissions posture grants tools (the existing git-op behavior)", () => { + const args = buildClaudeProposalArgs({ ...base, posture: "skip-permissions" }); + expect(args).toContain("--dangerously-skip-permissions"); + expect(args).not.toContain("--tools"); + expect(args).not.toContain("--strict-mcp-config"); + expect(args).not.toContain("--mcp-config"); + }); + + it("honors model and effort/settings per call", () => { + const args = buildClaudeProposalArgs({ + jsonSchemaStr: '{"type":"object"}', + model: "claude-sonnet-4-6", + cliEffort: "high", + settingsJson: '{"alwaysThinkingEnabled":true}', + posture: "no-tool", + }); + const modelIndex = args.indexOf("--model"); + expect(args[modelIndex + 1]).toBe("claude-sonnet-4-6"); + const effortIndex = args.indexOf("--effort"); + expect(args[effortIndex + 1]).toBe("high"); + const settingsIndex = args.indexOf("--settings"); + expect(args[settingsIndex + 1]).toBe('{"alwaysThinkingEnabled":true}'); + }); +}); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index c8fe4ead3be..9f34a89104e 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -318,6 +318,42 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { }), ); + it.effect("generates board proposals NO-TOOL (no built-ins, no MCP, no skip-permissions)", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + // proposedDefinition is now a JSON STRING on the wire (the provider + // schema types it as a string); the op decodes it back to an object. + proposedDefinition: JSON.stringify({ lanes: ["todo", "doing", "done"] }), + rationale: " Adds a doing lane to reduce WIP. ", + }, + }), + // SAFETY ASSERTION: the meta-agent must load ZERO tools — built-ins + // disabled (`--tools ""`) AND all MCP suppressed (`--strict-mcp-config + // --mcp-config {}`), ordered so --tools is last — and MUST NOT pass the + // tool-granting skip-permissions flag. (`$*` joins args with spaces; + // the empty `--tools` value is the trailing element.) + argsMustContain: "--strict-mcp-config --mcp-config {} --tools", + argsMustNotContain: "--dangerously-skip-permissions", + stdinMustContain: "propose an improved board definition", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBoardProposal({ + prompt: "Metrics: tickets stuck in todo. Current def: {lanes:[todo,done]}.", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.proposedDefinition).toEqual({ lanes: ["todo", "doing", "done"] }); + expect(generated.rationale).toBe("Adds a doing lane to reduce WIP."); + }), + ), + ); + it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 453bb62b728..764c9547f5f 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -8,6 +8,7 @@ * @module ClaudeTextGeneration */ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -20,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; import * as TextGeneration from "./TextGeneration.ts"; import { + buildBoardProposalPrompt, buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, @@ -47,6 +49,67 @@ import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; +/** + * Permission posture for a Claude CLI invocation. + * + * - `"skip-permissions"` passes `--dangerously-skip-permissions`, which grants + * the agent full tool/filesystem access. Used for the git text-generation ops + * where the model only emits structured JSON but historically ran with + * skip-permissions. + * - `"no-tool"` loads ZERO tools regardless of the machine's permission/settings + * /MCP config. Per `claude --help`: + * - `--tools ""` disables all tools from the BUILT-IN set ONLY. It does NOT + * affect MCP-server tools (from `~/.claude.json` etc.), which would + * otherwise stay loaded and — under an auto-approve permission mode or a + * write/bash-capable MCP server — let the agent write `.t3/boards/*.json` + * and bypass the human approval gate. + * - `--strict-mcp-config` makes Claude "Only use MCP servers from + * --mcp-config, ignoring all other MCP configurations". + * - `--mcp-config "{}"` supplies an EMPTY MCP server set. + * Together (`--strict-mcp-config --mcp-config "{}" --tools ""`) NO built-in + * tools and NO MCP tools are loaded — independent of permission mode. This is + * the architectural guarantee for the self-improving-boards meta-agent: it can + * reason and emit a proposal but physically cannot apply it. + */ +export type ClaudePermissionPosture = "skip-permissions" | "no-tool"; + +/** + * Pure builder for the Claude CLI argument vector. Extracted so the no-tool + * guarantee can be unit-asserted without spawning a process (a live MCP test + * isn't possible in CI): a `"no-tool"` posture MUST emit + * `--strict-mcp-config`, `--mcp-config "{}"`, and `--tools ""`, and MUST NOT + * emit `--dangerously-skip-permissions`. + * + * NOTE on ordering: `--tools` and `--mcp-config` are variadic, so any flag + * placed AFTER them could be swallowed as a value. The no-tool flags are + * emitted LAST, with `--tools ""` the very last pair (only the stdin-fed prompt + * follows). `--strict-mcp-config` is a boolean flag (takes no value) so it is + * safe to place before `--mcp-config "{}"`. + */ +export const buildClaudeProposalArgs = (input: { + readonly jsonSchemaStr: string; + readonly model: string; + readonly cliEffort: string | undefined; + readonly settingsJson: string | undefined; + readonly posture: ClaudePermissionPosture; +}): ReadonlyArray => [ + "-p", + "--output-format", + "json", + "--json-schema", + input.jsonSchemaStr, + "--model", + input.model, + ...(input.cliEffort ? ["--effort", input.cliEffort] : []), + ...(input.settingsJson ? ["--settings", input.settingsJson] : []), + // SAFETY: the posture decides tool access. `"no-tool"` loads zero tools + // (no built-ins via `--tools ""`, no MCP via `--strict-mcp-config` + + // `--mcp-config "{}"`); `"skip-permissions"` grants full access. + ...(input.posture === "no-tool" + ? ["--strict-mcp-config", "--mcp-config", "{}", "--tools", ""] + : ["--dangerously-skip-permissions"]), +]; + /** * Schema for the wrapper JSON returned by `claude -p --output-format json`. * We only care about `structured_output`. @@ -64,6 +127,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const fileSystem = yield* FileSystem.FileSystem; const readStreamAsString = ( operation: string, @@ -85,7 +149,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateBoardProposal", value: unknown, detail: string, ): Effect.Effect => @@ -110,16 +175,24 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu prompt, outputSchemaJson, modelSelection, + posture = "skip-permissions", }: { operation: | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; cwd: string; prompt: string; outputSchemaJson: S; modelSelection: ModelSelection; + /** + * Permission posture. Defaults to `"skip-permissions"` (the existing git + * ops). The board-proposal op passes `"no-tool"` so the meta-agent cannot + * use any tools. + */ + posture?: ClaudePermissionPosture; }): Effect.fn.Return { const jsonSchemaStr = yield* encodeJsonForOperation( operation, @@ -160,16 +233,13 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ - "-p", - "--output-format", - "json", - "--json-schema", - jsonSchemaStr, - "--model", - resolveClaudeApiModelId(modelSelection), - ...(cliEffort ? ["--effort", cliEffort] : []), - ...(settingsJson ? ["--settings", settingsJson] : []), - "--dangerously-skip-permissions", + ...buildClaudeProposalArgs({ + jsonSchemaStr, + model: resolveClaudeApiModelId(modelSelection), + cliEffort, + settingsJson, + posture, + }), ], { env: claudeEnvironment }, ); @@ -355,10 +425,51 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }; }); + const generateBoardProposal: TextGeneration.TextGeneration["Service"]["generateBoardProposal"] = + Effect.fn("ClaudeTextGeneration.generateBoardProposal")(function* (input) { + const { prompt, outputSchema } = buildBoardProposalPrompt({ prompt: input.prompt }); + + // SAFETY (defense-in-depth): run the no-tool op in a throwaway temp dir + // rather than the repo root, which holds `.t3/boards/*.json`. The no-tool + // posture already loads zero tools, but pointing cwd away from the board + // files shrinks the blast radius if anything ever slips. The scoped temp + // dir is removed when the effect completes. + const generated = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3code-board-proposal-" }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Failed to create sandbox working directory for board proposal.", + cause, + }), + ), + Effect.flatMap((sandboxCwd) => + runClaudeJson({ + operation: "generateBoardProposal", + cwd: sandboxCwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + // SAFETY: no-tool posture — the meta-agent cannot write a board def. + posture: "no-tool", + }), + ), + Effect.scoped, + ); + + return { + proposedDefinition: generated.proposedDefinition, + rationale: generated.rationale.trim(), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 24054a95870..a21884987be 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -37,6 +37,8 @@ function makeFakeCodexBinary( forbidReasoningEffort?: boolean; stdinMustContain?: string; stdinMustNotContain?: string; + /** If provided, the binary writes $PWD to this file so tests can assert cwd. */ + cwdRecordPath?: string; }, ) { return Effect.gen(function* () { @@ -148,6 +150,12 @@ function makeFakeCodexBinary( input.output, "__T3CODE_FAKE_CODEX_OUTPUT__", "fi", + ...(input.cwdRecordPath !== undefined + ? [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + `printf "%s" "$PWD" > ${JSON.stringify(input.cwdRecordPath)}`, + ] + : []), `exit ${input.exitCode ?? 0}`, "", ].join("\n"), @@ -168,6 +176,8 @@ function withFakeCodexEnv( forbidReasoningEffort?: boolean; stdinMustContain?: string; stdinMustNotContain?: string; + /** If provided, the binary writes $PWD to this file so tests can assert cwd. */ + cwdRecordPath?: string; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -602,4 +612,82 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { }), ), ); + + // ── Prompt-only egress: generateBoardProposal cwd isolation ────────────── + + it.effect("generateBoardProposal runs codex from an empty temp dir (not the repo cwd)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Create a named file that the binary will write $PWD into. + const cwdRecord = yield* fs.makeTempFileScoped({ + prefix: "t3code-codex-cwd-record-", + }); + + yield* withFakeCodexEnv( + { + // @effect-diagnostics-next-line preferSchemaOverJson:off + output: JSON.stringify({ + // proposedDefinition is a JSON STRING on the wire (provider schema + // types it as a string); the op decodes it back to an object. + // @effect-diagnostics-next-line preferSchemaOverJson:off + proposedDefinition: JSON.stringify({ + lanes: [], + name: "Test Board", + description: "", + triggers: [], + }), + rationale: "test rationale", + }), + cwdRecordPath: cwdRecord, + }, + (textGeneration) => + textGeneration.generateBoardProposal({ + prompt: "Create a simple kanban board.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ); + + const recordedCwd = yield* fs.readFileString(cwdRecord); + // Must NOT be the repo root. + expect(recordedCwd).not.toBe(process.cwd()); + // Must be inside the OS temp dir hierarchy. + const osTmp = path.dirname(yield* fs.realPath(cwdRecord)); + // The recorded cwd is inside a freshly-created temp dir — it must share + // a common ancestor with the OS temp directory. + expect(recordedCwd).toContain("t3code-board-proposal-"); + }).pipe(Effect.scoped), + ); + + it.effect( + "generateCommitMessage (git op) keeps the caller-supplied repo cwd, not a temp dir", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwdRecord = yield* fs.makeTempFileScoped({ + prefix: "t3code-codex-cwd-record-", + }); + const repoCwd = process.cwd(); + + yield* withFakeCodexEnv( + { + // @effect-diagnostics-next-line preferSchemaOverJson:off + output: JSON.stringify({ subject: "Add important change", body: "" }), + cwdRecordPath: cwdRecord, + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: repoCwd, + branch: "feature/cwd-check", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ); + + const recordedCwd = yield* fs.readFileString(cwdRecord); + // Git ops must use the repo cwd passed by the caller. + expect(recordedCwd).toBe(repoCwd); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0e68994fd3d..3d77d88906e 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -17,6 +17,7 @@ import { expandHomePath } from "../pathExpansion.ts"; import { TextGenerationError } from "@t3tools/contracts"; import * as TextGeneration from "./TextGeneration.ts"; import { + buildBoardProposalPrompt, buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, @@ -35,6 +36,71 @@ import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); + +/** + * Build the `codex exec` argv for a structured-output text-generation run. + * + * `noToolPosture` is the board-proposal hardening (the analog of the Claude + * path's `--strict-mcp-config --mcp-config "{}" --tools ""` suppression). + * Empirically verified against codex-cli 0.142.2: + * + * - `--ignore-user-config`: don't load `$CODEX_HOME/config.toml` (configured + * MCP servers, `developer_instructions`, ...). Auth still uses + * `CODEX_HOME`, and model/reasoning-effort/service-tier are passed + * explicitly so they survive the dropped config. NOTE: skills and + * `hooks.json` load from `$CODEX_HOME` independently of `config.toml`, so + * this flag does NOT suppress them — both are user-authored local config + * (full-trust), not reachable from ticket-authored prompt text. + * - `--disable shell_tool`: removes the command-execution tool entirely. + * `-s read-only` alone is NOT sufficient: its sandbox still lets commands + * run and READ the whole disk (verified: `cat /etc/hosts` succeeds from an + * unrelated cwd; writes and network are what it blocks). Since the prompt + * embeds ticket-authored text, a prompt-injected model could read a host + * secret and surface it in the returned `rationale`. No shell tool closes + * that read-to-rationale exfil channel. + * - `tools.web_search=false`: removes the provider-side `web.run` tool — the + * one remaining direct network egress (the read-only sandbox blocks + * network for sandboxed commands, not provider-side browsing). + * + * Residual (accepted under full-trust): `view_image` can load a local image + * file into model context and `apply_patch` stays listed (its writes are + * rejected by the read-only sandbox); neither provides general text reads. + * The empty temp cwd (see `generateBoardProposal`) and `-s read-only` remain + * as second layers. Git ops keep the user config and shell tool (they are + * not no-tool). + */ +export function buildCodexExecArgs(input: { + readonly model: string; + readonly reasoningEffort: string; + readonly serviceTier?: string | undefined; + readonly schemaPath: string; + readonly outputPath: string; + readonly imagePaths?: ReadonlyArray; + readonly noToolPosture?: boolean; +}): Array { + return [ + "exec", + "--ephemeral", + "--skip-git-repo-check", + ...(input.noToolPosture + ? ["--ignore-user-config", "--disable", "shell_tool", "--config", "tools.web_search=false"] + : []), + "-s", + "read-only", + "--model", + input.model, + "--config", + `model_reasoning_effort="${input.reasoningEffort}"`, + ...(input.serviceTier ? ["--config", `service_tier="${input.serviceTier}"`] : []), + "--output-schema", + input.schemaPath, + "--output-last-message", + input.outputPath, + ...(input.imagePaths ?? []).flatMap((imagePath) => ["--image", imagePath]), + "-", + ]; +} + /** * Build a Codex text-generation closure bound to a specific `CodexSettings` * payload. See `makeCodexAdapter` for the overall per-instance rationale. @@ -97,7 +163,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateBoardProposal", value: unknown, ): Effect.Effect => encodeJsonString(value).pipe( @@ -116,7 +183,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateBoardProposal", attachments: TextGeneration.BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { @@ -153,18 +221,24 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func imagePaths = [], cleanupPaths = [], modelSelection, + noToolPosture = false, }: { operation: | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; cwd: string; prompt: string; outputSchemaJson: S; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; modelSelection: ModelSelection; + // No-tool posture: drop $CODEX_HOME/config.toml, disable the shell tool, + // and disable provider-side web search (see buildCodexExecArgs). Only the + // board-proposal op sets this; git ops keep the user config and tools. + noToolPosture?: boolean; }): Effect.fn.Return { const schemaJson = yield* encodeJsonForOperation( operation, @@ -180,24 +254,15 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const serviceTier = getCodexServiceTierOptionValue(modelSelection); const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", - [ - "exec", - "--ephemeral", - "--skip-git-repo-check", - "-s", - "read-only", - "--model", - modelSelection.model, - "--config", - `model_reasoning_effort="${reasoningEffort}"`, - ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), - "--output-schema", + buildCodexExecArgs({ + model: modelSelection.model, + reasoningEffort, + serviceTier, schemaPath, - "--output-last-message", outputPath, - ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), - "-", - ], + imagePaths, + noToolPosture, + }), { env: resolvedEnvironment }, ); const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -395,10 +460,56 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateBoardProposal: TextGeneration.TextGeneration["Service"]["generateBoardProposal"] = + Effect.fn("CodexTextGeneration.generateBoardProposal")(function* (input) { + const { prompt, outputSchema } = buildBoardProposalPrompt({ prompt: input.prompt }); + + // SAFETY (defense-in-depth): run the board-proposal op from an empty + // throwaway temp dir rather than the repo root. Even with the shell tool + // disabled (see buildCodexExecArgs), residual tools like `view_image` + // resolve relative paths against process.cwd(); an empty temp dir keeps + // the repo out of reach entirely, making this prompt-only egress (only + // the assembled prompt leaves the machine). The scoped temp dir is + // removed when the effect completes. + // NOTE: this is ONLY for generateBoardProposal — git ops (generateCommitMessage + // etc.) must keep the repo cwd they receive via input.cwd. + const generated = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3code-board-proposal-" }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Failed to create sandbox working directory for board proposal.", + cause, + }), + ), + Effect.flatMap((sandboxCwd) => + runCodexJson({ + operation: "generateBoardProposal", + cwd: sandboxCwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + // No-tool clean room: no user config (MCP servers, + // developer_instructions), no shell tool, no web search. + noToolPosture: true, + }), + ), + Effect.scoped, + ); + + return { + proposedDefinition: generated.proposedDefinition, + rationale: generated.rationale.trim(), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts index 2dc4720dcad..644630135b7 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -273,4 +273,31 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { }), ); }); + + // ── No-tool guarantee: Cursor's "ask" mode still exposes tools behind + // permission prompts, so board proposals must be REFUSED, not run. ────────── + it.effect( + "generateBoardProposal is unsupported (no provable no-tool mode) and fails with TextGenerationError", + () => + withFakeAcpAgent( + { + // The op fails before spawning the binary; no response is consumed. + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ ignored: "no binary call expected" }), + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateBoardProposal({ + prompt: "Metrics show WIP is too high. Propose an improved board definition.", + modelSelection: createModelSelection( + ProviderInstanceId.make("cursor"), + "composer-2", + ), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/not supported for board proposals/i); + }), + ), + ); }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 3e1f4eb8bbc..13fa7e3e68b 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -253,10 +253,23 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateBoardProposal: TextGeneration.TextGeneration["Service"]["generateBoardProposal"] = + () => + // UNSUPPORTED: the Cursor ACP runtime cannot be proven no-tool (its "ask" + // mode still exposes tools behind permission prompts), so we reject board + // proposals rather than ship a tool-enabled meta-agent. + Effect.fail( + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Cursor provider not supported for board proposals (no provable no-tool mode).", + }), + ); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 85127b519b9..097453585fa 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -233,4 +233,31 @@ it.layer(GrokTextGenerationTestLayer)("GrokTextGeneration", (it) => { }), ), ); + + // ── No-tool guarantee: Grok has no provable no-tool mode, so board proposals + // must be REFUSED rather than run through a tool-capable meta-agent. ────────── + it.effect( + "generateBoardProposal is unsupported (no provable no-tool mode) and fails with TextGenerationError", + () => + withFakeAcpGrok( + { + // The op fails before spawning the binary; no response is consumed. + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ ignored: "no binary call expected" }), + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateBoardProposal({ + prompt: "Metrics show WIP is too high. Propose an improved board definition.", + modelSelection: createModelSelection( + ProviderInstanceId.make("grok"), + "grok-build", + ), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/not supported for board proposals/i); + }), + ), + ); }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1bb58216305..d9d847fa664 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -245,10 +245,22 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi } satisfies TextGeneration.ThreadTitleGenerationResult; }); + const generateBoardProposal: TextGeneration.TextGeneration["Service"]["generateBoardProposal"] = + () => + // UNSUPPORTED: the Grok ACP runtime has no provable no-tool mode, so we + // reject board proposals rather than ship a tool-enabled meta-agent. + Effect.fail( + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Grok provider not supported for board proposals (no provable no-tool mode).", + }), + ); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 558a8663b64..eb31e42524a 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -19,6 +19,10 @@ const runtimeMock = { startCalls: [] as string[], promptUrls: [] as string[], authHeaders: [] as Array, + /** The `directory` argument passed to createOpenCodeSdkClient for each call. */ + promptDirectories: [] as Array, + /** The args passed to session.create for each call (captures the `permission` posture). */ + sessionCreateArgs: [] as Array, closeCalls: [] as string[], sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, @@ -31,6 +35,8 @@ const runtimeMock = { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; this.state.authHeaders.length = 0; + this.state.promptDirectories.length = 0; + this.state.sessionCreateArgs.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; @@ -64,10 +70,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { external: Boolean(serverUrl), }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => + createOpenCodeSdkClient: ({ baseUrl, serverPassword, directory }) => ({ session: { - create: async () => { + create: async (args: unknown) => { + runtimeMock.state.sessionCreateArgs.push(args); if (runtimeMock.state.sessionCreateError !== undefined) { throw runtimeMock.state.sessionCreateError; } @@ -78,6 +85,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); + runtimeMock.state.promptDirectories.push(directory); if (runtimeMock.state.promptRequestError !== undefined) { throw runtimeMock.state.promptRequestError; } @@ -405,6 +413,115 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { }), ), ); + + // ── Prompt-only egress: generateBoardProposal cwd isolation ────────────── + + it.effect("generateBoardProposal passes an empty temp dir as directory (not the repo cwd)", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + // @effect-diagnostics-next-line preferSchemaOverJson:off + text: JSON.stringify({ + // proposedDefinition is a JSON STRING on the wire (provider + // schema types it as a string); the op decodes it to an object. + // @effect-diagnostics-next-line preferSchemaOverJson:off + proposedDefinition: JSON.stringify({ + lanes: [], + name: "Test Board", + description: "", + triggers: [], + }), + rationale: "test rationale", + }), + }, + ], + }, + }; + + yield* textGeneration.generateBoardProposal({ + prompt: "Create a simple kanban board.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.promptDirectories).toHaveLength(1); + const boardProposalDir = runtimeMock.state.promptDirectories[0]; + // Must NOT be the repo root. + expect(boardProposalDir).not.toBe(process.cwd()); + // Must be inside the OS temp dir hierarchy (carries the well-known prefix). + expect(boardProposalDir).toContain("t3code-board-proposal-"); + }), + ), + ); + + // ── No-tool guarantee: the session denies every tool permission ────────── + it.effect( + "generateBoardProposal opens a session that denies EVERY tool permission (no-tool)", + () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + // @effect-diagnostics-next-line preferSchemaOverJson:off + text: JSON.stringify({ + // @effect-diagnostics-next-line preferSchemaOverJson:off + proposedDefinition: JSON.stringify({ lanes: [], name: "X" }), + rationale: "r", + }), + }, + ], + }, + }; + + yield* textGeneration.generateBoardProposal({ + prompt: "Create a simple kanban board.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.sessionCreateArgs).toHaveLength(1); + const createArgs = runtimeMock.state.sessionCreateArgs[0] as { + readonly permission?: ReadonlyArray<{ + permission?: string; + pattern?: string; + action?: string; + }>; + }; + // The no-tool guarantee: a single deny-all rule, so the meta-agent + // cannot invoke ANY tool (built-in or MCP) — even if an external + // server had MCP servers connected. + expect(createArgs.permission).toEqual([ + { permission: "*", pattern: "*", action: "deny" }, + ]); + }), + ), + ); + + it.effect( + "generateCommitMessage (git op) passes the caller-supplied repo cwd, not a temp dir", + () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const repoCwd = process.cwd(); + yield* textGeneration.generateCommitMessage({ + cwd: repoCwd, + branch: "feature/opencode-cwd-check", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.promptDirectories).toHaveLength(1); + // Git ops must use the repo cwd passed by the caller. + expect(runtimeMock.state.promptDirectories[0]).toBe(repoCwd); + }), + ), + ); }); it.layer(OpenCodeTextGenerationExistingServerTestLayer)( diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 1f94f970692..ec6a0595648 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -19,6 +20,7 @@ import { extractJsonObject } from "@t3tools/shared/schemaJson"; import * as ServerConfig from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { + buildBoardProposalPrompt, buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, @@ -39,6 +41,7 @@ const OpenCodeTextGenerationOperation = Schema.Literals([ "generatePrContent", "generateBranchName", "generateThreadTitle", + "generateBoardProposal", ]); type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; @@ -196,6 +199,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const serverConfig = yield* ServerConfig.ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const resolvedEnvironment = environment ?? process.env; + const fileSystem = yield* FileSystem.FileSystem; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); @@ -253,7 +257,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; }) => sharedServerMutex.withPermit( Effect.gen(function* () { @@ -393,6 +398,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" try: () => client.session.create({ title: `T3 Code ${input.operation}`, + // SAFETY: deny every tool permission. This is the no-tool guarantee + // for all OpenCode text-generation ops, including generateBoardProposal. permission: [{ permission: "*", pattern: "*", action: "deny" }], }), catch: (cause) => @@ -611,10 +618,52 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; }); + const generateBoardProposal: TextGeneration.TextGeneration["Service"]["generateBoardProposal"] = + Effect.fn("OpenCodeTextGeneration.generateBoardProposal")(function* (input) { + const { prompt, outputSchema } = buildBoardProposalPrompt({ prompt: input.prompt }); + + // SAFETY (defense-in-depth): run the board-proposal op from an empty + // throwaway temp dir rather than the repo root. OpenCode already denies all + // tool permissions (`permission deny *`) so file access via tools is blocked, + // but the cwd is still passed to the SDK client as the session's `directory`. + // Pointing it to an empty temp dir ensures prompt-only egress (only the + // assembled prompt leaves the machine) and is consistent with the Claude path. + // NOTE: this is ONLY for generateBoardProposal — git ops (generateCommitMessage + // etc.) must keep the repo cwd they receive via input.cwd. + const generated = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3code-board-proposal-" }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBoardProposal", + detail: "Failed to create sandbox working directory for board proposal.", + cause, + }), + ), + Effect.flatMap((sandboxCwd) => + runOpenCodeJson({ + operation: "generateBoardProposal", + cwd: sandboxCwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }), + ), + Effect.scoped, + ); + + return { + proposedDefinition: generated.proposedDefinition, + rationale: generated.rationale.trim(), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateBoardProposal, } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 9bccb9c1fc5..be0928ef4df 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -21,6 +21,8 @@ const makeStubTextGeneration = ( generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + generateBoardProposal: () => + Effect.die("generateBoardProposal stub not configured for this test"), ...overrides, }); @@ -95,6 +97,36 @@ describe("makeTextGenerationFromRegistry", () => { }), ); + it.effect("delegates generateBoardProposal and returns the parsed proposal", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const recorded: Array<{ prompt: string; model: string }> = []; + const instance = makeStubInstance( + instanceId, + makeStubTextGeneration({ + generateBoardProposal: (input) => { + recorded.push({ prompt: input.prompt, model: input.modelSelection.model }); + return Effect.succeed({ + proposedDefinition: { lanes: ["a", "b"] }, + rationale: "because", + }); + }, + }), + ); + + const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([instance])); + + const result = yield* tg.generateBoardProposal({ + prompt: "assembled metrics + def", + modelSelection: createModelSelection(instanceId, "claude-sonnet-4-6"), + }); + + expect(result.proposedDefinition).toEqual({ lanes: ["a", "b"] }); + expect(result.rationale).toBe("because"); + expect(recorded).toEqual([{ prompt: "assembled metrics + def", model: "claude-sonnet-4-6" }]); + }), + ); + it.effect("fails with TextGenerationError when the instance is unknown", () => Effect.gen(function* () { const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([])); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..8f6568b802f 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -67,6 +67,29 @@ export interface ThreadTitleGenerationResult { title: string; } +export interface BoardProposalGenerationInput { + /** + * Fully assembled prompt (metrics + current board definition + instructions). + * The caller (the self-improving-boards meta-agent) builds this; the provider + * does NOT read any files — the underlying model invocation is no-tool / + * read-only so it physically cannot write a board definition. + */ + readonly prompt: string; + /** What model and provider to use for generation (model + effort/thinking). */ + readonly modelSelection: ModelSelection; +} + +export interface BoardProposalGenerationResult { + /** + * The proposed workflow/board definition. Returned as `unknown` here; the + * caller (Task E4) decodes it as a WorkflowDefinition. Schema-enforced via + * the provider's structured-output mechanism where supported. + */ + readonly proposedDefinition: unknown; + /** Human-readable explanation of why this proposal was made. */ + readonly rationale: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, @@ -109,6 +132,20 @@ export class TextGeneration extends Context.Service< readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; + + /** + * Generate a structured board/workflow proposal from an assembled prompt. + * + * SAFETY: this op MUST run NO-TOOL / read-only. The underlying model + * invocation has all tools/filesystem access denied so the meta-agent + * cannot itself write a board definition — only the human-gated + * `saveBoardDefinition` path applies a proposal. Providers that cannot be + * proven no-tool fail with a `TextGenerationError` ("provider not supported + * for board proposals") rather than shipping a tool-enabled meta-agent. + */ + readonly generateBoardProposal: ( + input: BoardProposalGenerationInput, + ) => Effect.Effect; } >()("t3/textGeneration/TextGeneration") {} @@ -119,7 +156,8 @@ type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateBoardProposal"; const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], @@ -159,6 +197,10 @@ export const makeTextGenerationFromRegistry = ( resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), ), + generateBoardProposal: (input) => + resolveInstance(registry, "generateBoardProposal", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateBoardProposal(input)), + ), }); export const make = Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d4..303c31b183b 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -216,3 +216,44 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Board proposal (self-improving boards meta-agent) +// --------------------------------------------------------------------------- + +export interface BoardProposalPromptInput { + /** + * Fully assembled prompt (metrics + current board definition + instructions) + * built by the caller. This builder only wraps it with the structured-output + * contract and supplies the output schema. + */ + prompt: string; +} + +export function buildBoardProposalPrompt(input: BoardProposalPromptInput) { + const prompt = [ + "You analyze workflow board metrics and propose an improved board definition.", + "Return a JSON object with keys: proposedDefinition, rationale.", + "Rules:", + // proposedDefinition is a STRING (the definition serialized with JSON.stringify), + // NOT a nested object. Provider structured-output schemas (OpenAI/Codex + // `text.format.schema`) reject a property with no concrete `type`, so the full + // recursive WorkflowDefinition cannot be expressed inline — the model returns it + // as a JSON string and the server parses it back. + "- proposedDefinition must be a STRING containing the complete workflow/board definition serialized as JSON (i.e. JSON.stringify of the definition object)", + "- rationale must concisely explain why the proposal improves the board", + "- do not attempt to apply, save, or write the definition anywhere; only return it", + "", + input.prompt, + ].join("\n"); + + const outputSchema = Schema.Struct({ + // `fromJsonString(Unknown)`: wire/JSON-schema type is `string` (valid for every + // provider's structured-output validator), and decode JSON.parses it back into + // the definition object so callers receive an object exactly as before. + proposedDefinition: Schema.fromJsonString(Schema.Unknown), + rationale: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..a5e2e7288f9 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -110,6 +110,7 @@ export interface GitCommitProgress { export interface GitCommitOptions { readonly timeoutMs?: number; + readonly noVerify?: boolean; readonly progress?: GitCommitProgress; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dc58fc2543c..778c9f1dd2f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { GitCommandError } from "@t3tools/contracts"; @@ -110,7 +111,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("does not retain git arguments or stderr in command failures", () => + it.effect("keeps capped stderr available without exposing args or stderr in messages", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const driver = yield* GitVcsDriver.GitVcsDriver; @@ -134,10 +135,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.isNumber(error.exitCode); assert.isAbove(error.stderrLength ?? 0, 0); + // Raw stderr stays readable in-process for classification... + assert.isString(error.stderr); + assert.include(error.stderr ?? "", secret); assert.notInclude(error.detail, secret); assert.notInclude(error.message, secret); assert.notProperty(error, "args"); - assert.notProperty(error, "stderr"); + // ...but must NOT reach the wire: the schema-encoded RPC payload and + // naive JSON serialization both drop the raw stderr. + const encoded = yield* Schema.encodeEffect(GitCommandError)(error); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.notInclude(JSON.stringify(encoded), secret); + assert.notProperty(encoded, "stderr"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.notInclude(JSON.stringify(error), secret); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a406cbce549..7b16e141513 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -738,13 +738,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* trace2Monitor.flush; if (!input.allowNonZeroExit && exitCode !== 0) { - return yield* new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Git command exited with a non-zero status.", - exitCode, - stdoutLength: stdout.text.length, - stderrLength: stderr.text.length, - }); + // Raw stderr rides along as a server-side-only property (never + // serialized to the RPC wire); the wire carries only its length. + return yield* GitCommandError.withStderr( + { + ...gitCommandContext(commandInput), + detail: "Git command exited with a non-zero status.", + exitCode, + stdoutLength: stdout.text.length, + }, + stderr.text, + ); } return { @@ -819,13 +823,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return Effect.succeed(result); } return Effect.fail( - new GitCommandError({ - ...gitCommandContext({ operation, cwd, args }), - detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", - ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), + GitCommandError.withStderr( + { + ...gitCommandContext({ operation, cwd, args }), + detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + }, + result.stderr, + ), ); }), ); @@ -1564,7 +1570,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - const args = ["commit", "-m", subject]; + const args = ["commit", ...(options?.noVerify ? ["--no-verify"] : []), "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 675d20cb82c..beeb883e7cd 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -4,6 +4,7 @@ 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 Schema from "effect/Schema"; import { TestClock } from "effect/testing"; import { @@ -14,6 +15,8 @@ import { import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; +const isVcsProcessExitError = Schema.is(VcsProcessExitError); + const run = (input: VcsProcess.VcsProcessInput) => Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; @@ -100,6 +103,7 @@ describe("VcsProcess.run", () => { }).pipe(Effect.flip); expect(error).toBeInstanceOf(VcsProcessExitError); + if (!isVcsProcessExitError(error)) return; expect(error).toMatchObject({ operation: "test.exit", command: "node", @@ -110,12 +114,22 @@ describe("VcsProcess.run", () => { stderrLength: secretStderr.length, stderrTruncated: false, }); + // Raw stderr stays readable in-process for classification... + expect(error.stderr).toBe(secretStderr); expect(error.message).not.toContain(secretArgument); expect(error.message).not.toContain(secretStderr); + // ...but must NOT reach the wire: neither the schema-encoded RPC + // payload nor a naive JSON serialization may carry the secret. + const encoded = yield* Schema.encodeEffect(VcsProcessExitError)(error); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.stringify(encoded)).not.toContain("super-secret-token"); + expect(encoded).not.toHaveProperty("stderr"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.stringify(error)).not.toContain("super-secret-token"); }).pipe(provideLive), ); - it.effect("classifies authentication failures without retaining stderr", () => + it.effect("classifies authentication failures with capped stderr off the message", () => Effect.gen(function* () { const secretStderr = "authentication failed for token super-secret-token"; const error = yield* run({ @@ -126,6 +140,7 @@ describe("VcsProcess.run", () => { }).pipe(Effect.flip); expect(error).toBeInstanceOf(VcsProcessExitError); + if (!isVcsProcessExitError(error)) return; expect(error).toMatchObject({ operation: "test.authentication", command: "node", @@ -135,8 +150,14 @@ describe("VcsProcess.run", () => { stderrLength: secretStderr.length, stderrTruncated: false, }); + // In-process classification keeps the raw stderr; the wire does not. + expect(error.stderr).toBe(secretStderr); expect(error.message).not.toContain(secretStderr); expect(error.message).not.toContain("super-secret-token"); + const encoded = yield* Schema.encodeEffect(VcsProcessExitError)(error); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.stringify(encoded)).not.toContain("super-secret-token"); + expect(encoded).not.toHaveProperty("stderr"); }).pipe(provideLive), ); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..df34fc4d651 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildRootGroups, buildThreadActionItems, + executeCommandPaletteActionItem, filterCommandPaletteGroups, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -163,3 +165,73 @@ describe("buildThreadActionItems", () => { expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); }); + +describe("plugin command palette group", () => { + it("omits the Plugins group when no plugin commands are registered", () => { + const actionItems = [ + { + kind: "action" as const, + value: "action:settings", + searchTerms: ["settings"], + title: "Open settings", + icon: null, + run: async () => undefined, + }, + ]; + + expect(buildRootGroups({ actionItems, recentThreadItems: [] })).toEqual( + buildRootGroups({ actionItems, recentThreadItems: [], pluginCommandItems: [] }), + ); + }); + + it("adds registered plugin commands as a searchable Plugins group", () => { + const run = vi.fn(async () => undefined); + const groups = buildRootGroups({ + actionItems: [], + recentThreadItems: [], + pluginCommandItems: [ + { + kind: "action", + value: "plugin:hello-board:refresh", + searchTerms: ["Refresh board", "hello-board", "refresh"], + title: "Refresh board", + description: "Reload plugin notes", + icon: null, + run, + }, + ], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.label).toBe("Plugins"); + const filtered = filterCommandPaletteGroups({ + activeGroups: groups, + query: "refresh", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + expect(filtered[0]?.items[0]?.value).toBe("plugin:hello-board:refresh"); + }); + + it("contains throwing command actions through the shared executor", async () => { + const onError = vi.fn(); + executeCommandPaletteActionItem( + { + kind: "action", + value: "plugin:hello-board:boom", + searchTerms: ["boom"], + title: "Boom", + icon: null, + run: async () => { + throw new Error("boom"); + }, + }, + onError, + ); + + await Promise.resolve(); + await Promise.resolve(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb1..8134fffcfd3 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -333,12 +333,16 @@ export function getCommandPaletteMode(input: { export function buildRootGroups(input: { actionItems: ReadonlyArray; + pluginCommandItems?: ReadonlyArray | undefined; recentThreadItems: ReadonlyArray; }): CommandPaletteGroup[] { const groups: CommandPaletteGroup[] = []; if (input.actionItems.length > 0) { groups.push({ value: "actions", label: "Actions", items: input.actionItems }); } + if ((input.pluginCommandItems?.length ?? 0) > 0) { + groups.push({ value: "plugins", label: "Plugins", items: input.pluginCommandItems ?? [] }); + } if (input.recentThreadItems.length > 0) { groups.push({ value: "recent-threads", @@ -349,6 +353,13 @@ export function buildRootGroups(input: { return groups; } +export function executeCommandPaletteActionItem( + item: CommandPaletteActionItem, + onError: (error: unknown) => void, +): void { + void item.run().catch(onError); +} + export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string { switch (mode) { case "root": diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..37474537bd9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -29,6 +29,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + PlugIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; @@ -96,6 +97,7 @@ import { buildProjectActionItems, buildRootGroups, buildThreadActionItems, + executeCommandPaletteActionItem, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, @@ -127,6 +129,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import { pluginUiRegistryAtom } from "../plugins/PluginUiHost"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -476,6 +479,7 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const pluginUiRegistry = useAtomValue(pluginUiRegistryAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -715,6 +719,21 @@ function OpenCommandPaletteDialog(props: { [activeThreadId, clientSettings.sidebarThreadSortOrder, navigate, projectTitleById, threads], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); + const pluginCommandItems = useMemo( + () => + pluginUiRegistry.commands.map((command) => ({ + kind: "action", + value: `plugin:${command.pluginId}:${command.id}`, + searchTerms: [command.title, command.description ?? "", command.pluginId, command.id], + title: command.title, + ...(command.description ? { description: command.description } : {}), + icon: , + run: async () => { + await command.run(command.context); + }, + })), + [pluginUiRegistry.commands], + ); function pushPaletteView(view: CommandPaletteView): void { setViewStack((previousViews) => [ @@ -1056,7 +1075,7 @@ function OpenCommandPaletteDialog(props: { }, }); - const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const rootGroups = buildRootGroups({ actionItems, pluginCommandItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; const activeGroups = @@ -1532,7 +1551,7 @@ function OpenCommandPaletteDialog(props: { setOpen(false); } - void item.run().catch((error: unknown) => { + executeCommandPaletteActionItem(item, (error: unknown) => { toastManager.add( stackedThreadToast({ type: "error", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9700e14c626..36f78d3043d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -126,6 +126,7 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; +import { PluginProjectActions } from "../plugins/PluginProjectActions"; import { PluginSidebarSections } from "../plugins/PluginSidebarSections"; import { Kbd } from "./ui/kbd"; import { @@ -2284,6 +2285,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec )} + {/* Per-project actions contributed by web plugins (e.g. "New workflow + board"), revealed on hover to the left of the built-in new-thread + button. Targets the group's primary member — the same project the + new-thread button seeds from. */} + {project.memberProjects[0] ? ( +
+ +
+ ) : null} = {}): PluginInfo => ({ state: "active", capabilities: ["database"], hasWeb: true, + hasStyles: false, lastError: null, ...overrides, }); @@ -39,6 +42,42 @@ function commandFailure
(message: string): AtomCommandResult { return AsyncResult.failure(Cause.fail(new Error(message))); } +const marketplaceVersion = (version: string): MarketplaceVersion => ({ + version, + tarball: `https://example.test/plugin-${version}.tgz`, + sha256: "a".repeat(64), + hostApi: "^1.0.0", + publishedAt: "2026-07-03T00:00:00.000Z", +}); + +describe("latestMarketplaceVersion", () => { + it("picks the semver-max instead of trusting the publisher-controlled order", () => { + const versions = [marketplaceVersion("0.9.0"), marketplaceVersion("1.0.0")]; + expect(latestMarketplaceVersion(versions)?.version).toBe("1.0.0"); + expect(latestMarketplaceVersion(versions.toReversed())?.version).toBe("1.0.0"); + }); + + it("excludes prereleases when a stable release exists", () => { + expect( + latestMarketplaceVersion([marketplaceVersion("1.0.0"), marketplaceVersion("1.1.0-rc.1")]) + ?.version, + ).toBe("1.0.0"); + }); + + it("falls back to the newest prerelease when nothing stable is published", () => { + expect( + latestMarketplaceVersion([ + marketplaceVersion("1.0.0-rc.2"), + marketplaceVersion("1.0.0-rc.10"), + ])?.version, + ).toBe("1.0.0-rc.10"); + }); + + it("returns null for empty version lists", () => { + expect(latestMarketplaceVersion([])).toBeNull(); + }); +}); + describe("Plugins settings logic", () => { it("detects relaunch states and selected install source", () => { expect(pluginRequiresRelaunch(plugin({ state: "pending-remove" }))).toBe(true); diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts b/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts index 315076cf22d..6753cb0e3cf 100644 --- a/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts +++ b/apps/web/src/components/settings/plugins/PluginsSettings.logic.ts @@ -10,6 +10,7 @@ import type { PluginSourcesRemoveInput, PluginState, } from "@t3tools/contracts"; +import { compareSemver, isPrereleaseVersion } from "@t3tools/contracts"; import { squashAtomCommandFailure, type AtomCommandResult, @@ -66,7 +67,13 @@ export function pluginStateBadgeVariant( export function latestMarketplaceVersion( versions: ReadonlyArray, ): MarketplaceVersion | null { - return versions[0] ?? null; + // The index order is publisher-controlled, so pick the true semver-max + // (matching the server's checkUpdates) instead of trusting versions[0]. + // Stable releases are preferred; a prerelease is only offered when the + // source publishes nothing else. + const stable = versions.filter((candidate) => !isPrereleaseVersion(candidate.version)); + const pool = stable.length > 0 ? stable : versions; + return pool.toSorted((left, right) => compareSemver(right.version, left.version))[0] ?? null; } export function effectiveInstallSourceId( diff --git a/apps/web/src/components/settings/plugins/PluginsSettings.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.tsx index b9ec1299c2d..d8bd009d604 100644 --- a/apps/web/src/components/settings/plugins/PluginsSettings.tsx +++ b/apps/web/src/components/settings/plugins/PluginsSettings.tsx @@ -535,7 +535,9 @@ function CatalogEntryRow({

By{" "} {entry.author.url ? ( - {entry.author.name} + + {entry.author.name} + ) : ( entry.author.name )} diff --git a/apps/web/src/plugins/PluginProjectActions.tsx b/apps/web/src/plugins/PluginProjectActions.tsx new file mode 100644 index 00000000000..c5222b5cb58 --- /dev/null +++ b/apps/web/src/plugins/PluginProjectActions.tsx @@ -0,0 +1,57 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useRouter } from "@tanstack/react-router"; +import { createElement, type FunctionComponent } from "react"; +import type { PluginProjectActionRenderProps } from "@t3tools/plugin-sdk-web"; + +import { PluginSurfaceErrorBoundary, pluginUiRegistryAtom } from "./PluginUiHost"; + +export interface PluginProjectActionsProps { + readonly environmentId: string; + readonly projectId: string; + readonly projectName: string; +} + +/** + * Render the per-project actions registered by web plugins inline in a project + * row (alongside the built-in "New thread" button). Each plugin's `render` + * returns its own trigger and manages its own UI; the host supplies project + * context and a mode-correct route base for post-action navigation. + */ +export function PluginProjectActions({ + environmentId, + projectId, + projectName, +}: PluginProjectActionsProps) { + const snapshot = useAtomValue(pluginUiRegistryAtom); + const router = useRouter(); + const actions = snapshot.projectActions; + + if (actions.length === 0) { + return null; + } + + return ( + <> + {actions.map((action) => { + // Mode-correct route base (hash history on desktop, browser history on + // web) so a plugin can navigate to its own routes after the action runs. + const routeBasePath = router.history.createHref(`/${environmentId}/p/${action.pluginId}`); + return ( + + {createElement(action.render as FunctionComponent, { + pluginId: action.pluginId, + environmentId, + projectId, + projectName, + routeBasePath, + })} + + ); + })} + + ); +} diff --git a/apps/web/src/plugins/PluginSidebarSections.tsx b/apps/web/src/plugins/PluginSidebarSections.tsx index a03d9d315bf..eb21e330c50 100644 --- a/apps/web/src/plugins/PluginSidebarSections.tsx +++ b/apps/web/src/plugins/PluginSidebarSections.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { useRouter } from "@tanstack/react-router"; import { createElement, type FunctionComponent } from "react"; import type { PluginSidebarSectionRenderProps } from "@t3tools/plugin-sdk-web"; @@ -22,6 +23,7 @@ export function getVisiblePluginSidebarSections(snapshot: PluginUiRegistrySnapsh export function PluginSidebarSections() { const snapshot = useAtomValue(pluginUiRegistryAtom); const environmentId = useActiveEnvironmentId(); + const router = useRouter(); const sections = getVisiblePluginSidebarSections(snapshot); if (sections.length === 0) { @@ -31,14 +33,24 @@ export function PluginSidebarSections() { return ( <> {sections.map((section) => { + // Build the base path as a real href for the active history mode so a + // plugin's `` navigates correctly. The + // desktop app uses hash history (`#/...`) and the web app uses browser + // history (`/...`); a bare path only works in the latter, so hand plugins + // a mode-correct base via the router's `createHref`. const routeBasePath = - environmentId === null ? null : `/${environmentId}/p/${section.pluginId}`; + environmentId === null + ? null + : router.history.createHref(`/${environmentId}/p/${section.pluginId}`); return ( {section.title} - + {createElement( section.render as FunctionComponent, { pluginId: section.pluginId, environmentId, routeBasePath }, diff --git a/apps/web/src/plugins/PluginUiHost.test.tsx b/apps/web/src/plugins/PluginUiHost.test.tsx index 77786339de6..8c888a74673 100644 --- a/apps/web/src/plugins/PluginUiHost.test.tsx +++ b/apps/web/src/plugins/PluginUiHost.test.tsx @@ -6,9 +6,12 @@ import * as Stream from "effect/Stream"; import { createPluginUiHostState, getPluginWebEntryUrl, + parsePluginIdParam, + PluginSurfaceErrorBoundary, resolvePluginRouteRegistration, resolvePluginSettingsPageRegistration, syncPluginUiHostRegistrations, + type PluginSurfaceErrorBoundaryProps, } from "./PluginUiHost"; const fixturePluginId = PluginId.make("fixture-plugin"); @@ -22,6 +25,7 @@ function pluginInfo(overrides: Partial = {}): PluginInfo { state: "active", capabilities: [], hasWeb: true, + hasStyles: false, lastError: null, ...overrides, }; @@ -58,6 +62,15 @@ describe("PluginUiHost", () => { title: "General", component: () => null, }); + ctx.registerCommand({ + id: "refresh", + title: "Refresh", + run: () => undefined, + }); + ctx.registerProjectAction({ + id: "new-board", + render: () => null, + }); void ctx.rpc.call("ping"); }, }), @@ -77,6 +90,12 @@ describe("PluginUiHost", () => { expect(snapshot.routes).toHaveLength(1); expect(snapshot.sidebarSections).toHaveLength(1); expect(snapshot.settingsPages).toHaveLength(1); + expect(snapshot.commands).toHaveLength(1); + expect(snapshot.commands[0]?.pluginId).toBe(fixturePluginId); + expect(snapshot.commands[0]?.context.pluginId).toBe(fixturePluginId); + expect(snapshot.projectActions).toHaveLength(1); + expect(snapshot.projectActions[0]?.pluginId).toBe(fixturePluginId); + expect(snapshot.projectActions[0]?.id).toBe("new-board"); expect(snapshot.failures).toEqual({}); }); @@ -106,6 +125,7 @@ describe("PluginUiHost", () => { }, }); expect(failedSnapshot.routes).toHaveLength(0); + expect(failedSnapshot.commands).toHaveLength(0); expect(failedSnapshot.failures[failingPluginId]).toContain("boom"); const emptySnapshot = await syncPluginUiHostRegistrations({ @@ -153,4 +173,101 @@ describe("PluginUiHost", () => { it("uses the conventional same-origin web entry URL", () => { expect(getPluginWebEntryUrl(pluginInfo())).toBe("/plugins/fixture-plugin/1.2.3/web/index.js"); }); + + it("retries a failed web plugin import on the next sync, then keeps the loaded plugin", async () => { + const state = createPluginUiHostState(); + let attempts = 0; + const importWebPlugin = async () => { + attempts += 1; + if (attempts === 1) { + throw new Error("transient 404"); + } + return { + default: defineWebPlugin({ + register(ctx) { + ctx.registerRoute({ path: "overview", component: () => null }); + }, + }), + }; + }; + const sync = () => + syncPluginUiHostRegistrations({ + state, + plugins: [pluginInfo()], + waitForHost: async () => undefined, + importWebPlugin, + }); + + const failed = await sync(); + expect(failed.routes).toHaveLength(0); + expect(failed.failures[fixturePluginId]).toContain("transient 404"); + + const recovered = await sync(); + expect(recovered.routes).toHaveLength(1); + expect(recovered.failures).toEqual({}); + expect(attempts).toBe(2); + + // A successfully loaded plugin is NOT re-imported by later syncs. + await sync(); + expect(attempts).toBe(2); + }); + + it("parses valid plugin id route params and rejects invalid ones without throwing", () => { + expect(parsePluginIdParam("fixture-plugin")).toBe("fixture-plugin"); + expect(parsePluginIdParam("NOT_VALID!!")).toBeNull(); + expect(parsePluginIdParam("!!")).toBeNull(); + expect(parsePluginIdParam("")).toBeNull(); + expect(parsePluginIdParam("Fixture-Plugin")).toBeNull(); + expect(parsePluginIdParam("a")).toBeNull(); + }); +}); + +describe("PluginSurfaceErrorBoundary", () => { + const makeErroredBoundary = (props: Omit) => { + const boundary = new PluginSurfaceErrorBoundary({ children: null, ...props }); + boundary.state = { error: new Error("boom") }; + const resets: Array = []; + boundary.setState = ((next: unknown) => { + resets.push(next); + }) as typeof boundary.setState; + return { boundary, resets }; + }; + + it("captures render errors via getDerivedStateFromError", () => { + const error = new Error("boom"); + expect(PluginSurfaceErrorBoundary.getDerivedStateFromError(error)).toEqual({ error }); + }); + + it("keeps the error while the same surface re-renders", () => { + const resetKey = () => null; + const { boundary, resets } = makeErroredBoundary({ label: "route:a:overview", resetKey }); + boundary.componentDidUpdate({ children: null, label: "route:a:overview", resetKey }); + expect(resets).toEqual([]); + }); + + it("resets when the boundary is reused for a different surface", () => { + const { boundary, resets } = makeErroredBoundary({ label: "route:b:overview" }); + boundary.componentDidUpdate({ children: null, label: "route:a:overview" }); + expect(resets).toEqual([{ error: null }]); + }); + + it("resets when a registry re-sync reloads the plugin surface", () => { + const { boundary, resets } = makeErroredBoundary({ + label: "route:a:overview", + resetKey: () => null, + }); + boundary.componentDidUpdate({ + children: null, + label: "route:a:overview", + resetKey: () => null, + }); + expect(resets).toEqual([{ error: null }]); + }); + + it("does not reset while no error is stored", () => { + const { boundary, resets } = makeErroredBoundary({ label: "route:b:overview" }); + boundary.state = { error: null }; + boundary.componentDidUpdate({ children: null, label: "route:a:overview" }); + expect(resets).toEqual([]); + }); }); diff --git a/apps/web/src/plugins/PluginUiHost.tsx b/apps/web/src/plugins/PluginUiHost.tsx index 4ba8ada2192..7b3c43b2b37 100644 --- a/apps/web/src/plugins/PluginUiHost.tsx +++ b/apps/web/src/plugins/PluginUiHost.tsx @@ -2,20 +2,20 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import type { PluginCommandRegistration, PluginComponent, + PluginProjectActionRenderProps, PluginRouteComponentProps, PluginSettingsComponentProps, - PluginSidebarSectionRegistration, PluginSidebarSectionRenderProps, PluginUiContext, PluginWebDefinition, PluginWebRpc, } from "@t3tools/plugin-sdk-web"; -import type { PluginId, PluginInfo } from "@t3tools/contracts"; +import { PLUGIN_ID_PATTERN_SOURCE, type PluginId, type PluginInfo } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { Component, useEffect, useRef, type ErrorInfo, type ReactNode } from "react"; import { pluginListAtom, pluginRpc } from "../state/plugins"; -import { whenPluginHostReady } from "./hostSingletons"; +import { whenPluginHostReady } from "./hostSingletonsReady"; export interface RegisteredPluginRoute { readonly pluginId: PluginId; @@ -37,8 +37,15 @@ export interface RegisteredPluginSettingsPage { readonly component: PluginComponent; } +export interface RegisteredPluginProjectAction { + readonly pluginId: PluginId; + readonly id: string; + readonly render: (props: PluginProjectActionRenderProps) => unknown; +} + export interface RegisteredPluginCommand extends PluginCommandRegistration { readonly pluginId: PluginId; + readonly context: PluginUiContext; } export interface PluginUiRegistrySnapshot { @@ -46,6 +53,7 @@ export interface PluginUiRegistrySnapshot { readonly sidebarSections: ReadonlyArray; readonly settingsPages: ReadonlyArray; readonly commands: ReadonlyArray; + readonly projectActions: ReadonlyArray; readonly failures: Readonly>; } @@ -56,6 +64,7 @@ interface LoadedPlugin { readonly sidebarSections: ReadonlyArray; readonly settingsPages: ReadonlyArray; readonly commands: ReadonlyArray; + readonly projectActions: ReadonlyArray; readonly failure: string | null; } @@ -68,6 +77,7 @@ export const EMPTY_PLUGIN_UI_REGISTRY_SNAPSHOT: PluginUiRegistrySnapshot = Objec sidebarSections: Object.freeze([]), settingsPages: Object.freeze([]), commands: Object.freeze([]), + projectActions: Object.freeze([]), failures: Object.freeze({}), }); @@ -79,6 +89,19 @@ export function createPluginUiHostState(): PluginUiHostState { return { loaded: new Map() }; } +const PLUGIN_ID_PARAM_PATTERN = new RegExp(`^${PLUGIN_ID_PATTERN_SOURCE}$`); + +/** + * Non-throwing PluginId parse for user-typed route params. URL segments are + * attacker-controlled and `PluginId.make` THROWS during render on a pattern + * mismatch — past the plugin surface boundary, into the root errorComponent — + * so routes must resolve invalid ids to null and fall through to their + * not-found views instead. + */ +export function parsePluginIdParam(raw: string): PluginId | null { + return PLUGIN_ID_PARAM_PATTERN.test(raw) ? (raw as PluginId) : null; +} + function normalizePluginPath(path: string): string { return path .split("/") @@ -95,6 +118,7 @@ function snapshotFromState(state: PluginUiHostState): PluginUiRegistrySnapshot { const sidebarSections: Array = []; const settingsPages: Array = []; const commands: Array = []; + const projectActions: Array = []; const failures: Record = {}; for (const loaded of state.loaded.values()) { @@ -106,9 +130,10 @@ function snapshotFromState(state: PluginUiHostState): PluginUiRegistrySnapshot { sidebarSections.push(...loaded.sidebarSections); settingsPages.push(...loaded.settingsPages); commands.push(...loaded.commands); + projectActions.push(...loaded.projectActions); } - return { routes, sidebarSections, settingsPages, commands, failures }; + return { routes, sidebarSections, settingsPages, commands, projectActions, failures }; } export function getPluginWebEntryUrl(plugin: Pick): string { @@ -116,6 +141,56 @@ export function getPluginWebEntryUrl(plugin: Pick) return `/plugins/${encodeURIComponent(plugin.id)}/${encodeURIComponent(plugin.version)}/web/index.js`; } +export function getPluginStylesUrl( + plugin: Pick, +): string | null { + // A plugin that declares `entries.styles` ships a compiled stylesheet next to + // its web bundle. The host build only scans host source, so a plugin must ship + // its own CSS for any classes it uses that the host doesn't. + return plugin.hasStyles + ? `/plugins/${encodeURIComponent(plugin.id)}/${encodeURIComponent(plugin.version)}/web/index.css` + : null; +} + +const PLUGIN_STYLE_LINK_ATTR = "data-t3-plugin-styles"; + +/** + * Reconcile the `` elements for active web plugins that + * ship styles: inject one per plugin (keyed by id), drop links for plugins that + * are no longer active or whose version (href) changed. Idempotent. + */ +function reconcilePluginStyleLinks(activeWebPlugins: ReadonlyArray): void { + if (typeof document === "undefined") { + return; + } + const desired = new Map(); + for (const plugin of activeWebPlugins) { + const url = getPluginStylesUrl(plugin); + if (url !== null) { + desired.set(plugin.id, url); + } + } + for (const element of Array.from( + document.head.querySelectorAll(`link[${PLUGIN_STYLE_LINK_ATTR}]`), + )) { + const id = element.getAttribute(PLUGIN_STYLE_LINK_ATTR); + if (id === null || desired.get(id) !== element.getAttribute("href")) { + element.remove(); + } + } + for (const [id, url] of desired) { + if ( + document.head.querySelector(`link[${PLUGIN_STYLE_LINK_ATTR}="${CSS.escape(id)}"]`) === null + ) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.setAttribute("href", url); + link.setAttribute(PLUGIN_STYLE_LINK_ATTR, id); + document.head.appendChild(link); + } + } +} + function makePluginLogger(pluginId: PluginId): PluginUiContext["logger"] { const prefix = `[plugin:${pluginId}]`; return { @@ -164,13 +239,24 @@ export async function syncPluginUiHostRegistrations({ const activeWebPlugins = plugins.filter((plugin) => plugin.state === "active" && plugin.hasWeb); const activeKeys = new Set(activeWebPlugins.map((plugin) => `${plugin.id}@${plugin.version}`)); + // Inject/remove each active web plugin's stylesheet alongside its JS. + reconcilePluginStyleLinks(activeWebPlugins); + for (const [pluginId, loaded] of state.loaded.entries()) { if (!activeKeys.has(`${pluginId}@${loaded.version}`)) { state.loaded.delete(pluginId); } } - const pluginsToLoad = activeWebPlugins.filter((plugin) => !state.loaded.has(plugin.id)); + // Reload plugins whose previous load FAILED as well as never-loaded ones: an + // import failure can be transient (server restart 404ing the bundle, network + // blip), and lifecycle-driven resyncs are the self-healing signal. A retry is + // cheap — the browser caches the module once it succeeds — and a + // deterministic failure just re-records itself. + const pluginsToLoad = activeWebPlugins.filter((plugin) => { + const loaded = state.loaded.get(plugin.id); + return loaded === undefined || loaded.failure !== null; + }); if (pluginsToLoad.length > 0) { await waitForHost(); } @@ -180,6 +266,7 @@ export async function syncPluginUiHostRegistrations({ const sidebarSections: Array = []; const settingsPages: Array = []; const commands: Array = []; + const projectActions: Array = []; try { const module = await importWebPlugin(getPluginWebEntryUrl(plugin)); @@ -202,7 +289,10 @@ export async function syncPluginUiHostRegistrations({ settingsPages.push({ ...registration, pluginId: plugin.id }); }, registerCommand: (registration) => { - commands.push({ ...registration, pluginId: plugin.id }); + commands.push({ ...registration, pluginId: plugin.id, context: ctx }); + }, + registerProjectAction: (registration) => { + projectActions.push({ ...registration, pluginId: plugin.id }); }, }; await maybeAwait(definition.register(ctx)); @@ -213,6 +303,7 @@ export async function syncPluginUiHostRegistrations({ sidebarSections, settingsPages, commands, + projectActions, failure: null, }); } catch (error) { @@ -225,6 +316,7 @@ export async function syncPluginUiHostRegistrations({ sidebarSections: [], settingsPages: [], commands: [], + projectActions: [], failure: message, }); } @@ -274,7 +366,13 @@ export function PluginUiHost() { syncPluginUiHostRegistrations({ state: stateRef.current, plugins, - waitForHost: () => whenPluginHostReady, + // Publish the host singletons on demand: hostSingletons pulls the full + // `effect` barrel + `@t3tools/contracts` + the SDK surface, so it is + // code-split out of the main bundle and only loaded once a web plugin + // actually needs it. `whenPluginHostReady` resolves when it publishes; + // a failed chunk load rejects the sync, which the chain logs and the + // next lifecycle-driven sync retries. + waitForHost: () => import("./hostSingletons").then(() => whenPluginHostReady), importWebPlugin: (url) => import(/* @vite-ignore */ url), }).then((snapshot) => { if (!cancelled) { @@ -294,11 +392,21 @@ export function PluginUiHost() { return null; } +export interface PluginSurfaceErrorBoundaryProps { + readonly children: ReactNode; + readonly label: string; + // Identity of the rendered plugin surface (e.g. the registered component + // function). Registry re-syncs re-import a plugin's module, so a changed + // resetKey means new plugin code — retry rendering instead of staying stuck + // on the fallback. + readonly resetKey?: unknown; +} + export class PluginSurfaceErrorBoundary extends Component< - { readonly children: ReactNode; readonly label: string }, + PluginSurfaceErrorBoundaryProps, { readonly error: Error | null } > { - override state = { error: null }; + override state: { readonly error: Error | null } = { error: null }; static getDerivedStateFromError(error: Error) { return { error }; @@ -308,6 +416,22 @@ export class PluginSurfaceErrorBoundary extends Component< console.error(`[plugin-ui] ${this.props.label} crashed`, error, info); } + // React error boundaries keep their error state until they remount, but this + // boundary instance can be REUSED for a different surface (TanStack Router + // does not remount a route component on param-only changes) or for reloaded + // plugin code (registry re-sync). Reset the error whenever the surface + // identity changes so one crash doesn't permanently stick the fallback. + override componentDidUpdate(prevProps: PluginSurfaceErrorBoundaryProps) { + if ( + this.state.error !== null && + (prevProps.label !== this.props.label || prevProps.resetKey !== this.props.resetKey) + ) { + // Guarded error-boundary reset: only fires on a surface-identity change, so it cannot loop. + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ error: null }); + } + } + override render() { if (this.state.error) { return ( diff --git a/apps/web/src/plugins/hostSingletons.test.ts b/apps/web/src/plugins/hostSingletons.test.ts index 152acc334ab..3bb3931feff 100644 --- a/apps/web/src/plugins/hostSingletons.test.ts +++ b/apps/web/src/plugins/hostSingletons.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { getPluginHostShimSource, pluginHostShimExportNames } from "@t3tools/shared/pluginHostWeb"; +import { + getPluginHostShimSource, + pluginHostModuleSpecifiers, + pluginHostShimExportNames, +} from "@t3tools/shared/pluginHostWeb"; +import { isPluginSdkWebExternal } from "@t3tools/plugin-sdk-web"; +import reactPackageJson from "react/package.json"; import { getPluginHost, whenPluginHostReady } from "./hostSingletons"; @@ -10,6 +16,7 @@ describe("hostSingletons", () => { expect(Object.keys(host).sort()).toEqual([ "@effect/atom-react", + "@t3tools/contracts", "@t3tools/plugin-sdk-web", "effect", "react", @@ -18,10 +25,19 @@ describe("hostSingletons", () => { "react/jsx-dev-runtime", "react/jsx-runtime", ]); - expect(host.react.version).toBe("19.2.6"); + expect(host.react.version).toBe(reactPackageJson.version); expect(typeof host["@t3tools/plugin-sdk-web"].hostCompat).toBe("object"); }); + // Every module the runtime import map serves as a host singleton must also be + // externalized by plugin builds — otherwise a plugin bundles its own copy and + // forks state the host expects to share. + it("marks every import-map module external for plugin builds", () => { + for (const specifier of pluginHostModuleSpecifiers) { + expect(isPluginSdkWebExternal(specifier), specifier).toBe(true); + } + }); + it("react shim modules re-export the host singleton identities", async () => { const currentHost = getPluginHost(); const hostReact = { diff --git a/apps/web/src/plugins/hostSingletons.ts b/apps/web/src/plugins/hostSingletons.ts index e5f525705e6..4de87a8d295 100644 --- a/apps/web/src/plugins/hostSingletons.ts +++ b/apps/web/src/plugins/hostSingletons.ts @@ -1,4 +1,5 @@ import * as atomReact from "@effect/atom-react"; +import * as contracts from "@t3tools/contracts"; import * as pluginSdkWeb from "@t3tools/plugin-sdk-web"; import * as effect from "effect"; import * as React from "react"; @@ -7,6 +8,14 @@ import * as ReactDOMClient from "react-dom/client"; import * as jsxDevRuntime from "react/jsx-dev-runtime"; import * as jsxRuntime from "react/jsx-runtime"; +import { publishPluginHostSingletons, whenPluginHostReady } from "./hostSingletonsReady"; + +// Heavy half of the plugin host singletons: importing this module pulls the +// full `effect` barrel, the whole `@t3tools/contracts` surface, and the SDK's +// host re-exports. It is only imported dynamically (PluginUiHost, when the +// first active web plugin needs the host), so all of that stays out of the +// main web bundle. The readiness promise lives in ./hostSingletonsReady. + export interface PluginHostSingletons { readonly react: typeof React; readonly "react-dom": typeof ReactDOM; @@ -15,34 +24,10 @@ export interface PluginHostSingletons { readonly "react/jsx-dev-runtime": typeof jsxDevRuntime; readonly "@effect/atom-react": typeof atomReact; readonly effect: typeof effect; + readonly "@t3tools/contracts": typeof contracts; readonly "@t3tools/plugin-sdk-web": typeof pluginSdkWeb; } -declare global { - // Host ESM shims read this object synchronously after the SPA boot module publishes it. - // eslint-disable-next-line no-var - var __T3_PLUGIN_HOST__: PluginHostSingletons | undefined; - // eslint-disable-next-line no-var - var __T3_PLUGIN_HOST_READY__: Promise | undefined; - // eslint-disable-next-line no-var - var __T3_PLUGIN_HOST_READY_RESOLVE__: ((host: PluginHostSingletons) => void) | undefined; -} - -let resolvePluginHostReady: (host: PluginHostSingletons) => void; - -export const whenPluginHostReady = - globalThis.__T3_PLUGIN_HOST_READY__ ?? - new Promise((resolve) => { - resolvePluginHostReady = resolve; - globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__ = resolve; - }); - -if (!globalThis.__T3_PLUGIN_HOST_READY__) { - globalThis.__T3_PLUGIN_HOST_READY__ = whenPluginHostReady; -} else { - resolvePluginHostReady = globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__ ?? (() => {}); -} - const pluginHost: PluginHostSingletons = { react: React, "react-dom": ReactDOM, @@ -51,12 +36,13 @@ const pluginHost: PluginHostSingletons = { "react/jsx-dev-runtime": jsxDevRuntime, "@effect/atom-react": atomReact, effect, + "@t3tools/contracts": contracts, "@t3tools/plugin-sdk-web": pluginSdkWeb, }; -globalThis.__T3_PLUGIN_HOST__ = pluginHost; -globalThis.__T3_PLUGIN_HOST_READY_RESOLVE__?.(pluginHost); -resolvePluginHostReady!(pluginHost); +publishPluginHostSingletons(pluginHost); + +export { whenPluginHostReady }; export function getPluginHost(): PluginHostSingletons { if (!globalThis.__T3_PLUGIN_HOST__) { diff --git a/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx index fbf45e6796e..06bdf7822df 100644 --- a/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx +++ b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { PluginId } from "@t3tools/contracts"; import { createFileRoute } from "@tanstack/react-router"; import { createElement, type FunctionComponent } from "react"; import type { PluginRouteComponentProps } from "@t3tools/plugin-sdk-web"; @@ -7,6 +6,7 @@ import type { PluginRouteComponentProps } from "@t3tools/plugin-sdk-web"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; import { + parsePluginIdParam, PluginSurfaceErrorBoundary, pluginUiRegistryAtom, resolvePluginRouteRegistration, @@ -30,27 +30,58 @@ function PluginRouteNotFound() { ); } +function normalizeSearch(search: Record): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(search)) { + if (typeof value === "string") { + normalized[key] = value; + } else if (typeof value === "number" || typeof value === "boolean") { + normalized[key] = String(value); + } + } + return normalized; +} + function PluginRouteView() { const params = Route.useParams(); + const search = Route.useSearch() as Record; const snapshot = useAtomValue(pluginUiRegistryAtom); - const pluginId = PluginId.make(params.pluginId); - const route = resolvePluginRouteRegistration(snapshot, pluginId, splatFromParams(params)); + // Non-throwing parse: `$pluginId` is a raw user-typed URL segment, and an + // invalid id must land on the not-found view, not crash the route tree. + const pluginId = parsePluginIdParam(params.pluginId); + const route = + pluginId === null + ? null + : resolvePluginRouteRegistration(snapshot, pluginId, splatFromParams(params)); if (!route) { return ; } + const surfaceLabel = `route:${route.pluginId}:${route.path}`; + // Render the plugin component as its OWN React element (createElement), not // by calling it as a function: a function call would run the plugin's hooks // on this route's fiber and break the Rules of Hooks when the resolved // component changes. As an element it gets its own fiber and the error // boundary actually wraps the mounted component. + // + // Key the boundary by the surface: TanStack Router reuses this route + // component across param-only changes, so without the key one plugin's crash + // would leave the boundary stuck on the fallback for every other plugin + // route. `resetKey` retries after a registry re-sync reloads the plugin. return ( - + {createElement(route.component as FunctionComponent, { pluginId: route.pluginId, path: route.path, + environmentId: typeof params.environmentId === "string" ? params.environmentId : null, + search: normalizeSearch(search), })} diff --git a/apps/web/src/routes/settings.$.tsx b/apps/web/src/routes/settings.$.tsx index f7410ae0794..b3a9e20e55c 100644 --- a/apps/web/src/routes/settings.$.tsx +++ b/apps/web/src/routes/settings.$.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { PluginId } from "@t3tools/contracts"; +import type { PluginId } from "@t3tools/contracts"; import { createFileRoute } from "@tanstack/react-router"; import { createElement, type FunctionComponent } from "react"; import type { PluginSettingsComponentProps } from "@t3tools/plugin-sdk-web"; @@ -7,6 +7,7 @@ import type { PluginSettingsComponentProps } from "@t3tools/plugin-sdk-web"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SettingsPageContainer } from "../components/settings/settingsLayout"; import { + parsePluginIdParam, PluginSurfaceErrorBoundary, pluginUiRegistryAtom, resolvePluginSettingsPageRegistration, @@ -31,13 +32,13 @@ function parseSettingsSplat(splat: string): { .slice(pluginIdIndex + 1) .filter((part) => part.length > 0) .join("/"); - if (!rawPluginId || pageId.length === 0) { + // Non-throwing parse: the splat is a raw user-typed URL, and an invalid + // plugin id must land on the not-found view, not crash the route tree. + const pluginId = rawPluginId === undefined ? null : parsePluginIdParam(rawPluginId); + if (pluginId === null || pageId.length === 0) { return null; } - return { - pluginId: PluginId.make(rawPluginId), - pageId, - }; + return { pluginId, pageId }; } function PluginSettingsNotFound() { @@ -65,11 +66,15 @@ function PluginSettingsRouteView() { return ; } + const surfaceLabel = `settings:${page.pluginId}:${page.id}`; + // Render as an element (its own fiber) so plugin hooks work — see the note - // in the plugin route splat. + // in the plugin route splat. The boundary is keyed per surface (the router + // reuses this component across param-only changes) and resets when a + // registry re-sync reloads the plugin's component. return ( - + {createElement(page.component as FunctionComponent, { pluginId: page.pluginId, pageId: page.id, diff --git a/apps/web/src/state/plugins.test.ts b/apps/web/src/state/plugins.test.ts index 3eaf842155c..4c7f764ac4a 100644 --- a/apps/web/src/state/plugins.test.ts +++ b/apps/web/src/state/plugins.test.ts @@ -1,18 +1,27 @@ -import { PluginId } from "@t3tools/contracts"; +import { type PluginInfo, PluginId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import { AsyncResult } from "effect/unstable/reactivity"; -import { makePluginListStream, pluginRpc } from "./plugins"; +import { + keepLastKnownPluginList, + makePluginListStream, + pluginRpc, + resolvePluginListWithCache, +} from "./plugins"; const pluginId = PluginId.make("fixture-plugin"); describe("web plugin state", () => { - it.effect("loads plugin list initially and refreshes on plugin lifecycle changes", () => + it.effect("loads plugin list initially and refreshes on every server-lifecycle event", () => Effect.gen(function* () { let calls = 0; const lists = yield* makePluginListStream( Stream.make( + // A `ready` event is the reconnect signal (replayed to every new + // subscriber), so it must trigger a reload — not only `plugin-state-changed`. { version: 1 as const, sequence: 1, @@ -40,17 +49,91 @@ describe("web plugin state", () => { state: "active" as const, capabilities: [], hasWeb: true, + hasStyles: false, lastError: null, }, ]; }), ).pipe(Stream.runCollect); - expect(calls).toBe(2); - expect(Array.from(lists)).toHaveLength(2); + // Initial eager load + reload on `ready` + reload on `plugins` = 3. + expect(calls).toBe(3); + expect(Array.from(lists)).toHaveLength(3); }), ); + it.effect("keeps the last known plugin list across failed refreshes", () => + Effect.gen(function* () { + const pluginA: PluginInfo = { + id: pluginId, + name: "A", + version: "1.0.0", + state: "active", + capabilities: [], + hasWeb: true, + hasStyles: false, + lastError: null, + }; + const pluginB: PluginInfo = { ...pluginA, name: "B", version: "2.0.0" }; + const listA: ReadonlyArray = [pluginA]; + const listB: ReadonlyArray = [pluginB]; + + const emitted = yield* keepLastKnownPluginList( + Stream.make( + Option.some(listA), // first successful load + Option.none>(), // failed refresh -> keep listA + Option.some(listB), // successful refresh -> listB + Option.none>(), // failed refresh -> keep listB + ), + ).pipe(Stream.runCollect); + + expect(Array.from(emitted)).toEqual([listA, listA, listB, listB]); + + // A transient failure BEFORE any success emits nothing, so the underlying + // result atom keeps its previous value (and the persistent per-env cache is + // not overwritten with a spurious empty list). + const emittedInitialFailure = yield* keepLastKnownPluginList( + Stream.make( + Option.none>(), // initial load failed -> emit nothing + Option.none>(), // still failing -> emit nothing + Option.some(listA), // first success -> listA + Option.none>(), // failed refresh -> keep listA + ), + ).pipe(Stream.runCollect); + + expect(Array.from(emittedInitialFailure)).toEqual([listA, listA]); + }), + ); + + it("keeps the last successful plugin list across transient non-success results", () => { + const pluginA: PluginInfo = { + id: pluginId, + name: "A", + version: "1.0.0", + state: "active", + capabilities: [], + hasWeb: true, + hasStyles: false, + lastError: null, + }; + const listA: ReadonlyArray = [pluginA]; + const cache = new Map>(); + const env = "env-1"; + + // No cache yet + a non-success (Initial) result -> empty. + expect(resolvePluginListWithCache(env, AsyncResult.initial>(), cache)).toEqual([]); + // A successful load returns the list and seeds the cache. + expect(resolvePluginListWithCache(env, AsyncResult.success>(listA), cache)).toEqual(listA); + // A transient blip (result reset to Initial) keeps the last known list. + expect(resolvePluginListWithCache(env, AsyncResult.initial>(), cache)).toEqual(listA); + // A genuine empty (successful) load clears it. + expect( + resolvePluginListWithCache(env, AsyncResult.success>([]), cache), + ).toEqual([]); + // ...and a subsequent blip keeps the (now empty) last known list. + expect(resolvePluginListWithCache(env, AsyncResult.initial>(), cache)).toEqual([]); + }); + it("binds plugin RPC helpers to one plugin id", () => { const calls: Array = []; const rpc = pluginRpc(pluginId, { diff --git a/apps/web/src/state/plugins.ts b/apps/web/src/state/plugins.ts index 31770ac32a0..5acd5371270 100644 --- a/apps/web/src/state/plugins.ts +++ b/apps/web/src/state/plugins.ts @@ -46,7 +46,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { isPluginStateChangedLifecycleEvent } from "@t3tools/client-runtime/state/server"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; const EMPTY_PLUGIN_LIST: ReadonlyArray = Object.freeze([]); @@ -72,17 +71,62 @@ function runPrimaryPluginManagement( }); } -export function makePluginListStream( +// Reload the plugin list on EVERY server-lifecycle event, not just +// `plugin-state-changed`. The lifecycle subscription (`subscribeServerLifecycle`) +// is transport-resilient — it re-subscribes on every environment (re)connect and +// the server replays the buffered `welcome`/`ready` snapshot to each new +// subscriber. Those replayed events are therefore the reliable "the connection is +// up now" signal. `listPlugins` itself is a plain unary request that fails hard +// (`EnvironmentRpcUnavailableError`) whenever the session is momentarily `None`, +// so the eager initial load below loses the race on a fresh mount / reconnect. If +// we only reloaded on `plugin-state-changed`, that failed initial load would never +// be retried until an install/uninstall happened to fire — leaving every plugin +// surface unregistered. Reloading on `welcome`/`ready` re-runs `listPlugins` as +// soon as the session connects, so the list self-heals. A redundant welcome+ready +// double-load returns the same list and is de-duplicated downstream by +// `keepLastKnownPluginList` + the per-environment cache. +function isPluginListRefreshLifecycleEvent( + event: Pick, +): boolean { + return event.type === "welcome" || event.type === "ready" || event.type === "plugins"; +} + +export function makePluginListStream( lifecycleEvents: Stream.Stream, - loadPlugins: Effect.Effect, E, R>, -): Stream.Stream, E, R> { + loadPlugins: Effect.Effect, +): Stream.Stream { const reloads = lifecycleEvents.pipe( - Stream.filter(isPluginStateChangedLifecycleEvent), + Stream.filter(isPluginListRefreshLifecycleEvent), Stream.mapEffect(() => loadPlugins), ); return Stream.concat(Stream.fromEffect(loadPlugins), reloads); } +// A failed refresh must NOT clear the plugin list. `PluginUiHost` drives the web +// route/sidebar/command registry off this list, so wiping it to empty on a +// transient `listPlugins` failure (e.g. a brief environment-connection blip) +// unregisters every plugin surface and makes plugin routes resolve to "not +// found". Carry the last successfully-loaded list forward: a `None` (failed +// refresh) keeps the previous value; only a `Some` (successful refresh) replaces +// it. Crucially, emit NOTHING until the first success — if the very first load of +// a (re-)subscribed stream fails (a blip during navigation), emitting an empty +// list would surface a real-looking `Success([])` that `resolvePluginListWithCache` +// would then cache, destroying the good last-known list. Emitting nothing instead +// leaves the result atom at its previous value / lets the persistent cache win. +export function keepLastKnownPluginList( + results: Stream.Stream>, E, R>, +): Stream.Stream, E, R> { + return results.pipe( + Stream.mapAccum( + () => Option.none>(), + (previous, next) => { + const current = Option.orElse(next, () => previous); + return [current, Option.match(current, { onNone: () => [], onSome: (value) => [value] })]; + }, + ), + ); +} + const environmentPluginListResultAtom = createEnvironmentRpcSubscriptionAtomFamily( connectionAtomRuntime, { @@ -90,32 +134,55 @@ const environmentPluginListResultAtom = createEnvironmentRpcSubscriptionAtomFami tag: WS_METHODS.subscribeServerLifecycle, transform: (stream) => { const loadPlugins = listPlugins().pipe( - Effect.map((result) => result.plugins), + Effect.map((result) => Option.some(result.plugins)), Effect.catchCause((cause) => Effect.logWarning("Could not refresh plugin list", { cause: Cause.pretty(cause), - }).pipe(Effect.as(EMPTY_PLUGIN_LIST)), + }).pipe(Effect.as(Option.none>())), ), ); - return makePluginListStream(stream, loadPlugins); + return keepLastKnownPluginList(makePluginListStream(stream, loadPlugins)); }, }, ); +// Keep the last successfully-loaded plugin list per environment so a transient +// empty does NOT unload every plugin surface. PluginUiHost drives the web +// route/sidebar/command registry off this list; a connection blip that errors +// the lifecycle subscription can reset the underlying result atom to Initial +// (dropping the previous success `AsyncResult.value` would otherwise surface), or +// briefly flicker the environment, which — without this cache — collapses the +// list to empty and makes every plugin route resolve to "not found". A genuine +// change always arrives as a fresh SUCCESSFUL load and overwrites the cache +// (an install/uninstall that leaves zero plugins is a successful empty list, so +// it clears correctly). Keyed by environmentId; only the caller's env matters. +export function resolvePluginListWithCache( + environmentId: string, + result: AsyncResult.AsyncResult, E>, + cache: Map>, +): ReadonlyArray { + const value = AsyncResult.value(result); + if (Option.isSome(value)) { + cache.set(environmentId, value.value); + return value.value; + } + return cache.get(environmentId) ?? EMPTY_PLUGIN_LIST; +} + +const lastKnownPluginListByEnvironment = new Map>(); + export const environmentPluginListAtom = Atom.family((environmentId: string) => - Atom.make( - (get): ReadonlyArray => - Option.getOrElse( - AsyncResult.value( - get( - environmentPluginListResultAtom({ - environmentId: environmentId as never, - input: {}, - }), - ), - ), - () => EMPTY_PLUGIN_LIST, + Atom.make((get): ReadonlyArray => + resolvePluginListWithCache( + environmentId, + get( + environmentPluginListResultAtom({ + environmentId: environmentId as never, + input: {}, + }), ), + lastKnownPluginListByEnvironment, + ), ).pipe(Atom.withLabel(`web-plugins:list:${environmentId}`)), ); diff --git a/fixtures/hello-board/manifest.json b/fixtures/hello-board/manifest.json index 43705c15956..d53b8c6dbb6 100644 --- a/fixtures/hello-board/manifest.json +++ b/fixtures/hello-board/manifest.json @@ -7,7 +7,7 @@ "name": "T3 Tools" }, "hostApi": "^1.0.0", - "capabilities": ["database"], + "capabilities": ["database", "filesystem", "httpClient"], "entries": { "server": "server/index.js", "web": "web/index.js" diff --git a/fixtures/hello-board/scripts/build.mjs b/fixtures/hello-board/scripts/build.mjs index 46fee20a636..a7a78b2f48c 100644 --- a/fixtures/hello-board/scripts/build.mjs +++ b/fixtures/hello-board/scripts/build.mjs @@ -1,31 +1,36 @@ -import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { gzipSync } from "node:zlib"; -import { spawnSync } from "node:child_process"; - -const fixtureRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const repoRoot = resolve(fixtureRoot, "../.."); +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeZlib from "node:zlib"; + +const fixtureRoot = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "..", +); +const repoRoot = NodePath.resolve(fixtureRoot, "../.."); function outDirFromArgs(argv) { const direct = argv.find((arg) => arg.startsWith("--out-dir=")); - if (direct) return resolve(fixtureRoot, direct.slice("--out-dir=".length)); + if (direct) return NodePath.resolve(fixtureRoot, direct.slice("--out-dir=".length)); const index = argv.indexOf("--out-dir"); - if (index >= 0 && argv[index + 1]) return resolve(fixtureRoot, argv[index + 1]); - return join(fixtureRoot, "dist"); + if (index >= 0 && argv[index + 1]) return NodePath.resolve(fixtureRoot, argv[index + 1]); + return NodePath.join(fixtureRoot, "dist"); } const outDir = outDirFromArgs(process.argv.slice(2)); -const packageDir = join(outDir, "package"); -const manifest = JSON.parse(readFileSync(join(fixtureRoot, "manifest.json"), "utf8")); +const packageDir = NodePath.join(outDir, "package"); +const manifest = JSON.parse( + NodeFS.readFileSync(NodePath.join(fixtureRoot, "manifest.json"), "utf8"), +); const tarballName = `${manifest.id}-${manifest.version}.tgz`; -const tarballPath = join(outDir, tarballName); +const tarballPath = NodePath.join(outDir, tarballName); const shaPath = `${tarballPath}.sha256`; -const marketplacePath = join(outDir, "marketplace.json"); +const marketplacePath = NodePath.join(outDir, "marketplace.json"); function run(command, args) { - const result = spawnSync(command, args, { + const result = NodeChildProcess.spawnSync(command, args, { cwd: repoRoot, stdio: "inherit", env: process.env, @@ -91,40 +96,55 @@ function tar(entries) { ]); } -rmSync(outDir, { recursive: true, force: true }); -mkdirSync(join(packageDir, "server"), { recursive: true }); -mkdirSync(join(packageDir, "web"), { recursive: true }); - -writeFileSync(join(packageDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); - -bundle(join(fixtureRoot, "server/index.ts"), join(packageDir, "server/index.js"), "node", [ - "@t3tools/plugin-sdk", - "effect", - "effect/*", -]); -bundle(join(fixtureRoot, "web/index.tsx"), join(packageDir, "web/index.js"), "browser", [ - "@effect/atom-react", - "@t3tools/plugin-sdk-web", - "effect", - "react", - "react/*", - "react-dom", - "react-dom/*", -]); - -const archive = gzipSync( +NodeFS.rmSync(outDir, { recursive: true, force: true }); +NodeFS.mkdirSync(NodePath.join(packageDir, "server"), { recursive: true }); +NodeFS.mkdirSync(NodePath.join(packageDir, "web"), { recursive: true }); + +NodeFS.writeFileSync( + NodePath.join(packageDir, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, +); + +bundle( + NodePath.join(fixtureRoot, "server/index.ts"), + NodePath.join(packageDir, "server/index.js"), + "node", + ["@t3tools/plugin-sdk", "effect", "effect/*"], +); +bundle( + NodePath.join(fixtureRoot, "web/index.tsx"), + NodePath.join(packageDir, "web/index.js"), + "browser", + [ + "@effect/atom-react", + "@t3tools/plugin-sdk-web", + "effect", + "react", + "react/*", + "react-dom", + "react-dom/*", + ], +); + +const archive = NodeZlib.gzipSync( tar([ - { name: "manifest.json", body: readFileSync(join(packageDir, "manifest.json")) }, - { name: "server/index.js", body: readFileSync(join(packageDir, "server/index.js")) }, - { name: "web/index.js", body: readFileSync(join(packageDir, "web/index.js")) }, + { + name: "manifest.json", + body: NodeFS.readFileSync(NodePath.join(packageDir, "manifest.json")), + }, + { + name: "server/index.js", + body: NodeFS.readFileSync(NodePath.join(packageDir, "server/index.js")), + }, + { name: "web/index.js", body: NodeFS.readFileSync(NodePath.join(packageDir, "web/index.js")) }, ]), { mtime: 0 }, ); -writeFileSync(tarballPath, archive); +NodeFS.writeFileSync(tarballPath, archive); -const sha256 = createHash("sha256").update(archive).digest("hex"); -writeFileSync(shaPath, `${sha256} ${tarballName}\n`); -writeFileSync( +const sha256 = NodeCrypto.createHash("sha256").update(archive).digest("hex"); +NodeFS.writeFileSync(shaPath, `${sha256} ${tarballName}\n`); +NodeFS.writeFileSync( marketplacePath, `${JSON.stringify( { @@ -138,7 +158,7 @@ writeFileSync( versions: [ { version: manifest.version, - tarball: pathToFileURL(tarballPath).href, + tarball: NodeURL.pathToFileURL(tarballPath).href, sha256, hostApi: manifest.hostApi, publishedAt: "2026-07-03T00:00:00.000Z", diff --git a/fixtures/hello-board/server/index.ts b/fixtures/hello-board/server/index.ts index bdb1d6fa23e..f95530396b8 100644 --- a/fixtures/hello-board/server/index.ts +++ b/fixtures/hello-board/server/index.ts @@ -42,7 +42,9 @@ function noteBodyFromPayload(payload: unknown): Effect.Effect { export default definePlugin({ register: (hostApi) => Effect.gen(function* () { - const database = yield* Effect.mapError(hostApi.database, toPluginError); + const database = yield* hostApi.database; + const filesystem = yield* hostApi.filesystem; + const httpClient = yield* hostApi.httpClient; return { migrations: [ @@ -76,7 +78,7 @@ export default definePlugin({ { method: "addNote", scope: "operate" as const, - handler: (payload) => + handler: (payload: unknown) => Effect.gen(function* () { const body = yield* noteBodyFromPayload(payload); const rows = yield* database.execute( @@ -90,7 +92,39 @@ export default definePlugin({ return rows[0] ?? { body }; }), }, + { + method: "exerciseCapabilities", + scope: "operate" as const, + handler: () => + Effect.gen(function* () { + const roots = yield* filesystem.listRoots(); + const root = roots[0]; + if (!root) { + return yield* Effect.fail( + new HelloBoardPluginError("expected at least one filesystem root"), + ); + } + yield* filesystem.writeFileString({ + root, + relativePath: ".hello-board/capability.txt", + contents: "hello filesystem", + }); + const file = yield* filesystem.readFileString({ + root, + relativePath: ".hello-board/capability.txt", + }); + const response = yield* httpClient.request({ + method: "GET", + url: "https://fixture.test/ping", + }); + return { + file, + status: response.status, + body: new TextDecoder().decode(response.body), + }; + }), + }, ], }; - }), + }).pipe(Effect.mapError(toPluginError)), }); diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 4ea86670ff8..805fc672fbd 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { VcsCreateWorktreeInput, + GitCommandError, GitPreparePullRequestThreadInput, GitRunStackedActionResult, GitRunStackedActionInput, @@ -86,6 +87,36 @@ describe("GitRunStackedActionInput", () => { }); }); +describe("GitCommandError", () => { + it("keeps raw stderr readable in-process but off the wire", () => { + const secret = "https://user:hunter2-token@github.com/o/r.git"; + const error = GitCommandError.withStderr( + { + operation: "GitVcsDriver.push", + command: "git", + cwd: "/repo", + argumentCount: 2, + exitCode: 128, + detail: "Git command exited with a non-zero status.", + }, + `fatal: could not read from '${secret}'`, + ); + + // In-process consumers (failure classification) can read the raw text. + expect(error.stderr).toContain("hunter2-token"); + expect(error.stderrLength).toBeGreaterThan(0); + expect(error.stderrTruncated).toBe(false); + + // The wire-encoded payload and naive JSON serialization both drop it: + // only stderrLength/stderrTruncated cross the boundary. + const encoded = Schema.encodeSync(GitCommandError)(error); + expect(encoded).not.toHaveProperty("stderr"); + expect(JSON.stringify(encoded)).not.toContain("hunter2-token"); + expect(JSON.stringify(error)).not.toContain("hunter2-token"); + expect(error.message).not.toContain("hunter2-token"); + }); +}); + describe("GitRunStackedActionResult", () => { it("decodes a server-authored completion toast", () => { const parsed = decodeRunStackedActionResult({ diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index aa5cdf8432b..b0fd13861ea 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { capErrorStderr, ERROR_STDERR_MAX_CHARS } from "./internal/errorStderr.ts"; import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; @@ -320,6 +321,7 @@ export const VcsPullResult = Schema.Struct({ export type VcsPullResult = typeof VcsPullResult.Type; // RPC / domain errors + export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { operation: Schema.String, command: Schema.String, @@ -328,13 +330,53 @@ export class GitCommandError extends Schema.TaggedErrorClass()( exitCode: Schema.optional(Schema.Number), stdoutLength: Schema.optional(Schema.Number), stderrLength: Schema.optional(Schema.Number), + stderrTruncated: Schema.optional(Schema.Boolean), outputLength: Schema.optional(Schema.Number), detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) { + /** + * Raw (length-capped) stderr for in-process consumers such as failure + * classification. Deliberately NOT a schema field: git stderr can carry + * credentialed remote URLs or token-echoing auth failures, and this class + * is part of wire-serialized RPC error unions (e.g. + * `ReviewDiffPreviewError`). The private-field-plus-getter shape keeps it + * out of the schema-encoded payload, `JSON.stringify`, and + * `Schema.Defect()` cause encoding, while `error.stderr` stays readable on + * the server before serialization. Only `stderrLength`/`stderrTruncated` + * cross the wire. See `VcsProcessExitError.stderr` for the same pattern. + */ + #stderr: string | undefined; + + get stderr(): string | undefined { + return this.#stderr; + } + override get message(): string { return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}`; } + + /** + * Construct a `GitCommandError` carrying raw capped stderr as a + * server-side-only, non-serialized property (see `stderr`). Also derives + * the wire-visible `stderrLength`/`stderrTruncated` fields from the raw + * value. + */ + static withStderr( + props: Omit< + ConstructorParameters[0], + "stderrLength" | "stderrTruncated" + >, + stderr: string, + ): GitCommandError { + const error = new GitCommandError({ + ...props, + stderrLength: stderr.length, + stderrTruncated: stderr.length > ERROR_STDERR_MAX_CHARS, + }); + error.#stderr = capErrorStderr(stderr); + return error; + } } export class TextGenerationError extends Schema.TaggedErrorClass()( diff --git a/packages/contracts/src/internal/errorStderr.ts b/packages/contracts/src/internal/errorStderr.ts new file mode 100644 index 00000000000..14cda99c6a2 --- /dev/null +++ b/packages/contracts/src/internal/errorStderr.ts @@ -0,0 +1,14 @@ +/** + * Shared cap for the raw stderr retained on process-exit error classes + * (`GitCommandError`, `VcsProcessExitError`). Bounds memory held by + * long-lived error values while keeping the classification-relevant head of + * the output (branch-protection / non-fast-forward text appears early). + * + * The capped value is stored OFF the error schemas (a private field behind a + * getter) so it stays server-side only — see the `stderr` getters on those + * classes for the redaction rationale. + */ +export const ERROR_STDERR_MAX_CHARS = 8_192; + +export const capErrorStderr = (stderr: string): string => + stderr.length > ERROR_STDERR_MAX_CHARS ? stderr.slice(0, ERROR_STDERR_MAX_CHARS) : stderr; diff --git a/packages/contracts/src/plugin.test.ts b/packages/contracts/src/plugin.test.ts index 45bd75f8d41..58a68a45047 100644 --- a/packages/contracts/src/plugin.test.ts +++ b/packages/contracts/src/plugin.test.ts @@ -7,7 +7,9 @@ import { PluginInstallStaged, PluginLockfile, PluginManifest, + compareSemver, hostApiSatisfies, + isPrereleaseVersion, } from "./plugin.ts"; const decodeManifest = Schema.decodeUnknownSync(PluginManifest); @@ -40,11 +42,11 @@ describe("PluginManifest", () => { homepage: "https://example.test/plugin", license: "MIT", minAppVersion: "1.0.0", - capabilities: ["agents", "database"], + capabilities: ["agents", "database", "filesystem", "httpClient"], entries: { server: "dist/server.js", web: "dist/web.js" }, }); expect(decoded.name).toBe("Test Plugin"); - expect(decoded.capabilities).toEqual(["agents", "database"]); + expect(decoded.capabilities).toEqual(["agents", "database", "filesystem", "httpClient"]); }); it.each(["x", "1test-plugin", "test_plugin", "Test-Plugin", "a".repeat(42)])( @@ -94,6 +96,60 @@ describe("PluginManifest", () => { it("rejects bad hostApi ranges", () => { expect(() => decodeManifest({ ...minimalManifest, hostApi: ">=1.0.0" })).toThrow(); }); + + it.each([ + "javascript:alert(1)", + "data:text/html,", + "file:///etc/passwd", + "not a url", + ])("rejects non-http(s) author and homepage URLs: %s", (url) => { + expect(() => + decodeManifest({ ...minimalManifest, author: { name: "Mallory", url } }), + ).toThrow(); + expect(() => decodeManifest({ ...minimalManifest, homepage: url })).toThrow(); + }); + + it("accepts http(s) author and homepage URLs", () => { + const decoded = decodeManifest({ + ...minimalManifest, + author: { name: "T3", url: "https://example.test/team" }, + homepage: "http://example.test/plugin", + }); + expect(decoded.author?.url).toBe("https://example.test/team"); + expect(decoded.homepage).toBe("http://example.test/plugin"); + }); +}); + +describe("MarketplaceEntry author url", () => { + it("rejects javascript: author URLs in marketplace entries", () => { + expect(() => + decodeMarketplaceEntry({ + id: "test-plugin", + name: "Test Plugin", + description: "Adds test plugin behavior.", + author: { name: "Mallory", url: "javascript:alert(1)" }, + capabilities: [], + versions: [], + }), + ).toThrow(); + }); +}); + +describe("compareSemver", () => { + it("orders by semver precedence with prerelease support", () => { + expect(compareSemver("1.0.0", "0.9.9")).toBeGreaterThan(0); + expect(compareSemver("1.0.0-rc.1", "1.0.0")).toBeLessThan(0); + expect(compareSemver("1.0.0-rc.2", "1.0.0-rc.10")).toBeLessThan(0); + expect(compareSemver("1.0.0+build1", "1.0.0+build2")).toBe(0); + }); +}); + +describe("isPrereleaseVersion", () => { + it("detects prerelease components and ignores build metadata", () => { + expect(isPrereleaseVersion("1.1.0-rc.1")).toBe(true); + expect(isPrereleaseVersion("1.1.0")).toBe(false); + expect(isPrereleaseVersion("1.1.0+build5")).toBe(false); + }); }); describe("hostApiSatisfies", () => { @@ -167,9 +223,14 @@ describe("PluginInstallStaged", () => { }, capabilityDescriptions: { agents: "Run AI agents", + filesystem: + "Read and write files in your project workspace and in worktrees this plugin creates", + httpClient: "Make requests to public external HTTPS services", }, }); expect(decoded.capabilityDescriptions.agents).toBe("Run AI agents"); + expect(decoded.capabilityDescriptions.filesystem).toContain("Read and write files"); + expect(decoded.capabilityDescriptions.httpClient).toContain("HTTPS services"); }); }); diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts index 4de903526e7..b3c91935b5b 100644 --- a/packages/contracts/src/plugin.ts +++ b/packages/contracts/src/plugin.ts @@ -24,6 +24,8 @@ export const PluginCapability = Schema.Literals([ "environments.read", "secrets", "http", + "filesystem", + "httpClient", "sourceControl", "textGeneration", ]); @@ -31,7 +33,26 @@ 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))); +// author.url / homepage are rendered as in the marketplace UI, so the +// scheme is gated to http(s) at decode: a marketplace entry or manifest must +// not be able to smuggle a `javascript:`/`data:`/`file:` URI into a clickable +// link. +const OptionalUrl = Schema.optionalKey( + TrimmedNonEmptyString.check( + Schema.isMaxLength(2048), + Schema.makeFilter((value) => { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return "must be an absolute http(s) URL"; + } + return parsed.protocol === "http:" || parsed.protocol === "https:" + ? true + : "must use the http or https scheme"; + }), + ), +); const Sha256Hex = TrimmedNonEmptyString.check(Schema.isPattern(/^[a-f0-9]{64}$/i)); const RelativeEntryPath = TrimmedNonEmptyString.check( @@ -49,9 +70,14 @@ const RelativeEntryPath = TrimmedNonEmptyString.check( const ManifestEntries = Schema.Struct({ server: Schema.optionalKey(RelativeEntryPath), web: Schema.optionalKey(RelativeEntryPath), + // Optional compiled stylesheet shipped alongside the web bundle. The host + // injects it (as a ) when the plugin's web surface loads, so a plugin can + // ship its own CSS instead of relying on the host build to emit its classes. + styles: 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", + Schema.makeFilter<{ readonly server?: string; readonly web?: string; readonly styles?: string }>( + (entries) => + entries.server || entries.web ? true : "manifest entries must include server or web", ), ); export type PluginManifestEntries = typeof ManifestEntries.Type; @@ -135,6 +161,9 @@ export const PluginInfo = Schema.Struct({ state: PluginState, capabilities: Schema.Array(PluginCapability), hasWeb: Schema.Boolean, + // Whether the plugin ships a compiled stylesheet (manifest entries.styles) the + // host should inject when the web surface loads. + hasStyles: Schema.Boolean, lastError: Schema.NullOr(Schema.String), }); export type PluginInfo = typeof PluginInfo.Type; @@ -378,12 +407,77 @@ function parseStrictSemver(value: string): ParsedSemver | null { }; } -function compareSemver(left: ParsedSemver, right: ParsedSemver): number { +function compareStrictSemver(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; } +// Parse a version into its numeric core and prerelease identifiers, ignoring +// build metadata (after `+`), which does not affect precedence (semver.org +// §10). Missing/short numeric core fields are treated as 0, matching the plain +// `x.y.z` inputs the minAppVersion comparisons still pass in. +const parseSemverPrecedence = (value: string) => { + const withoutBuild = value.split("+")[0] ?? ""; + const dashIndex = withoutBuild.indexOf("-"); + const core = dashIndex === -1 ? withoutBuild : withoutBuild.slice(0, dashIndex); + const prerelease = dashIndex === -1 ? "" : withoutBuild.slice(dashIndex + 1); + const coreParts = core.split("."); + const numericAt = (index: number) => { + const parsed = Number.parseInt(coreParts[index] ?? "", 10); + return Number.isFinite(parsed) ? parsed : 0; + }; + return { + major: numericAt(0), + minor: numericAt(1), + patch: numericAt(2), + prerelease: prerelease.length === 0 ? [] : prerelease.split("."), + }; +}; + +// Compare two prerelease identifiers per semver.org §11: numeric identifiers +// compare numerically and always rank BELOW non-numeric ones; two non-numeric +// identifiers compare by ASCII. +const compareIdentifier = (left: string, right: string) => { + const leftNumeric = /^\d+$/u.test(left); + const rightNumeric = /^\d+$/u.test(right); + if (leftNumeric && rightNumeric) { + return Number.parseInt(left, 10) - Number.parseInt(right, 10); + } + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return left < right ? -1 : left > right ? 1 : 0; +}; + +// Semver-precedence-correct comparator (semver.org §11). Returns +// negative/zero/positive. Callers rely on sign only. +export const compareSemver = (left: string, right: string): number => { + const leftVersion = parseSemverPrecedence(left); + const rightVersion = parseSemverPrecedence(right); + if (leftVersion.major !== rightVersion.major) return leftVersion.major - rightVersion.major; + if (leftVersion.minor !== rightVersion.minor) return leftVersion.minor - rightVersion.minor; + if (leftVersion.patch !== rightVersion.patch) return leftVersion.patch - rightVersion.patch; + const leftPre = leftVersion.prerelease; + const rightPre = rightVersion.prerelease; + // Equal core: a version WITH a prerelease has LOWER precedence than one + // WITHOUT. + if (leftPre.length === 0 && rightPre.length === 0) return 0; + if (leftPre.length === 0) return 1; + if (rightPre.length === 0) return -1; + // Compare dot-separated identifiers left-to-right; when all preceding + // identifiers are equal, the larger set of fields has higher precedence. + const shared = Math.min(leftPre.length, rightPre.length); + for (let index = 0; index < shared; index++) { + const diff = compareIdentifier(leftPre[index] ?? "", rightPre[index] ?? ""); + if (diff !== 0) return diff; + } + return leftPre.length - rightPre.length; +}; + +/** True when a version carries a prerelease component (e.g. `1.1.0-rc.1`). */ +export const isPrereleaseVersion = (version: string): boolean => + parseSemverPrecedence(version).prerelease.length > 0; + export function hostApiSatisfies(range: string, version: string): boolean { const trimmedRange = range.trim(); const operator = @@ -392,7 +486,7 @@ export function hostApiSatisfies(range: string, version: string): boolean { const actual = parseStrictSemver(version); if (!target || !actual) return false; - const compared = compareSemver(actual, target); + const compared = compareStrictSemver(actual, target); if (operator === "") return compared === 0; if (compared < 0) return false; diff --git a/packages/contracts/src/vcs.test.ts b/packages/contracts/src/vcs.test.ts new file mode 100644 index 00000000000..0845a17f805 --- /dev/null +++ b/packages/contracts/src/vcs.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { VcsProcessExitError } from "./vcs.ts"; + +const encodeVcsProcessExitError = Schema.encodeSync(VcsProcessExitError); + +describe("VcsProcessExitError", () => { + it("keeps raw stderr readable in-process but off the wire", () => { + const secret = "x-access-token:ghp_super-secret-token"; + const error = VcsProcessExitError.fromProcessExit( + { operation: "GitVcsDriver.push", command: "git", cwd: "/repo", argumentCount: 3 }, + { + exitCode: 128, + stderr: `fatal: unable to access 'https://${secret}@github.com/o/r.git/': authentication failed`, + stderrTruncated: false, + }, + "authentication", + ); + + // In-process consumers (failure classification) can read the raw text. + expect(error.stderr).toContain("authentication failed"); + expect(error.stderr).toContain(secret); + expect(error.stderrLength).toBeGreaterThan(0); + expect(error.stderrTruncated).toBe(false); + + // The wire-encoded payload and naive JSON serialization both drop it: + // only stderrLength/stderrTruncated cross the boundary. + const encoded = encodeVcsProcessExitError(error); + expect(encoded).not.toHaveProperty("stderr"); + expect(JSON.stringify(encoded)).not.toContain("ghp_super-secret-token"); + expect(JSON.stringify(error)).not.toContain("ghp_super-secret-token"); + expect(error.message).not.toContain("ghp_super-secret-token"); + }); + + it("caps the retained stderr while reporting the full length", () => { + const error = VcsProcessExitError.fromProcessExit( + { operation: "op", command: "git", cwd: "/repo" }, + { exitCode: 1, stderr: "e".repeat(10_000), stderrTruncated: false }, + "command-failed", + ); + + expect(error.stderr?.length).toBe(8_192); + expect(error.stderrLength).toBe(10_000); + }); +}); diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index c1090f4f39a..80ab9cc727e 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { capErrorStderr } from "./internal/errorStderr.ts"; export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); export type VcsDriverKind = typeof VcsDriverKind.Type; @@ -122,6 +123,26 @@ export class VcsProcessExitError extends Schema.TaggedErrorClass { it("keeps host singleton dependencies external for plugin builds", () => { expect(pluginSdkWebExternalDependencies).toEqual( - expect.arrayContaining(["@effect/atom-react", "effect", "react", "react-dom"]), + expect.arrayContaining([ + "@effect/atom-react", + "@t3tools/contracts", + "@t3tools/plugin-sdk-web", + "effect", + "react", + "react-dom", + ]), ); }); diff --git a/packages/plugin-sdk-web/src/index.ts b/packages/plugin-sdk-web/src/index.ts index b33150a66e2..5d9250fce93 100644 --- a/packages/plugin-sdk-web/src/index.ts +++ b/packages/plugin-sdk-web/src/index.ts @@ -64,6 +64,46 @@ export { } from "../../../apps/web/src/components/chat/TraitsPicker.tsx"; export { useAtomCommand } from "../../../apps/web/src/state/use-atom-command.ts"; export { useAtomQueryRunner } from "../../../apps/web/src/state/use-atom-query-runner.ts"; +// Environment/project context so a plugin surface can resolve the active +// project(s) for the environment it renders in (e.g. the board list needs a real +// projectId — the environment id is NOT a project id). +export { useEnvironmentProjectRefs } from "../../../apps/web/src/state/entities.ts"; +// Schema-error formatting from @t3tools/shared. Re-exported through the SDK +// singleton so plugins don't bundle @t3tools/shared/schemaJson (which imports +// effect/* subpaths the browser import map can't resolve). +export { formatSchemaError } from "@t3tools/shared/schemaJson"; + +// Host UI/util surface available to plugin web bundles. These are re-exports of +// live host modules — a separately-built plugin externalises +// `@t3tools/plugin-sdk-web`, so at runtime it shares the host's +// singleton instances (React, atoms, settings, provider state) through the import +// map rather than bundling its own copies. +export { cn, randomUUID } from "../../../apps/web/src/lib/utils.ts"; +export { useTheme } from "../../../apps/web/src/hooks/useTheme.ts"; +export { usePrimarySettings } from "../../../apps/web/src/hooks/useSettings.ts"; +export { formatDuration } from "../../../apps/web/src/session-logic.ts"; +export { primaryServerProvidersAtom } from "../../../apps/web/src/state/server.ts"; +export { + deriveProviderInstanceEntries, + sortProviderInstanceEntries, +} from "../../../apps/web/src/providerInstances.ts"; +export { + getAppModelOptionsForInstance, + type AppModelOption, +} from "../../../apps/web/src/modelSelection.ts"; +// Diff-rendering stack (ticket diffs). `FileDiff` comes from `@pierre/diffs/react` +// (the host already depends on it for chat diffs) and relies on the host's +// worker-pool context provider being mounted around the app. +export { DiffStatLabel } from "../../../apps/web/src/components/chat/DiffStatLabel.tsx"; +export { + buildFileDiffRenderKey, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, + type RenderablePatch, + type DiffThemeName, +} from "../../../apps/web/src/lib/diffRendering.ts"; +export { FileDiff } from "@pierre/diffs/react"; export const hostCompat = { hostApiVersion: HOST_API_VERSION, @@ -78,6 +118,7 @@ export interface PluginUiContext { readonly registerSidebarSection: (registration: PluginSidebarSectionRegistration) => void; readonly registerSettingsPage: (registration: PluginSettingsPageRegistration) => void; readonly registerCommand: (registration: PluginCommandRegistration) => void; + readonly registerProjectAction: (registration: PluginProjectActionRegistration) => void; } export interface PluginWebLogger { @@ -100,6 +141,14 @@ export type PluginComponent> = (props: Props) => u export interface PluginRouteComponentProps { readonly pluginId: PluginId; readonly path: string; + // The active environment id from the route (`//p//...`), + // or null if unavailable. Plugin routes need it to scope host-side references + // (e.g. project/ticket cwd) even though `rpc` is already environment-bound. + readonly environmentId: string | null; + // The route's search params (string values), so a plugin route can read its own + // navigation state (e.g. `?boardId=...&ticket=...`) without touching the host + // router. Navigate by rendering `` off the sidebar `routeBasePath`. + readonly search: Readonly>; } export interface PluginRouteRegistration { @@ -119,6 +168,25 @@ export interface PluginSidebarSectionRegistration { readonly render: (props: PluginSidebarSectionRenderProps) => unknown; } +export interface PluginProjectActionRenderProps { + readonly pluginId: PluginId; + readonly environmentId: string; + readonly projectId: string; + readonly projectName: string; + // The plugin's route base for this environment (`//p/`), for + // navigating to plugin routes after the action runs. + readonly routeBasePath: string | null; +} + +// A per-project action the host renders inline in each project row (alongside the +// built-in "New thread" button). The plugin's `render` returns its own trigger +// (e.g. an icon button) and may manage its own dialog; the host provides project +// context. Return null to render nothing for a given project. +export interface PluginProjectActionRegistration { + readonly id: string; + readonly render: (props: PluginProjectActionRenderProps) => unknown; +} + export interface PluginSettingsComponentProps { readonly pluginId: PluginId; readonly pageId: string; @@ -157,5 +225,6 @@ export interface PluginWebRegistration { readonly sidebarSections?: ReadonlyArray; readonly settingsPages?: ReadonlyArray; readonly commands?: ReadonlyArray; + readonly projectActions?: ReadonlyArray; readonly providers?: (context: PluginUiContext) => unknown; } diff --git a/packages/plugin-sdk-web/vite.config.ts b/packages/plugin-sdk-web/vite.config.ts index e7c3e654e1d..6be131fdce2 100644 --- a/packages/plugin-sdk-web/vite.config.ts +++ b/packages/plugin-sdk-web/vite.config.ts @@ -1,10 +1,11 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vite-plus"; -import { isPluginSdkWebExternal } from "./src/externals"; - const webSrc = fileURLToPath(new URL("../../apps/web/src", import.meta.url)); +// No `build` config on purpose: this package is a virtual compile-time surface +// (see README) that resolves through the host app's Vite graph — it is never +// built standalone. This config only serves the package's own tests. export default defineConfig({ resolve: { alias: { @@ -12,16 +13,6 @@ export default defineConfig({ }, dedupe: ["react", "react-dom"], }, - build: { - lib: { - entry: "src/index.ts", - formats: ["es"], - fileName: "index", - }, - rollupOptions: { - external: isPluginSdkWebExternal, - }, - }, test: { include: ["src/**/*.test.{ts,tsx}"], }, diff --git a/packages/plugin-sdk/src/index.test.ts b/packages/plugin-sdk/src/index.test.ts index 36521a15e0e..7754cd6aa33 100644 --- a/packages/plugin-sdk/src/index.test.ts +++ b/packages/plugin-sdk/src/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Effect from "effect/Effect"; -import { definePlugin, HOST_API_VERSION } from "./index.ts"; +import { definePlugin, HOST_API_VERSION, writeFileAtomic } from "./index.ts"; describe("definePlugin", () => { it("preserves the plugin definition shape", () => { @@ -12,4 +12,51 @@ describe("definePlugin", () => { expect(typeof definition.register).toBe("function"); expect(HOST_API_VERSION).toBe("1.0.0"); }); + + it("writeFileAtomic writes a sibling temp file then renames it into place", async () => { + // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- plugin-sdk uses vite-plus/test and does not depend on @effect/vitest. + await Effect.runPromise( + Effect.gen(function* () { + const operations: string[] = []; + const fs = { + writeFile: ({ + relativePath, + contents, + }: { + readonly relativePath: string; + readonly contents: Uint8Array; + }) => + Effect.sync(() => { + operations.push(`write:${relativePath}:${new TextDecoder().decode(contents)}`); + }), + rename: ({ + fromRelativePath, + toRelativePath, + }: { + readonly fromRelativePath: string; + readonly toRelativePath: string; + }) => + Effect.sync(() => { + operations.push(`rename:${fromRelativePath}->${toRelativePath}`); + }), + remove: ({ relativePath }: { readonly relativePath: string }) => + Effect.sync(() => { + operations.push(`remove:${relativePath}`); + }), + } as any; + + yield* writeFileAtomic(fs, { + root: "/repo", + relativePath: "notes/today.md", + contents: "hello", + }); + + expect(operations).toHaveLength(2); + expect(operations[0]).toMatch(/^write:notes\/\.today\.md\.[a-z0-9-]+\.tmp:hello$/); + expect(operations[1]).toMatch( + /^rename:notes\/\.today\.md\.[a-z0-9-]+\.tmp->notes\/today\.md$/, + ); + }), + ); + }); }); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index d2ed09bb09b..231466134af 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -2,6 +2,7 @@ import type { ChangeRequestState, ChatAttachment, CheckpointRef, + CommandId, EnvironmentId, ExecutionEnvironmentDescriptor, MessageId, @@ -32,7 +33,9 @@ import type { VcsStatusResult, VcsSwitchRefResult, } from "@t3tools/contracts"; -import type * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Random from "effect/Random"; import type * as SqlClient from "effect/unstable/sql/SqlClient"; import type * as Stream from "effect/Stream"; @@ -120,8 +123,11 @@ export interface AgentsCapability { 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 + * Convenience read for the approval and user-input requests in + * `thread.activities[]` that are STILL open: requests with a matching + * `approval.resolved` / `user-input.resolved` (or a stale/unknown respond + * failure) are filtered out, so an already-answered request is never + * re-surfaced for a double submit. The same requests are also visible via * `observeThread` snapshots and events. */ readonly listPendingRequests: ( @@ -158,9 +164,12 @@ export interface VcsCapability { /** * 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. + * Every `worktreePath` / `repoRoot` must resolve (real-path, symlink-safe) + * inside the plugin's granted workspace roots: the projected project + * workspace roots plus worktrees this plugin created via `createWorktree`. + * Sub-paths (`removePath` / `clean` / `commit.filePaths`) must stay inside + * the worktree, and ref/branch/remote names are validated against git + * option injection (a ref may never begin with `-`). */ readonly status: (input: VcsWorktreeInput) => Effect.Effect; @@ -172,13 +181,18 @@ export interface VcsCapability { /** * Create a Git worktree for a ref. No lease concept is exposed because the * backing VCS layer does not implement leases. + * + * `repoRoot` must be a granted root; the new worktree `path` must live + * inside a granted root or inside the server-managed worktrees directory. + * On success the worktree path is granted, admitting every later vcs (and + * filesystem) operation on it. */ readonly createWorktree: ( input: VcsCreateWorktreeFacadeInput, ) => Effect.Effect; /** - * Remove a Git worktree by absolute path. + * Remove a Git worktree by absolute path and revoke its grant. */ readonly removeWorktree: (input: VcsRemoveWorktreeFacadeInput) => Effect.Effect; @@ -192,6 +206,32 @@ export interface VcsCapability { */ readonly switchRef: (input: VcsSwitchRefFacadeInput) => Effect.Effect; + /** + * Remove a tracked path from the index and working tree. Missing paths are + * ignored by the backing Git command. + */ + readonly removePath: (input: VcsPathInput) => Effect.Effect; + + /** + * Remove untracked files and directories at a path. + */ + readonly clean: (input: VcsPathInput) => Effect.Effect; + + /** + * Read the current branch name, or "HEAD" when detached. + */ + readonly currentBranch: (input: VcsWorktreeInput) => Effect.Effect; + + /** + * Count commits reachable from `head` but not `base`. + */ + readonly aheadCount: (input: VcsAheadCountInput) => Effect.Effect; + + /** + * List local and remote refs for a repository root. + */ + readonly listRefs: (input: VcsRepoInput) => Effect.Effect, Error>; + /** * Stage selected paths, or all changes when `filePaths` is omitted, then * create a commit. No-change commits are surfaced as a skipped value. @@ -219,6 +259,17 @@ export interface VcsCapability { */ readonly diffRefs: (input: VcsDiffRefsInput) => Effect.Effect; + /** + * Read the patch between an arbitrary base ref and the current working tree + * (tracked, uncommitted), optionally including untracked files as add-diffs. + * Unlike `diffRefs` (ref..ref) and `workingTreeDiff` (against HEAD), this + * diffs a caller-supplied base commit against the live working tree — needed + * when the base is an out-of-band ref that never moved HEAD. + */ + readonly diffRefToWorkingTree: ( + input: VcsDiffRefToWorkingTreeInput, + ) => Effect.Effect; + /** * Capture a filesystem checkpoint at a caller-provided Git ref. */ @@ -280,6 +331,14 @@ export interface TerminalsCapability { } export interface DatabaseCapability { + /** + * The raw Effect SqlClient bound to the shared database. Full-trust: runtime + * SQL is unpoliced (the p__ namespace gate is migration-time only), so + * this grants no power beyond `execute`. Provided for plugins whose ported + * code uses tagged-template SQL / composable fragments / withTransaction. + */ + readonly client: SqlClient.SqlClient; + /** * Execute trusted plugin SQL and return decoded row objects. * @@ -333,6 +392,13 @@ export interface ProjectionsReadCapability { readonly limit?: number; }) => Effect.Effect, Error>; + /** + * Read a projected thread message directly by message id. + */ + readonly getMessageById: ( + messageId: MessageId, + ) => Effect.Effect; + /** * List projected thread activities in runtime sequence order with a bounded * result cap. @@ -407,6 +473,163 @@ export interface HttpCapability { readonly basePath: string; } +export interface FilesystemPathInput { + readonly root: string; + readonly relativePath: string; +} + +export interface FilesystemRenameInput { + readonly root: string; + readonly fromRelativePath: string; + readonly toRelativePath: string; + /** + * Permit atomically replacing an existing destination. Defaults to `false`, in + * which case a rename onto an existing path is rejected. Set `true` for + * atomic-replace flows such as {@link writeFileAtomic}, where a temp file must + * be renamed over the (possibly already-present) target on every update. + */ + readonly overwrite?: boolean | undefined; +} + +export interface FileStat { + readonly type: "file" | "directory" | "other"; + readonly size: number; + readonly mtime: number; + readonly realPath?: string; +} + +export interface DirEntry { + readonly name: string; + readonly relativePath: string; + readonly type: "file" | "directory" | "other"; +} + +export interface FilesystemCapability { + /** + * List currently granted absolute roots. This includes active project + * workspaces plus worktrees this plugin created through the VCS capability. + */ + readonly listRoots: () => Effect.Effect, Error>; + + /** + * Read a file as bytes. The host enforces a 16 MiB hard cap. + */ + readonly readFile: (input: FilesystemPathInput) => Effect.Effect; + + /** + * Read a UTF-8 file as a string. The host enforces a 16 MiB hard cap. + */ + readonly readFileString: (input: FilesystemPathInput) => Effect.Effect; + + /** + * Read at most maxBytes from a UTF-8 file as a string. + */ + readonly readFileStringCapped: ( + input: FilesystemPathInput & { readonly maxBytes: number }, + ) => Effect.Effect; + + /** + * Write bytes to a file, creating missing parent directories after each + * parent has been validated inside the granted root. + */ + readonly writeFile: ( + input: FilesystemPathInput & { readonly contents: Uint8Array }, + ) => Effect.Effect; + + /** + * Write a UTF-8 string to a file. + */ + readonly writeFileString: ( + input: FilesystemPathInput & { readonly contents: string }, + ) => Effect.Effect; + + /** + * Create a file and fail if the final path already exists. + */ + readonly createFileExclusive: ( + input: FilesystemPathInput & { readonly contents: string | Uint8Array }, + ) => Effect.Effect; + + /** + * Test whether a path exists within a granted root. + */ + readonly exists: (input: FilesystemPathInput) => Effect.Effect; + + /** + * Read file metadata. + */ + readonly stat: (input: FilesystemPathInput) => Effect.Effect; + + /** + * List direct directory children. + */ + readonly listDir: (input: FilesystemPathInput) => Effect.Effect, Error>; + + /** + * Recursively list directory children. The host caps results at 500 entries. + */ + readonly listDirRecursive: ( + input: FilesystemPathInput, + ) => Effect.Effect, Error>; + + /** + * Create a directory path segment by segment after validating each parent. + */ + readonly makeDirectory: (input: FilesystemPathInput) => Effect.Effect; + + /** + * Remove a file or directory. Missing paths are treated as already removed. + */ + readonly remove: (input: FilesystemPathInput) => Effect.Effect; + + /** + * Rename within a granted root. Cross-root renames are rejected. Overwriting an + * existing destination is rejected unless `input.overwrite` is `true`. + */ + readonly rename: (input: FilesystemRenameInput) => Effect.Effect; +} + +export interface HttpClientRequestInput { + readonly method: string; + readonly url: string; + readonly headers?: Readonly> | undefined; + readonly body?: string | Uint8Array | undefined; + readonly maxResponseBytes?: number | undefined; + readonly timeoutMs?: number | undefined; +} + +export interface HttpClientResponseResult { + readonly status: number; + readonly headers: Readonly>; + readonly body: Uint8Array; +} + +export interface HttpClientCapability { + /** + * Make an outbound request. The host allows public HTTPS targets by default, + * does not follow redirects, caps responses to 8 MiB by default / 32 MiB + * hard, and caps timeouts to 30s by default / 120s hard. + */ + readonly request: ( + input: HttpClientRequestInput, + ) => Effect.Effect; + + /** + * Request JSON and parse the response body. + */ + readonly requestJson: ( + input: Omit & { readonly body?: unknown }, + ) => Effect.Effect; + + /** + * Convenience JSON GET wrapper. + */ + readonly getJson: ( + url: string, + input?: Omit, + ) => Effect.Effect; +} + export interface SourceControlCapability { /** * Detect the source-control provider context for a repository root. @@ -424,9 +647,7 @@ export interface SourceControlCapability { >; /** - * 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. + * List open GitHub pull requests for a head selector. */ readonly listOpenPullRequests: (input: { readonly cwd: string; @@ -442,6 +663,14 @@ export interface SourceControlCapability { readonly reference: string; }) => Effect.Effect; + /** + * Read repository clone URLs from GitHub. + */ + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + /** * Create a GitHub pull request using a body file already present on disk. */ @@ -451,8 +680,51 @@ export interface SourceControlCapability { readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly draft?: boolean | undefined; + }) => Effect.Effect; + + /** + * Merge a GitHub pull request with the selected strategy. + */ + readonly mergePullRequest: (input: { + readonly cwd: string; + readonly number: number; + readonly strategy: GitHubMergeStrategy; }) => Effect.Effect; + /** + * Read raw GitHub pull request detail fields. + */ + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect; + + /** + * List raw GitHub pull request check rows. + */ + readonly listPullRequestChecks: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, Error>; + + /** + * List raw GitHub pull request reviews. + */ + readonly listPullRequestReviews: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, Error>; + + /** + * List raw GitHub pull request review comments. + */ + readonly listPullRequestReviewComments: (input: { + readonly cwd: string; + readonly repo: string; + readonly number: number; + }) => Effect.Effect, Error>; + /** * Read the default branch reported by the GitHub CLI for the current repo. */ @@ -499,6 +771,18 @@ export interface TextGenerationCapability { readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; + + /** + * Generate a structured workflow-board proposal from an assembled prompt. + * + * The host runs this NO-TOOL / read-only so the model can only reason and + * emit a proposal — it physically cannot write a board definition. Providers + * that cannot be proven no-tool fail rather than shipping a tool-enabled + * meta-agent. + */ + readonly generateBoardProposal: ( + input: BoardProposalGenerationInput, + ) => Effect.Effect; } export interface TerminalSessionHandle { @@ -563,6 +847,13 @@ export interface AgentsBootstrapCreateThreadInput extends AgentsCreateThreadInpu export interface AgentsStartTurnBootstrapInput { readonly createThread?: AgentsBootstrapCreateThreadInput | undefined; + /** + * NOT supported on the plugin capability path — `startTurn` fails with a + * typed error when set. The atomic worktree-prep bootstrap lives only in the + * app's WS entrypoint; the engine command plane the plugin dispatches + * through ignores it, so honoring the field would be a silent no-op. Create + * the worktree via the `vcs` capability instead. + */ readonly prepareWorktree?: | { readonly projectCwd: string; @@ -571,12 +862,19 @@ export interface AgentsStartTurnBootstrapInput { readonly startFromOrigin?: boolean | undefined; } | undefined; + /** + * NOT supported on the plugin capability path — `startTurn` fails with a + * typed error when `true` (see `prepareWorktree`). Run setup via the + * `terminals` capability instead. + */ readonly runSetupScript?: boolean | undefined; } export interface AgentsStartTurnInput { readonly threadId: ThreadId; readonly text: string; + readonly messageId?: MessageId | undefined; + readonly commandId?: CommandId | undefined; readonly attachments?: ReadonlyArray | undefined; readonly modelSelection?: ModelSelection | undefined; readonly bootstrap?: AgentsStartTurnBootstrapInput | undefined; @@ -632,6 +930,15 @@ export interface VcsWorktreeInput { readonly worktreePath: string; } +export interface VcsPathInput extends VcsWorktreeInput { + readonly path: string; +} + +export interface VcsAheadCountInput extends VcsWorktreeInput { + readonly base: string; + readonly head: string; +} + export interface VcsWorktreeSummary { readonly path: string; readonly branch: string | null; @@ -665,10 +972,17 @@ export interface VcsSwitchRefFacadeInput extends VcsWorktreeInput { readonly ref: string; } +export interface VcsRef { + readonly name: string; + readonly isRemote: boolean; + readonly worktreePath: string | null; +} + export interface VcsCommitInput extends VcsWorktreeInput { readonly subject: string; readonly body?: string | undefined; readonly filePaths?: ReadonlyArray | undefined; + readonly noVerify?: boolean | undefined; } export type VcsCommitResult = @@ -682,6 +996,10 @@ export type VcsCommitResult = export interface VcsMergeInput extends VcsWorktreeInput { readonly ref: string; + readonly message?: string | undefined; + readonly noFf?: boolean | undefined; + readonly noVerify?: boolean | undefined; + readonly abortOnConflict?: boolean | undefined; } export type VcsMergeResult = @@ -721,10 +1039,27 @@ export interface VcsDiffRefsInput extends VcsWorktreeInput { readonly ignoreWhitespace?: boolean | undefined; } +export interface VcsDiffRefToWorkingTreeInput extends VcsWorktreeInput { + /** Base ref/commit to diff FROM; resolved as `${baseRef}^{commit}`. */ + readonly baseRef: string; + /** + * Include untracked files as `/dev/null` → path add-diffs. Defaults to true + * (matches the workflow ticket-diff semantics). + */ + readonly includeUntracked?: boolean | undefined; + readonly ignoreWhitespace?: boolean | undefined; +} + export interface VcsDiffResult { readonly diff: string; } +export interface VcsDiffRefToWorkingTreeResult { + readonly diff: string; + /** True when any diff segment was truncated at the output-size cap. */ + readonly truncated: boolean; +} + export interface VcsCheckpointInput extends VcsWorktreeInput { readonly checkpointRef: CheckpointRef; } @@ -759,6 +1094,45 @@ export interface GitHubPullRequestSummary { readonly headRepositoryOwnerLogin?: string | null | undefined; } +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export type GitHubMergeStrategy = "squash" | "merge" | "rebase"; + +export interface GitHubPullRequestDetail { + readonly state: string; + readonly mergedAt: string | null; + readonly reviewDecision: string | null; + readonly headRefOid: string; + readonly url: string; +} + +export interface GitHubPullRequestCheck { + readonly name: string; + readonly state: string; + readonly bucket: string; + readonly link: string; +} + +export interface GitHubPullRequestReview { + readonly id: string; + readonly author: string; + readonly state: string; + readonly body: string; + readonly submittedAt: string; +} + +export interface GitHubPullRequestReviewComment { + readonly id: number; + readonly user: string; + readonly body: string; + readonly path: string | null; + readonly createdAt: string; +} + export interface CommitMessageGenerationInput { readonly cwd: string; readonly branch: string | null; @@ -811,6 +1185,23 @@ export interface ThreadTitleGenerationResult { readonly title: string; } +export interface BoardProposalGenerationInput { + /** + * Fully assembled prompt (metrics + current board definition + instructions). + * The caller builds this; the provider does NOT read any files — the + * underlying model invocation is no-tool / read-only. + */ + readonly prompt: string; + readonly modelSelection: ModelSelection; +} + +export interface BoardProposalGenerationResult { + /** The proposed workflow/board definition, decoded from the model's output. */ + readonly proposedDefinition: unknown; + /** Human-readable explanation of why this proposal was made. */ + readonly rationale: string; +} + export interface PluginHostApi { readonly hostApiVersion: string; readonly config: PluginHostConfig; @@ -822,6 +1213,8 @@ export interface PluginHostApi { readonly environmentsRead: Effect.Effect; readonly secrets: Effect.Effect; readonly http: Effect.Effect; + readonly filesystem: Effect.Effect; + readonly httpClient: Effect.Effect; readonly sourceControl: Effect.Effect; readonly textGeneration: Effect.Effect; } @@ -915,6 +1308,52 @@ export interface PluginDefinition { | ((hostApi: PluginHostApi) => PluginRegistration); } +export function writeFileAtomic( + filesystem: Pick, + input: FilesystemPathInput & { readonly contents: string | Uint8Array }, +): Effect.Effect { + return Effect.gen(function* () { + const segments = input.relativePath.split("/"); + const fileName = segments.pop() ?? "file"; + const now = yield* Clock.currentTimeMillis; + const random = Math.abs(yield* Random.nextInt); + const tempName = `.${fileName}.${now.toString(36)}-${random.toString(36)}.tmp`; + const tempRelativePath = [...segments, tempName] + .filter((segment) => segment.length > 0) + .join("/"); + const contents = + typeof input.contents === "string" + ? new TextEncoder().encode(input.contents) + : input.contents; + + return yield* filesystem + .writeFile({ + root: input.root, + relativePath: tempRelativePath, + contents, + }) + .pipe( + Effect.andThen( + filesystem.rename({ + root: input.root, + fromRelativePath: tempRelativePath, + toRelativePath: input.relativePath, + // Atomic replace: the destination usually already exists on the + // second and later writes to the same path. Without this the rename + // would be rejected ("destination already exists") and every atomic + // *update* after the first create would fail. + overwrite: true, + }), + ), + Effect.catch((error) => + filesystem + .remove({ root: input.root, relativePath: tempRelativePath }) + .pipe(Effect.ignore, Effect.andThen(Effect.fail(error))), + ), + ); + }); +} + export function definePlugin( definition: Definition, ): Definition { diff --git a/packages/shared/src/pluginHostWeb.test.ts b/packages/shared/src/pluginHostWeb.test.ts index 2d26976f024..1302103e258 100644 --- a/packages/shared/src/pluginHostWeb.test.ts +++ b/packages/shared/src/pluginHostWeb.test.ts @@ -2,25 +2,45 @@ import { describe, expect, it } from "vite-plus/test"; import { PLUGIN_HOST_IMPORT_MAP_MARKER, + PLUGIN_HOST_LAYER_MARKER, getPluginHostShimSource, injectPluginHostHeadHtml, pluginHostImportMap, } from "./pluginHostWeb.ts"; describe("pluginHostWeb", () => { - it("injects the import map and bootstrap before head close once", () => { + it("injects the import map and bootstrap at head start once", () => { const html = "T3"; const injected = injectPluginHostHeadHtml(html); const reinjected = injectPluginHostHeadHtml(injected); expect(injected).toContain(PLUGIN_HOST_IMPORT_MAP_MARKER); + expect(injected.indexOf(PLUGIN_HOST_IMPORT_MAP_MARKER)).toBeGreaterThan( + injected.indexOf(""), + ); expect(injected.indexOf(PLUGIN_HOST_IMPORT_MAP_MARKER)).toBeLessThan( injected.indexOf(""), ); + // The `plugins` cascade-layer declaration must be injected into the head so it + // is registered first (before any runtime CSS), keeping plugin styles lowest. + expect(injected).toContain(``); expect(reinjected.match(new RegExp(PLUGIN_HOST_IMPORT_MAP_MARKER, "g"))?.length).toBe(1); }); + it("injects the import map before a module script already present in the head", () => { + // A built index.html carries the app's entry '; + + const injected = injectPluginHostHeadHtml(html); + + expect(injected.indexOf(PLUGIN_HOST_IMPORT_MAP_MARKER)).toBeLessThan( + injected.indexOf('`, ``, "", @@ -555,9 +1388,15 @@ export function injectPluginHostHeadHtml(html: string): string { return html; } const injection = `\n${buildPluginHostHeadInjection()}\n`; - const headCloseMatch = /<\/head\s*>/iu.exec(html); - if (!headCloseMatch || headCloseMatch.index === undefined) { + // Inject right AFTER opens, not before : a built index.html + // carries the app's own