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/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..49133b5e762 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1,10 +1,11 @@ import { AuthSessionId, AuthStandardClientScopes, - AuthEnvironmentScopes, + AuthScopes, + authEnvironmentScopes, type AuthClientMetadata, type AuthClientSession, - type AuthEnvironmentScope, + type AuthScope, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -36,7 +37,7 @@ export interface IssuedSession { readonly method: ServerAuthSessionMethod; readonly client: AuthClientMetadata; readonly expiresAt: DateTime.DateTime; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly proofKeyThumbprint?: string; } @@ -47,7 +48,7 @@ export interface VerifiedSession { readonly client: AuthClientMetadata; readonly expiresAt?: DateTime.DateTime; readonly subject: string; - readonly scopes: ReadonlyArray; + readonly scopes: ReadonlyArray; readonly proofKeyThumbprint?: string; } @@ -363,7 +364,7 @@ export class SessionStore extends Context.Service< readonly ttl?: Duration.Duration; readonly subject?: string; readonly method?: ServerAuthSessionMethod; - readonly scopes?: ReadonlyArray; + readonly scopes?: ReadonlyArray; readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; }) => Effect.Effect; @@ -408,7 +409,7 @@ const SessionClaims = Schema.Struct({ kind: Schema.Literal("session"), sid: AuthSessionId, sub: Schema.String, - scopes: AuthEnvironmentScopes, + scopes: AuthScopes, method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), jkt: Schema.optionalKey(Schema.String), iat: Schema.Number, @@ -452,9 +453,14 @@ function toClientMetadata(record: { }; } -function toAuthClientSession(input: Omit): AuthClientSession { +function toAuthClientSession( + input: Omit & { + readonly scopes: ReadonlyArray; + }, +): AuthClientSession { return { ...input, + scopes: authEnvironmentScopes(input.scopes), current: false, }; } diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 71fb00b970a..a4670965c86 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -1,6 +1,7 @@ import { AuthAccessReadScope, AuthAccessWriteScope, + AuthPluginsManageScope, AuthStandardClientScopes, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -19,9 +20,11 @@ import { EnvironmentScopeRequiredError, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, + isPluginScope, + satisfiesScope, } from "@t3tools/contracts"; -import type { AuthEnvironmentScope } from "@t3tools/contracts"; -import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; +import type { AuthEnvironmentScope, AuthScope } from "@t3tools/contracts"; +import { parseOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -109,7 +112,7 @@ export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidR ); } -export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) { +export function failEnvironmentScopeRequired(requiredScope: AuthScope) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => Effect.fail( @@ -161,6 +164,32 @@ export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope" return session; }); +const TOKEN_EXCHANGE_CORE_SCOPES = new Set([ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthTerminalOperateScope, + AuthReviewWriteScope, + AuthAccessReadScope, + AuthAccessWriteScope, + AuthRelayReadScope, + AuthRelayWriteScope, + AuthPluginsManageScope, +]); + +function parseTokenExchangeScope(value: string): ReadonlyArray | null { + const scopes = parseOAuthScope(value); + if (scopes === null) return null; + if ( + !scopes.every( + (scope): scope is AuthScope => + TOKEN_EXCHANGE_CORE_SCOPES.has(scope as AuthEnvironmentScope) || isPluginScope(scope), + ) + ) { + return null; + } + return scopes; +} + export const environmentAuthenticatedAuthLayer = Layer.effect( EnvironmentAuthenticatedAuth, Effect.gen(function* () { @@ -250,19 +279,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 +347,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/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/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/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/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/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..3819b9aeb31 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 `, }); @@ -577,6 +622,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON turns.thread_id = threads.thread_id AND turns.turn_id = threads.latest_turn_id WHERE threads.latest_turn_id IS NOT NULL + AND threads.owner = ${DEFAULT_THREAD_OWNER} ORDER BY turns.thread_id ASC `, }); @@ -603,6 +649,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 +676,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 +701,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 +759,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 +805,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 +1238,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 +1290,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadRows(undefined).pipe( + listAllThreadRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getCommandReadModel:listThreads:query", @@ -1374,6 +1437,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 +1831,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 +2046,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 +2119,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/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/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..c17105e2f8b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,8 @@ 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"; +import Migration0034 from "./Migrations/034_PluginMigrations.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +91,8 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [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/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/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/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/plugins/HelloBoardFixture.integration.test.ts b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts new file mode 100644 index 00000000000..7b7d70ca033 --- /dev/null +++ b/apps/server/src/plugins/HelloBoardFixture.integration.test.ts @@ -0,0 +1,485 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +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"; +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, HttpClientRequest, 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 { 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"; +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 WORKSPACE_ROOT_ENV = "T3_HELLO_BOARD_WORKSPACE_ROOT"; +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 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; +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: () => + 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, + 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, + mergePullRequest: unexpectedCapabilityUse, + getPullRequestDetail: unexpectedCapabilityUse, + listPullRequestChecks: unexpectedCapabilityUse, + listPullRequestReviews: unexpectedCapabilityUse, + listPullRequestReviewComments: 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, + }), + TestOutboundLookupLive, + TestPluginHttpClientTransportLive, +); + +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, + workspaceRoot: process.env[WORKSPACE_ROOT_ENV], + })), + () => + 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; + } + if (previous.workspaceRoot === undefined) { + delete process.env[WORKSPACE_ROOT_ENV]; + } else { + process.env[WORKSPACE_ROOT_ENV] = previous.workspaceRoot; + } + }), + ); + +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 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); + 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"); + assert.property(staged.capabilityDescriptions, "filesystem"); + assert.property(staged.capabilityDescriptions, "httpClient"); + + 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, + hasStyles: plugin.hasStyles, + capabilities: plugin.capabilities, + lastError: plugin.lastError, + })), + { + id: pluginId, + state: "active", + hasWeb: true, + hasStyles: false, + capabilities: ["database", "filesystem", "httpClient"], + 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 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' + `; + assert.deepEqual(tables, [{ name: "p_hello_board_notes" }]); + }), + ), + ), + ); +}); diff --git a/apps/server/src/plugins/OutboundUrlValidator.test.ts b/apps/server/src/plugins/OutboundUrlValidator.test.ts new file mode 100644 index 00000000000..a82bb11bb3f --- /dev/null +++ b/apps/server/src/plugins/OutboundUrlValidator.test.ts @@ -0,0 +1,114 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { OutboundUrlValidator } from "./OutboundUrlValidator.ts"; + +const validateWith = (url: string, addrs: ReadonlyArray) => + Effect.exit(OutboundUrlValidator.validate(url, { lookup: () => Effect.succeed(addrs) })); + +describe("OutboundUrlValidator", () => { + it.effect("accepts a public https host", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("https://hooks.slack.com/services/x", ["140.82.112.3"]))._tag, + "Success", + ); + }), + ); + + it.effect("rejects non-https by default", () => + Effect.gen(function* () { + assert.equal( + (yield* validateWith("http://hooks.slack.com/x", ["140.82.112.3"]))._tag, + "Failure", + ); + }), + ); + + it.effect("blocks private, loopback, link-local, metadata, ULA, and CGNAT addresses", () => + Effect.gen(function* () { + for (const addr of [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "100.127.255.255", + "::1", + "fe80::1", + "fc00::1", + "fdff::1", + ]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Failure", addr); + } + }), + ); + + it.effect("blocks special-use IPv4 ranges but accepts neighboring public ranges", () => + Effect.gen(function* () { + for (const addr of [ + "0.0.0.0", + "192.0.0.1", + "192.0.2.5", + "192.88.99.1", + "198.18.0.1", + "198.19.255.255", + "198.51.100.5", + "203.0.113.5", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255", + ]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Failure", addr); + } + for (const addr of ["100.63.255.255", "100.128.0.1", "172.32.0.1", "198.20.0.1"]) { + assert.equal((yield* validateWith("https://x.test/y", [addr]))._tag, "Success", addr); + } + }), + ); + + it.effect( + "blocks IPv4-mapped IPv6, NAT64, 6to4, decimal IPv4, octal IPv4, and mixed answers", + () => + Effect.gen(function* () { + for (const [url, addr] of [ + ["https://x.test/y", "::ffff:10.0.0.1"], + ["https://x.test/y", "::ffff:7f00:1"], + ["https://x.test/y", "0:0:0:0:0:ffff:7f00:1"], + ["https://x.test/y", "::7f00:1"], + ["https://x.test/y", "64:ff9b::7f00:1"], + ["https://x.test/y", "2002:7f00:1::"], + ["https://2130706433/y", "127.0.0.1"], + ["https://0177.0.0.1/y", "127.0.0.1"], + ] as const) { + assert.equal((yield* validateWith(url, [addr]))._tag, "Failure", `${url} -> ${addr}`); + } + assert.equal( + (yield* validateWith("https://x.test/y", ["140.82.112.3", "10.0.0.1"]))._tag, + "Failure", + ); + }), + ); + + it.effect("allows http only for loopback when explicitly enabled for plugin development", () => + Effect.gen(function* () { + const allowed = yield* Effect.exit( + OutboundUrlValidator.validate("http://localhost:5173/x", { + lookup: () => Effect.succeed(["127.0.0.1"]), + allowHttpLoopback: true, + }), + ); + const rejected = yield* Effect.exit( + OutboundUrlValidator.validate("http://example.test/x", { + lookup: () => Effect.succeed(["140.82.112.3"]), + allowHttpLoopback: true, + }), + ); + + assert.equal(allowed._tag, "Success"); + assert.equal(rejected._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/plugins/OutboundUrlValidator.ts b/apps/server/src/plugins/OutboundUrlValidator.ts new file mode 100644 index 00000000000..010e76f4beb --- /dev/null +++ b/apps/server/src/plugins/OutboundUrlValidator.ts @@ -0,0 +1,234 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeDns from "node:dns"; + +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export class OutboundUrlError extends Data.TaggedError("OutboundUrlError")<{ + readonly reason: string; +}> {} + +export interface ResolvedAddress { + readonly address: string; + readonly family: 4 | 6; +} + +export interface UrlValidatorDeps { + readonly lookup: ( + host: string, + ) => Effect.Effect, OutboundUrlError | Error>; + readonly allowHttpLoopback?: boolean | undefined; +} + +export interface ResolvedOutboundUrl { + readonly url: URL; + readonly addresses: ReadonlyArray; +} + +const normalizeResolvedAddress = (entry: string | ResolvedAddress): ResolvedAddress => { + if (typeof entry !== "string") return entry; + return { address: entry, family: entry.includes(":") ? 6 : 4 }; +}; + +export const defaultLookup = ( + host: string, +): Effect.Effect, OutboundUrlError> => + Effect.tryPromise({ + try: async () => { + const records = await NodeDns.promises.lookup(host, { all: true }); + return records.map((record) => ({ + address: record.address, + family: record.family === 6 ? 6 : 4, + })); + }, + catch: (error) => { + const code = (error as { code?: unknown })?.code; + const suffix = typeof code === "string" ? ` (${code})` : ""; + return new OutboundUrlError({ reason: `DNS resolution failed for ${host}${suffix}` }); + }, + }); + +export class OutboundUrlLookup extends Context.Service< + OutboundUrlLookup, + UrlValidatorDeps["lookup"] +>()("t3/plugins/OutboundUrlValidator/OutboundUrlLookup") {} + +export const OutboundUrlLookupLive = Layer.succeed(OutboundUrlLookup, defaultLookup); + +// INVARIANT: only call this on canonical dotted decimal from WHATWG URL or DNS. +const ipv4Bytes = (ip: string): ReadonlyArray | null => { + if (!ip.includes(".") || ip.includes(":")) return null; + const parts = ip.split(".").map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return null; + } + return parts; +}; + +const isDisallowedV4 = (bytes: ReadonlyArray): boolean => { + const first = bytes[0] ?? -1; + const second = bytes[1] ?? -1; + const third = bytes[2] ?? -1; + if (first === 0) return true; + if (first === 10) return true; + if (first === 127) return true; + if (first === 100 && second >= 64 && second <= 127) return true; + if (first === 169 && second === 254) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 192 && second === 0 && third === 0) return true; + if (first === 192 && second === 0 && third === 2) return true; + if (first === 192 && second === 88 && third === 99) return true; + if (first === 192 && second === 168) return true; + if (first === 198 && (second === 18 || second === 19)) return true; + if (first === 198 && second === 51 && third === 100) return true; + if (first === 203 && second === 0 && third === 113) return true; + if (first >= 224) return true; + return false; +}; + +const ipv6Bytes = (raw: string): ReadonlyArray | null => { + let ip = raw.toLowerCase().replace(/^\[|\]$/g, ""); + const zone = ip.indexOf("%"); + if (zone !== -1) ip = ip.slice(0, zone); + if (!ip.includes(":")) return null; + + const lastColon = ip.lastIndexOf(":"); + const tail = ip.slice(lastColon + 1); + if (tail.includes(".")) { + const v4 = ipv4Bytes(tail); + if (!v4) return null; + const hi = (((v4[0] ?? 0) << 8) | (v4[1] ?? 0)).toString(16); + const lo = (((v4[2] ?? 0) << 8) | (v4[3] ?? 0)).toString(16); + ip = `${ip.slice(0, lastColon + 1)}${hi}:${lo}`; + } + + const halves = ip.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tailParts = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : null; + let hextets: ReadonlyArray; + if (tailParts === null) { + hextets = head; + } else { + const fill = 8 - head.length - tailParts.length; + if (fill < 0) return null; + hextets = [...head, ...Array(fill).fill("0"), ...tailParts]; + } + if (hextets.length !== 8) return null; + + const bytes: number[] = []; + for (const hextet of hextets) { + if (!/^[0-9a-f]{1,4}$/.test(hextet)) return null; + const value = Number.parseInt(hextet, 16); + bytes.push((value >> 8) & 0xff, value & 0xff); + } + return bytes; +}; + +const embeddedV4 = (bytes: ReadonlyArray): ReadonlyArray | null => { + const zerosThrough = (length: number): boolean => + bytes.slice(0, length).every((byte) => byte === 0); + if (zerosThrough(10) && bytes[10] === 0xff && bytes[11] === 0xff) { + return bytes.slice(12, 16); + } + if ( + bytes[0] === 0x00 && + bytes[1] === 0x64 && + bytes[2] === 0xff && + bytes[3] === 0x9b && + bytes.slice(4, 12).every((byte) => byte === 0) + ) { + return bytes.slice(12, 16); + } + if (zerosThrough(12)) return bytes.slice(12, 16); + if (bytes[0] === 0x20 && bytes[1] === 0x02) return bytes.slice(2, 6); + return null; +}; + +const isPrivateV6 = (raw: string): boolean => { + const bytes = ipv6Bytes(raw); + if (!bytes) return true; + if (bytes.slice(0, 15).every((byte) => byte === 0) && (bytes[15] === 0 || bytes[15] === 1)) { + return true; + } + const first = bytes[0] ?? 0; + const second = bytes[1] ?? 0; + if (first === 0xfe && (second & 0xc0) === 0x80) return true; + if ((first & 0xfe) === 0xfc) return true; + if (first === 0xff) return true; + const v4 = embeddedV4(bytes); + if (v4) return isDisallowedV4(v4); + return false; +}; + +const isBlocked = (ip: string): boolean => { + const v4 = ipv4Bytes(ip); + if (v4) return isDisallowedV4(v4); + return isPrivateV6(ip); +}; + +const isLoopback = (ip: string): boolean => { + const v4 = ipv4Bytes(ip); + if (v4) return v4[0] === 127; + const bytes = ipv6Bytes(ip); + return !!bytes && bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1; +}; + +const mapLookupError = (host: string, error: unknown) => + error instanceof OutboundUrlError + ? error + : new OutboundUrlError({ reason: `DNS resolution failed for ${host}` }); + +export const OutboundUrlValidator = { + resolve: ( + rawUrl: string, + deps: UrlValidatorDeps = { lookup: defaultLookup }, + ): Effect.Effect => + Effect.gen(function* () { + let parsed: URL; + // @effect-diagnostics-next-line tryCatchInEffectGen:off -- WHATWG URL parsing is a synchronous guard converted to an Effect failure. + try { + parsed = new URL(rawUrl); + } catch { + return yield* new OutboundUrlError({ reason: "Malformed URL" }); + } + + const isHttps = parsed.protocol === "https:"; + const allowHttpLoopback = deps.allowHttpLoopback === true && parsed.protocol === "http:"; + if (!isHttps && !allowHttpLoopback) { + return yield* new OutboundUrlError({ reason: "Only https:// targets are allowed" }); + } + + const addresses = yield* deps.lookup(parsed.hostname).pipe( + Effect.map((records) => records.map(normalizeResolvedAddress)), + Effect.mapError((error) => mapLookupError(parsed.hostname, error)), + ); + if (addresses.length === 0) { + return yield* new OutboundUrlError({ reason: "Host did not resolve" }); + } + + for (const address of addresses) { + if (isHttps) { + if (isBlocked(address.address)) { + return yield* new OutboundUrlError({ + reason: `Resolved to a disallowed address (${address.address})`, + }); + } + } else if (!isLoopback(address.address)) { + return yield* new OutboundUrlError({ + reason: `HTTP development target resolved outside loopback (${address.address})`, + }); + } + } + + return { url: parsed, addresses }; + }), + + validate: (rawUrl: string, deps?: UrlValidatorDeps): Effect.Effect => + OutboundUrlValidator.resolve(rawUrl, deps).pipe(Effect.map((result) => result.url)), +}; diff --git a/apps/server/src/plugins/PluginCatalog.ts b/apps/server/src/plugins/PluginCatalog.ts new file mode 100644 index 00000000000..5f110b2107e --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.ts @@ -0,0 +1,122 @@ +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, + hasStyles: runtime.manifest.entries.styles !== 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, + hasStyles: 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, + hasStyles: manifest.entries.styles !== 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 new file mode 100644 index 00000000000..50b653af15c --- /dev/null +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -0,0 +1,1255 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + PluginId, + PluginManifest, + 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"; +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 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"; +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 { 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"; +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 unexpectedCapabilityUse = () => + Effect.die(new Error("unexpected capability use in host test")); + +const testLayerBase = 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(ServerLifecycleEvents.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(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: unexpectedCapabilityUse, + streamDomainEvents: Stream.empty, + }), + ), + 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(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, + 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, + mergePullRequest: unexpectedCapabilityUse, + getPullRequestDetail: unexpectedCapabilityUse, + listPullRequestChecks: unexpectedCapabilityUse, + listPullRequestReviews: unexpectedCapabilityUse, + listPullRequestReviewComments: unexpectedCapabilityUse, + getDefaultBranch: unexpectedCapabilityUse, + checkoutPullRequest: unexpectedCapabilityUse, + }), + ), + Layer.provideMerge( + 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")), + ), + ), + ), +); + +const testLayer = testLayerBase.pipe( + 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 decodeCapabilityMarker = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + httpBasePath: Schema.String, + terminalsUnavailable: Schema.Boolean, + }), + ), +); +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", + 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(NodeURL.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 capabilities?: ReadonlyArray; + 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: input.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 }; + }); + +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 }, + }), + }, + ], + }; + }); + }, +}; +`; + +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* () { + 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( + "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"); + 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"); + 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("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("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"); + 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)); + }), + ); + + 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); + }), + ); +}); + +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 new file mode 100644 index 00000000000..40e56c3a96b --- /dev/null +++ b/apps/server/src/plugins/PluginHost.ts @@ -0,0 +1,962 @@ +import { + HOST_API_VERSION, + PluginManifest, + hostApiSatisfies, + type PluginId, + type PluginLockfile, + type PluginLockfilePlugin, + type PluginState, +} 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 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" }; +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"; +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 { makeFilesystemCapability } from "./capabilities/FilesystemCapability.ts"; +import { makeHttpCapability } from "./capabilities/HttpCapability.ts"; +import { + makeHttpClientCapability, + PluginHttpClientTransportService, +} from "./capabilities/HttpClientCapability.ts"; +import { makeProjectionsReadCapability } from "./capabilities/ProjectionsReadCapability.ts"; +import { makeSecretsCapability } from "./capabilities/SecretsCapability.ts"; +import { makeSourceControlCapability } from "./capabilities/SourceControlCapability.ts"; +import { makeTerminalsCapability } from "./capabilities/TerminalsCapability.ts"; +import { makeTextGenerationCapability } from "./capabilities/TextGenerationCapability.ts"; +import { makeVcsCapability } from "./capabilities/VcsCapability.ts"; +import { OutboundUrlLookup } from "./OutboundUrlValidator.ts"; +import { + PluginLockfileStore, + 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"; +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); +}; + +// 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 }, +) { + override get message(): string { + return `Plugin ${this.pluginId} returned an invalid registration: ${this.detail}`; + } +} + +// Internal control-flow sentinel: raised when an activation is intentionally +// cancelled by a concurrent lifecycle change (see the pre-put state re-check in +// loadPlugin). It is deliberately NOT part of the plugin SDK/contract surface — +// it only travels inside the host's failure channel so a self-cancel can be +// told apart from a genuine fiber interruption (host shutdown) and from a real +// activation error. +export class PluginActivationCanceled 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 }, +) { + 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; + // 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") {} + +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) => + // 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), + }), + ), + ), + ); + +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 unavailable = (capability: string) => + // 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"]; + readonly config: ServerConfig.ServerConfig["Service"]; + 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"]; + 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); + 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, + config: { + appVersion: APP_VERSION, + hostApiVersion: HOST_API_VERSION, + dataDir: input.dataDir, + logger: input.logger, + }, + 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, + grants: input.grants, + }), + ), + 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)), + 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, + }), + ), + textGeneration: available( + "textGeneration", + makeTextGenerationCapability(input.deps.textGeneration), + ), + }; + + return { api, teardown }; +}; + +const upgradeLockfileEntry = ( + entry: PluginLockfilePlugin, + staged: NonNullable, +): PluginLockfilePlugin => ({ + version: staged.version, + sha256: staged.sha256, + sourceId: entry.sourceId, + enabled: entry.enabled, + state: "active", + // 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: null, +}); + +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, + // 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, + 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 httpRegistry = yield* PluginHttpRegistry; + const clock = yield* Clock.Clock; + 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; + 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({ + 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( + // 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)) + .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), + ) + .pipe(Effect.tap(() => publishPluginStateChanged(pluginId, "disabled-by-host"))); + 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* markFailure(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 = 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, + config, + fileSystem: fs, + path, + environment, + orchestrationEngine, + snapshots, + turns, + messages, + activities, + providerInstances, + git, + checkpointStore, + textGeneration, + sourceControlRegistry, + github, + terminals, + outboundLookup, + httpClientTransport, + }, + }); + + 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).pipe( + Effect.timeout(registrationTimeout()), + ); + yield* validateRegistration(pluginId, registration); + yield* migrator.run(pluginId, registration.migrations ?? []); + if (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( + Effect.forkScoped, + Scope.provide(scope), + ); + } + yield* Deferred.succeed(readiness, undefined).pipe(Effect.orDie); + // 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* markActivated(true); + } else { + yield* clock.sleep(healthyDelay).pipe( + Effect.flatMap(() => markActivated(true)), + 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); + // 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 }); + } else { + // 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) => + Effect.gen(function* () { + if (process.env.T3_NO_PLUGINS === "1") { + yield* Effect.logInfo("Plugin host disabled by T3_NO_PLUGINS", { pluginId }); + return; + } + // 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), + ), + ); + }), + ); + }); + + const deactivatePlugin: PluginHost["Service"]["deactivatePlugin"] = (pluginId) => + // 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* () { + if (entry.state === "pending-remove") { + 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; + } + if (entry.state === "pending-upgrade") { + if (!entry.staged) { + 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), + ) + .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, + ), + ) + .pipe(Effect.tap(() => publishPluginStateChanged(pluginId, "failed"))); + 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) => + // 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, + ), + ), + ); + } + }).pipe(Effect.ignoreCause({ log: true })); + + return PluginHost.of({ start, activatePlugin, deactivatePlugin }); +}); + +export const layer = Layer.effect(PluginHost, make()); 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..0a7eabedaed --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRoutes.test.ts @@ -0,0 +1,337 @@ +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("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; + 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/PluginInstaller.test.ts b/apps/server/src/plugins/PluginInstaller.test.ts new file mode 100644 index 00000000000..b480fdd7550 --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.test.ts @@ -0,0 +1,699 @@ +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; + readonly deactivated?: 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: (id) => + Effect.sync(() => { + input.deactivated?.push(id); + }), + }), + ); + 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, + hasStyles: 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("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")); + 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 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* () { + 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", () => { + const deactivated: Array = []; + return 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"); + // 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 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 new file mode 100644 index 00000000000..1277e5fc02a --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -0,0 +1,1040 @@ +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", + 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; + +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; +}; + +// 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 parseSemver = (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) => { + const leftVersion = parseSemver(left); + const rightVersion = parseSemver(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; +}; + +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).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)))), + ); + 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* () { + // 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, + ); + 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, () => + 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)); + 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* () { + 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) + .pipe( + Effect.mapError((cause) => + managementError("activation-failed", "Plugin activation failed.", { cause }), + ), + ); + } 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)); + // 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( + "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* () { + // 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)); + const updates = yield* Effect.forEach( + Object.entries(lockfile.plugins), + ([rawPluginId, entry]) => + Effect.gen(function* () { + const pluginId = PluginId.make(rawPluginId); + const source = lockfile.sources.find((candidate) => candidate.id === entry.sourceId); + if (!source) return null; + const index = yield* marketplace + .fetchSource(source) + .pipe(Effect.orElseSucceed(() => null)); + if (index === null) return null; + const marketplaceEntry = index.plugins.find((candidate) => candidate.id === pluginId); + if (!marketplaceEntry) return null; + const latest = marketplaceEntry.versions.toSorted((left, right) => + compareSemver(right.version, left.version), + )[0]; + if (!latest || compareSemver(latest.version, entry.version) <= 0) return null; + return { + pluginId, + currentVersion: entry.version, + latestVersion: latest.version, + }; + }), + { concurrency: 4 }, + ); + return { updates: updates.filter((update) => update !== null) }; + }); + + return PluginInstaller.of({ + beginInstall, + confirmInstall, + abortInstall, + setEnabled, + uninstall, + beginUpgrade, + confirmUpgrade, + checkUpdates, + }); +}); + +export { PRESERVE_DATA_MARKER }; +export const layer = Layer.effect(PluginInstaller, make()); diff --git a/apps/server/src/plugins/PluginLockfileStore.test.ts b/apps/server/src/plugins/PluginLockfileStore.test.ts new file mode 100644 index 00000000000..f96845df47b --- /dev/null +++ b/apps/server/src/plugins/PluginLockfileStore.test.ts @@ -0,0 +1,229 @@ +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); + // 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 }); + }), + ); + + 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("advisory lock finalizer only removes a lock it still owns", () => + Effect.gen(function* () { + const store = yield* PluginLockfileStoreModule.PluginLockfileStore; + const fs = yield* FileSystem.FileSystem; + + // While we hold the lock, simulate another process reclaiming it by + // overwriting the lock file with a different owner token. + yield* store.updatePlugin(pluginId, () => + Effect.gen(function* () { + yield* fs + .writeFileString(store.advisoryLockPath, "9999:foreign-owner-token\n") + .pipe(Effect.orDie); + return makePlugin(); + }), + ); + + // 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; + + 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); + } + }), + ); +}); + +// 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 new file mode 100644 index 00000000000..4f0e86ed55b --- /dev/null +++ b/apps/server/src/plugins/PluginLockfileStore.ts @@ -0,0 +1,412 @@ +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 NodeCrypto from "node:crypto"; + +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, 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.`; + } +} + +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 updateSources: ( + fn: ( + sources: ReadonlyArray, + lockfile: PluginLockfile, + ) => Effect.Effect< + ReadonlyArray, + PluginLockfileStoreError + >, + ) => Effect.Effect; + 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, + 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; + // 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( + 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 }); + // `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), + ), + ); + }), + ); + + const opened = yield* openLock.pipe(Effect.result); + if (Result.isSuccess(opened)) return { path: input.advisoryLockPath, token: ownerToken }; + + 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) { + return yield* new PluginLockfileLockError({ + path: input.advisoryLockPath, + cause: opened.failure, + }); + } + + // 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, + }); + 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 { path: input.advisoryLockPath, token: ownerToken }; + }), + ({ path: lockPath, token }) => + FileSystem.FileSystem.pipe( + 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, + ), + ); + +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 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)); + + 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, + updateSources, + updatePlugin, + removePlugin, + transition, + }); +}); + +export const layer = Layer.effect(PluginLockfileStore, make()); 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/PluginManagementRpcHandlers.test.ts b/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts new file mode 100644 index 00000000000..b78dd92153c --- /dev/null +++ b/apps/server/src/plugins/PluginManagementRpcHandlers.test.ts @@ -0,0 +1,163 @@ +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("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; + + 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"); + }), + ); + + 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 new file mode 100644 index 00000000000..390cb1310bb --- /dev/null +++ b/apps/server/src/plugins/PluginManagementRpcHandlers.ts @@ -0,0 +1,193 @@ +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 { isSameMarketplaceSource, 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) => { + // 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) => + isSameMarketplaceSource(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* () { + // 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, 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) => + 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..4ce2e8dd842 --- /dev/null +++ b/apps/server/src/plugins/PluginMarketplace.test.ts @@ -0,0 +1,225 @@ +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", + ); + // 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", + ); + 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("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("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..a282e556b1f --- /dev/null +++ b/apps/server/src/plugins/PluginMarketplace.ts @@ -0,0 +1,334 @@ +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 = ""; + // 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)) { + 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, + }); + } + // 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, + }; + }); + + return PluginMarketplace.of({ + normalizeSourceUrl, + fetchSource, + catalog, + findVersion, + }); +}); + +export const layer = Layer.effect(PluginMarketplace, 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..c68ff4bea15 --- /dev/null +++ b/apps/server/src/plugins/PluginMigrator.ts @@ -0,0 +1,299 @@ +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") {} + +// 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` + 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))); +}; + +// 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; + 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..32831f7cf12 --- /dev/null +++ b/apps/server/src/plugins/PluginModuleLoader.ts @@ -0,0 +1,178 @@ +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 * as NodeURL 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; + } + // 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; + yield* Effect.sync(() => + nodeModule.register(registeredHookUrl, { + parentURL: import.meta.url, + data: { + 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 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(NodeURL.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/PluginRpcDispatcher.test.ts b/apps/server/src/plugins/PluginRpcDispatcher.test.ts new file mode 100644 index 00000000000..ed829231cab --- /dev/null +++ b/apps/server/src/plugins/PluginRpcDispatcher.test.ts @@ -0,0 +1,317 @@ +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, + hasStyles: plugin.hasStyles, + lastError: plugin.lastError, + })), + [ + { + 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/PluginRpcDispatcher.ts b/apps/server/src/plugins/PluginRpcDispatcher.ts new file mode 100644 index 00000000000..52da0a18e46 --- /dev/null +++ b/apps/server/src/plugins/PluginRpcDispatcher.ts @@ -0,0 +1,191 @@ +import { + PluginRpcError, + pluginOperateScope, + pluginReadScope, + satisfiesScope, + type AuthScope, +} from "@t3tools/contracts"; +import type { PluginId } from "@t3tools/contracts/plugin"; +import type { PluginRpcDescriptor, PluginStreamDescriptor } from "@t3tools/plugin-sdk"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { makePluginLogger } from "./PluginLogger.ts"; +import { PluginRuntimeRegistry, type ActivePluginRuntime } from "./PluginRuntimeRegistry.ts"; + +export interface PluginRpcSession { + readonly scopes: ReadonlyArray; +} + +export class PluginRpcDispatcher extends Context.Service< + PluginRpcDispatcher, + { + readonly call: ( + pluginId: PluginId, + method: string, + payload: unknown, + session: PluginRpcSession, + ) => Effect.Effect; + readonly subscribe: ( + pluginId: PluginId, + method: string, + payload: unknown, + session: PluginRpcSession, + ) => Stream.Stream; + } +>()("t3/plugins/PluginRpcDispatcher") {} + +const pluginRpcError = ( + pluginId: PluginId, + code: PluginRpcError["code"], + message: string, + data?: unknown, +) => + new PluginRpcError({ + pluginId, + code, + message, + ...(data === undefined ? {} : { data }), + }); + +const internalPluginRpcError = (pluginId: PluginId, error: unknown) => + pluginRpcError(pluginId, "internal", error instanceof Error ? error.message : String(error)); + +const pluginDefectError = (pluginId: PluginId) => + pluginRpcError(pluginId, "internal", "Plugin method failed."); + +const lookupRuntime = Effect.fn("PluginRpcDispatcher.lookupRuntime")(function* ( + registry: PluginRuntimeRegistry["Service"], + pluginId: PluginId, +) { + const runtime = yield* registry.get(pluginId); + if (Option.isNone(runtime)) { + return yield* pluginRpcError(pluginId, "not-found", "Plugin was not found."); + } + return runtime.value; +}); + +const ensureDescriptorReady = Effect.fn("PluginRpcDispatcher.ensureDescriptorReady")(function* ( + runtime: ActivePluginRuntime, + descriptor: PluginRpcDescriptor | PluginStreamDescriptor, +) { + if (descriptor.readiness === "always") { + return; + } + const readiness = yield* Deferred.poll(runtime.readiness); + if (Option.isNone(readiness)) { + return yield* pluginRpcError( + runtime.manifest.id, + "not-ready", + "Plugin is not ready to handle this method.", + ); + } +}); + +const authorizeDescriptor = ( + runtime: ActivePluginRuntime, + descriptor: PluginRpcDescriptor | PluginStreamDescriptor, + session: PluginRpcSession, +) => { + const requiredScope = + descriptor.scope === "operate" + ? pluginOperateScope(runtime.manifest.id) + : pluginReadScope(runtime.manifest.id); + return satisfiesScope(requiredScope, session.scopes) + ? Effect.void + : Effect.fail( + pluginRpcError( + runtime.manifest.id, + "unauthorized", + `The authenticated token is missing required scope: ${requiredScope}.`, + ), + ); +}; + +const mapPluginHandlerCause = (pluginId: PluginId, cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause as Cause.Cause); + } + if (Cause.hasDies(cause)) { + return Effect.logError("Plugin RPC handler defect", { + pluginId, + cause: Cause.pretty(cause), + }).pipe(Effect.andThen(Effect.fail(pluginDefectError(pluginId)))); + } + return Effect.fail(internalPluginRpcError(pluginId, Cause.squash(cause))); +}; + +const mapPluginHandlerStreamCause = (pluginId: PluginId, cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Stream.failCause(cause as Cause.Cause); + } + if (Cause.hasDies(cause)) { + return Stream.fromEffect( + Effect.logError("Plugin RPC stream handler defect", { + pluginId, + cause: Cause.pretty(cause), + }), + ).pipe(Stream.drain, Stream.concat(Stream.fail(pluginDefectError(pluginId)))); + } + return Stream.fail(internalPluginRpcError(pluginId, Cause.squash(cause))); +}; + +export const make = Effect.fn("PluginRpcDispatcher.make")(function* () { + const registry = yield* PluginRuntimeRegistry; + + // 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/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/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..0ef05ade1d4 --- /dev/null +++ b/apps/server/src/plugins/PluginWebRoutes.ts @@ -0,0 +1,235 @@ +import { PLUGIN_ID_PATTERN_SOURCE, type PluginId } 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 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"); + +// 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 { + 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": pluginBundleCacheControl, + "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": pluginShimCacheControl, + "X-Content-Type-Options": "nosniff", + }, + }); + }), +); + +export const pluginWebRouteLayer = Layer.mergeAll(pluginBundleRouteLayer, pluginHostShimRouteLayer); 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 new file mode 100644 index 00000000000..e8e54084ed2 --- /dev/null +++ b/apps/server/src/plugins/capabilities/AgentsCapability.test.ts @@ -0,0 +1,1038 @@ +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 { + AgentsThreadNotFoundError, + 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("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 } + >(); + 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); + const { threadId } = yield* agents.createThread({ + projectId: ProjectId.make("project-agents"), + title: "Observed", + modelSelection, + }); + + const [snapshotItem, eventItem] = 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, + ); + // 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"), + threadId, + activity: { + id: "event-plugin-observe-activity" as any, + tone: "info", + kind: "note", + summary: "Observed", + payload: {}, + turnId: null, + createdAt, + }, + createdAt, + }); + const event = yield* Queue.take(items); + return [snapshot, event] as const; + }), + ); + + 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", + () => + 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"); + }), + ); + + 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 forwards bootstrap prep (minus createThread) when it creates the thread", + () => + 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, so startTurn creates it from bootstrap. + 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"); + + yield* 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, + }, + }); + + const turnStart = dispatched.find((command) => command.type === "thread.turn.start"); + expect(turnStart?.type).toBe("thread.turn.start"); + // createThread is stripped (the thread already exists), but the + // worktree/setup prep for the first turn is forwarded, not dropped. + const bootstrap = + turnStart?.type === "thread.turn.start" ? (turnStart as any).bootstrap : undefined; + expect(bootstrap).toBeDefined(); + expect(bootstrap?.createThread).toBeUndefined(); + expect(bootstrap?.prepareWorktree).toEqual({ + projectCwd: "/tmp/project-agents", + baseBranch: "main", + }); + expect(bootstrap?.runSetupScript).toBe(true); + }), + ); + + 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 } + >(); + 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 } + >(); + 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( + "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 } + >(); + 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 new file mode 100644 index 00000000000..fdc22f9009a --- /dev/null +++ b/apps/server/src/plugins/capabilities/AgentsCapability.ts @@ -0,0 +1,652 @@ +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 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"; +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); +// 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", + { + 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()}`); + +// 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" || + 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"]; + }, + // 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. + turnAliases: Map< + string, + { readonly threadId: ThreadId; readonly messageId: MessageId } + > = 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 with a generous FIFO cap. Normal turns are + // pruned on their terminal read (readTerminalTurn) well before the cap, so + // eviction only ever affects long-abandoned turns that were started but never + // awaited — bounding what was previously unbounded growth for the plugin + // process lifetime. An evicted alias makes a later awaitTurn for that turn + // fall through to the not-found path (degraded, not a crash), which is only + // reachable under pathological (> cap) concurrent un-awaited turns. + const rememberTurnAlias = ( + turnId: TurnId, + entry: { readonly threadId: ThreadId; readonly messageId: MessageId }, + ) => { + const key = String(turnId); + if (!turnAliases.has(key) && turnAliases.size >= maxTurnAliases) { + const oldest = turnAliases.keys().next().value; + if (oldest !== undefined) turnAliases.delete(oldest); + } + turnAliases.set(key, entry); + }; + + const requireOwnedThread = (threadId: ThreadId) => + input.snapshots.getThreadOwnerById(threadId).pipe( + 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) => + 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; + // 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); + }), + ); + }); + + 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(), + }); + } + // 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 }); + // When we created the thread ourselves, forward the rest of the bootstrap + // (prepareWorktree / runSetupScript) with ONLY createThread stripped — the + // thread now exists and the decider would ignore createThread — so the + // first turn still gets its worktree/setup prep instead of dropping the + // whole bootstrap. bootstrap is non-null here (the createThread guard above + // errored otherwise). + const turnBootstrap = createdThread + ? (() => { + const { createThread: _createThread, ...rest } = bootstrap!; + return Object.keys(rest).length > 0 ? rest : undefined; + })() + : bootstrap; + yield* input.engine + .dispatch({ + type: "thread.turn.start", + commandId: request.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(() => + // 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.unwrap( + Effect.gen(function* () { + yield* requireOwnedThread(threadId); + // 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.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 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/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 new file mode 100644 index 00000000000..0f774d89ed5 --- /dev/null +++ b/apps/server/src/plugins/capabilities/DatabaseCapability.ts @@ -0,0 +1,11 @@ +import type { DatabaseCapability } from "@t3tools/plugin-sdk"; +import type * as SqlClient from "effect/unstable/sql/SqlClient"; + +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/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/FilesystemCapability.test.ts b/apps/server/src/plugins/capabilities/FilesystemCapability.test.ts new file mode 100644 index 00000000000..51530355232 --- /dev/null +++ b/apps/server/src/plugins/capabilities/FilesystemCapability.test.ts @@ -0,0 +1,293 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { FilesystemPathError, makeFilesystemCapability } from "./FilesystemCapability.ts"; +import { makePluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; + +const TestLayer = NodeServices.layer; +const layer = it.layer(TestLayer); + +const projectShell = (workspaceRoot: string, id = "project-1") => + ({ + id, + title: id, + workspaceRoot, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-03T00:00:00.000Z", + updatedAt: "2026-07-03T00:00:00.000Z", + }) as any; + +function makeCapability(input: { + readonly projectRoots: ReadonlyArray; + readonly grants: any; +}) { + return makeFilesystemCapability({ + snapshots: { + getShellSnapshot: () => + Effect.succeed({ + projects: input.projectRoots.map((root, index) => projectShell(root, `project-${index}`)), + threads: [], + } as any), + } as any, + grants: input.grants, + }); +} + +const makeTempDir = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix }); + }); + +const expectPathFailure = (effect: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* Effect.exit(effect); + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + assert.include(String(exit.cause), FilesystemPathError.name); + } + }); + +layer("FilesystemCapability", (it) => { + it.effect("rejects traversal and absolute relative paths before touching the filesystem", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* expectPathFailure(filesystem.readFileString({ root, relativePath: "../secret" })); + yield* expectPathFailure(filesystem.exists({ root, relativePath: "/tmp/secret" })); + }), + ), + ); + + it.effect("round-trips files, directories, stats, lists, roots, and idempotent remove", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.makeDirectory({ root, relativePath: "notes" }); + yield* filesystem.writeFileString({ + root, + relativePath: "notes/today.txt", + contents: "hello", + }); + assert.equal( + yield* filesystem.readFileString({ root, relativePath: "notes/today.txt" }), + "hello", + ); + assert.deepEqual( + Array.from(yield* filesystem.readFile({ root, relativePath: "notes/today.txt" })), + Array.from(new TextEncoder().encode("hello")), + ); + assert.isTrue(yield* filesystem.exists({ root, relativePath: "notes/today.txt" })); + const stat = yield* filesystem.stat({ root, relativePath: "notes/today.txt" }); + assert.equal(stat.type, "file"); + assert.equal(stat.size, 5); + assert.isAtLeast(stat.mtime, 0); + assert.deepEqual(yield* filesystem.listDir({ root, relativePath: "notes" }), [ + { name: "today.txt", relativePath: "notes/today.txt", type: "file" }, + ]); + assert.deepEqual( + (yield* filesystem.listDirRecursive({ root, relativePath: "" })).map( + (entry) => entry.relativePath, + ), + ["notes", "notes/today.txt"], + ); + assert.deepEqual(yield* filesystem.listRoots(), [root]); + + yield* filesystem.remove({ root, relativePath: "notes/today.txt" }); + yield* filesystem.remove({ root, relativePath: "notes/today.txt" }); + assert.isFalse(yield* filesystem.exists({ root, relativePath: "notes/today.txt" })); + }), + ), + ); + + it.effect("rejects symlink leaves and symlinked parents for sensitive operations", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(outside, "secret.txt"), "no")); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(outside, "secret.txt"), NodePath.join(root, "leaf-link")), + ); + yield* Effect.promise(() => + NodeFSP.symlink(outside, NodePath.join(root, "parent-link"), "dir"), + ); + + yield* expectPathFailure(filesystem.readFileString({ root, relativePath: "leaf-link" })); + yield* expectPathFailure( + filesystem.writeFileString({ root, relativePath: "leaf-link", contents: "changed" }), + ); + yield* expectPathFailure( + filesystem.makeDirectory({ root, relativePath: "parent-link/created-outside" }), + ); + assert.isFalse( + yield* Effect.promise(() => + NodeFSP.stat(NodePath.join(outside, "created-outside")) + .then(() => true) + .catch(() => false), + ), + ); + + yield* filesystem.writeFileString({ root, relativePath: "safe.txt", contents: "safe" }); + yield* expectPathFailure( + filesystem.rename({ + root, + fromRelativePath: "safe.txt", + toRelativePath: "parent-link/safe.txt", + }), + ); + }), + ), + ); + + it.effect("removes recursively without following symlinks outside the root", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.makeDirectory({ root, relativePath: "nested" }); + yield* filesystem.writeFileString({ + root, + relativePath: "nested/inside.txt", + contents: "inside", + }); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(outside, "keep.txt"), "keep")); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(outside, "keep.txt"), NodePath.join(root, "nested", "out")), + ); + + yield* filesystem.remove({ root, relativePath: "nested" }); + + assert.isFalse(yield* filesystem.exists({ root, relativePath: "nested" })); + assert.equal( + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(outside, "keep.txt"), "utf8")), + "keep", + ); + }), + ), + ); + + it.effect("fails exclusive create, no-overwrite rename, and read/write caps", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + + yield* filesystem.createFileExclusive({ root, relativePath: "one.txt", contents: "one" }); + assert.equal( + (yield* Effect.exit( + filesystem.createFileExclusive({ root, relativePath: "one.txt", contents: "two" }), + ))._tag, + "Failure", + ); + yield* filesystem.writeFileString({ root, relativePath: "two.txt", contents: "two" }); + assert.equal( + (yield* Effect.exit( + filesystem.rename({ root, fromRelativePath: "one.txt", toRelativePath: "two.txt" }), + ))._tag, + "Failure", + ); + + const tooLarge = "x".repeat(16 * 1024 * 1024 + 1); + assert.equal( + (yield* Effect.exit( + filesystem.writeFileString({ root, relativePath: "large.txt", contents: tooLarge }), + ))._tag, + "Failure", + ); + yield* filesystem.writeFileString({ root, relativePath: "small.txt", contents: "abcdef" }); + assert.equal( + yield* filesystem.readFileStringCapped({ + root, + relativePath: "small.txt", + maxBytes: 3, + }), + "abc", + ); + }), + ), + ); + + it.effect("grants worktree roots after VCS creation and rejects them after removal", () => + Effect.scoped( + Effect.gen(function* () { + const projectRoot = yield* makeTempDir("plugin-fs-project-"); + const worktreeRoot = yield* makeTempDir("plugin-fs-worktree-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [projectRoot], grants }); + + yield* filesystem.writeFileString({ + root: projectRoot, + relativePath: "project.txt", + contents: "project", + }); + yield* expectPathFailure( + filesystem.writeFileString({ + root: worktreeRoot, + relativePath: "worktree.txt", + contents: "denied", + }), + ); + + yield* grants.grant(worktreeRoot); + yield* filesystem.writeFileString({ + root: worktreeRoot, + relativePath: "worktree.txt", + contents: "allowed", + }); + assert.deepEqual( + (yield* filesystem.listRoots()).toSorted(), + [projectRoot, worktreeRoot].toSorted(), + ); + + yield* grants.revoke(worktreeRoot); + yield* expectPathFailure( + filesystem.readFileString({ root: worktreeRoot, relativePath: "worktree.txt" }), + ); + }), + ), + ); + + it.effect("keeps resolved absolute paths out of plugin-facing messages", () => + Effect.scoped( + Effect.gen(function* () { + const root = yield* makeTempDir("plugin-fs-root-"); + const outside = yield* makeTempDir("plugin-fs-outside-"); + const grants = yield* makePluginWorkspaceGrants; + const filesystem = makeCapability({ projectRoots: [root], grants }); + const outsideFile = NodePath.join(outside, "secret.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(outsideFile, "no")); + yield* Effect.promise(() => NodeFSP.symlink(outsideFile, NodePath.join(root, "link"))); + + const error = yield* filesystem + .readFileString({ root, relativePath: "link" }) + .pipe(Effect.flip); + const realOutsideFile = yield* Effect.promise(() => NodeFSP.realpath(outsideFile)); + + assert.notInclude(error.message, outsideFile); + assert.deepInclude((error as any).data, { resolvedPath: realOutsideFile }); + }), + ), + ); +}); diff --git a/apps/server/src/plugins/capabilities/FilesystemCapability.ts b/apps/server/src/plugins/capabilities/FilesystemCapability.ts new file mode 100644 index 00000000000..85f68931ffd --- /dev/null +++ b/apps/server/src/plugins/capabilities/FilesystemCapability.ts @@ -0,0 +1,897 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { DirEntry, FileStat, FilesystemCapability } from "@t3tools/plugin-sdk"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { PluginWorkspaceGrants } from "../PluginWorkspaceGrants.ts"; + +const FILE_MAX_BYTES = 16 * 1024 * 1024; +const LIST_RECURSIVE_MAX_ENTRIES = 500; +const READ_CHUNK_BYTES = 64 * 1024; + +type FilesystemOperation = + | "list-roots" + | "read-file" + | "read-file-string" + | "read-file-string-capped" + | "write-file" + | "create-file-exclusive" + | "exists" + | "stat" + | "list-dir" + | "list-dir-recursive" + | "make-directory" + | "remove" + | "rename"; + +interface FilesystemPathContext { + readonly root: string; + readonly relativePath: string; +} + +export class FilesystemPathError extends Schema.TaggedErrorClass()( + "FilesystemPathError", + { + root: Schema.String, + relativePath: Schema.String, + operation: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `Filesystem path '${this.relativePath}' in root '${this.root}' is not allowed: ${this.reason}`; + } +} + +export class FilesystemIoError extends Schema.TaggedErrorClass()( + "FilesystemIoError", + { + root: Schema.String, + relativePath: Schema.String, + operation: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `Filesystem operation '${this.operation}' failed for '${this.relativePath}' in root '${this.root}': ${this.reason}`; + } +} + +type FilesystemError = FilesystemPathError | FilesystemIoError; + +class NodePathNotFound extends Error { + readonly _tag = "NodePathNotFound"; +} + +const isFilesystemIoError = Schema.is(FilesystemIoError); + +const isNodeNotFound = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause as { readonly code?: unknown }).code === "ENOENT"; + +const isNodeSymlinkLoop = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause as { readonly code?: unknown }).code === "ELOOP"; + +const pathError = ( + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, + data?: unknown, +) => + new FilesystemPathError({ + ...context, + operation, + reason, + ...(data === undefined ? {} : { data }), + }); + +const ioError = ( + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, + data?: unknown, +) => + new FilesystemIoError({ + ...context, + operation, + reason, + ...(data === undefined ? {} : { data }), + }); + +const containsRealPath = (realRoot: string, realTarget: string): boolean => { + const relative = NodePath.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !NodePath.isAbsolute(relative)); +}; + +const isAbsoluteRelativePath = (relativePath: string): boolean => + NodePath.isAbsolute(relativePath) || + relativePath.startsWith("\\") || + /^[a-zA-Z]:[\\/]/u.test(relativePath); + +function parseRelativePath( + context: FilesystemPathContext, + operation: FilesystemOperation, +): Effect.Effect, FilesystemPathError> { + if (context.relativePath.includes("\0")) { + return Effect.fail(pathError(context, operation, "path contains a NUL byte")); + } + if (isAbsoluteRelativePath(context.relativePath)) { + return Effect.fail(pathError(context, operation, "relativePath must not be absolute")); + } + const normalized = context.relativePath.replace(/\\/gu, "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + if (segments.includes("..")) { + return Effect.fail(pathError(context, operation, "relativePath must not contain '..'")); + } + return Effect.succeed(segments); +} + +const outputRelativePath = (base: string, name: string) => + [...base.replace(/\\/gu, "/").split("/").filter(Boolean), name].join("/"); + +const statType = (stat: NodeFS.Stats): FileStat["type"] => { + if (stat.isFile()) return "file"; + if (stat.isDirectory()) return "directory"; + return "other"; +}; + +const lstatOrNull = ( + absPath: string, + context: FilesystemPathContext, + operation: FilesystemOperation, + reason: string, +) => + Effect.tryPromise({ + try: () => NodeFSP.lstat(absPath), + catch: (cause) => + isNodeNotFound(cause) + ? new NodePathNotFound() + : ioError(context, operation, reason, { + resolvedPath: absPath, + cause, + }), + }).pipe( + Effect.catch((error) => + error instanceof NodePathNotFound ? Effect.succeed(null) : Effect.fail(error), + ), + ); + +const readProjectRoots = (snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]) => + snapshots + .getShellSnapshot() + .pipe(Effect.map((snapshot) => snapshot.projects.map((p) => p.workspaceRoot))); + +function snapshotGrantedRoots(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; +}): Effect.Effect, Error> { + return Effect.gen(function* () { + const projectRoots = yield* readProjectRoots(input.snapshots); + const worktreeRoots = [...(yield* input.grants.snapshot())]; + return [...new Set([...projectRoots, ...worktreeRoots])]; + }); +} + +function requireRoot(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; +}): Effect.Effect< + { readonly roots: ReadonlyArray; readonly realRoot: string }, + FilesystemError +> { + return Effect.gen(function* () { + const roots = yield* snapshotGrantedRoots(input).pipe( + Effect.mapError((cause) => + ioError(input.context, input.operation, "failed to read granted roots", { cause }), + ), + ); + if (!roots.includes(input.context.root)) { + return yield* pathError(input.context, input.operation, "root is not granted", { + roots, + }); + } + const realRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.context.root), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve root", { cause }), + }); + return { roots, realRoot }; + }); +} + +function realpathExistingTarget(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly segments: ReadonlyArray; +}): Effect.Effect { + return Effect.gen(function* () { + const logicalTarget = NodePath.join(input.context.root, ...input.segments); + const realTarget = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(logicalTarget), + catch: (cause) => + isNodeNotFound(cause) + ? pathError(input.context, input.operation, "path does not exist", { + resolvedPath: logicalTarget, + cause, + }) + : ioError(input.context, input.operation, "failed to resolve path", { + resolvedPath: logicalTarget, + cause, + }), + }); + if (!containsRealPath(input.realRoot, realTarget)) { + return yield* pathError(input.context, input.operation, "path resolves outside root", { + resolvedRoot: input.realRoot, + resolvedPath: realTarget, + }); + } + return realTarget; + }); +} + +function resolveParent(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly parentSegments: ReadonlyArray; + readonly create: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + let current = input.realRoot; + for (const segment of input.parentSegments) { + if (segment === ".") continue; + const candidate = NodePath.join(current, segment); + let lstat = yield* lstatOrNull( + candidate, + input.context, + input.operation, + "failed to inspect parent path", + ); + if (lstat === null) { + if (!input.create) { + return yield* ioError(input.context, input.operation, "parent path does not exist", { + resolvedPath: candidate, + }); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.mkdir(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to create directory", { + resolvedPath: candidate, + cause, + }), + }); + lstat = yield* Effect.tryPromise({ + try: () => NodeFSP.lstat(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to inspect created directory", { + resolvedPath: candidate, + cause, + }), + }); + } + const realCandidate = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(candidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve parent path", { + resolvedPath: candidate, + cause, + }), + }); + if (!containsRealPath(input.realRoot, realCandidate)) { + return yield* pathError(input.context, input.operation, "parent resolves outside root", { + resolvedRoot: input.realRoot, + resolvedPath: realCandidate, + }); + } + const stat = lstat.isSymbolicLink() + ? yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realCandidate), + catch: (cause) => + ioError(input.context, input.operation, "failed to inspect resolved parent", { + resolvedPath: realCandidate, + cause, + }), + }) + : lstat; + if (!stat.isDirectory()) { + return yield* ioError(input.context, input.operation, "parent path is not a directory", { + resolvedPath: realCandidate, + }); + } + current = realCandidate; + } + return current; + }); +} + +function resolveLeafParent(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly segments: ReadonlyArray; + readonly createParent: boolean; +}): Effect.Effect<{ readonly realParent: string; readonly leaf: string }, FilesystemError> { + return Effect.gen(function* () { + const leaf = input.segments.at(-1); + if (!leaf || leaf === ".") { + return yield* pathError(input.context, input.operation, "path must include a final entry"); + } + const realParent = yield* resolveParent({ + context: input.context, + operation: input.operation, + realRoot: input.realRoot, + parentSegments: input.segments.slice(0, -1), + create: input.createParent, + }); + return { realParent, leaf }; + }); +} + +function ensureNoSymlinkLeaf(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; +}): Effect.Effect { + return Effect.gen(function* () { + const lstat = yield* lstatOrNull( + input.target, + input.context, + input.operation, + "failed to inspect target", + ); + if (lstat?.isSymbolicLink()) { + return yield* pathError(input.context, input.operation, "symlink leaf is not allowed", { + resolvedPath: input.target, + }); + } + }); +} + +function openNoFollow(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; + readonly flags: number; +}): Effect.Effect { + return Effect.tryPromise({ + try: () => NodeFSP.open(input.target, input.flags | NodeFS.constants.O_NOFOLLOW, 0o666), + catch: (cause) => + isNodeSymlinkLoop(cause) + ? pathError(input.context, input.operation, "symlink leaf is not allowed", { + resolvedPath: input.target, + cause, + }) + : ioError(input.context, input.operation, "failed to open file", { + resolvedPath: input.target, + cause, + }), + }); +} + +function closeHandle(handle: NodeFSP.FileHandle) { + return Effect.promise(() => handle.close()).pipe(Effect.ignore); +} + +function readBytesFromHandle(input: { + readonly handle: NodeFSP.FileHandle; + readonly maxBytes: number; + readonly failOnOverflow: boolean; + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; +}): Effect.Effect { + return Effect.gen(function* () { + const chunks: Buffer[] = []; + let total = 0; + const readLimit = input.failOnOverflow ? input.maxBytes + 1 : input.maxBytes; + while (total < readLimit) { + const buffer = Buffer.alloc(Math.min(READ_CHUNK_BYTES, readLimit - total)); + const result = yield* Effect.tryPromise({ + try: () => input.handle.read(buffer, 0, buffer.byteLength, null), + catch: (cause) => ioError(input.context, input.operation, "failed to read file", { cause }), + }); + if (result.bytesRead === 0) break; + chunks.push(buffer.subarray(0, result.bytesRead)); + total += result.bytesRead; + } + if (input.failOnOverflow && total > input.maxBytes) { + return yield* ioError(input.context, input.operation, "file exceeds the size limit", { + limit: input.maxBytes, + actual: total, + }); + } + return new Uint8Array(Buffer.concat(chunks, total)); + }); +} + +function readExistingFile(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realPath: string; + readonly maxBytes: number; + readonly failOnOverflow: boolean; +}): Effect.Effect { + return Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => NodeFSP.open(input.realPath, NodeFS.constants.O_RDONLY), + catch: (cause) => + ioError(input.context, input.operation, "failed to open file", { + resolvedPath: input.realPath, + cause, + }), + }), + (handle) => + Effect.gen(function* () { + const stat = yield* Effect.tryPromise({ + try: () => handle.stat(), + catch: (cause) => + ioError(input.context, input.operation, "failed to stat file", { + resolvedPath: input.realPath, + cause, + }), + }); + if (!stat.isFile()) { + return yield* ioError(input.context, input.operation, "path is not a file", { + resolvedPath: input.realPath, + }); + } + return yield* readBytesFromHandle({ ...input, handle }); + }), + closeHandle, + ); +} + +function writeBytesToTarget(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly target: string; + readonly contents: Uint8Array; + readonly exclusive: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.contents.byteLength > FILE_MAX_BYTES) { + return yield* ioError(input.context, input.operation, "file exceeds the size limit", { + limit: FILE_MAX_BYTES, + actual: input.contents.byteLength, + }); + } + yield* ensureNoSymlinkLeaf(input); + const flags = + NodeFS.constants.O_WRONLY | + NodeFS.constants.O_CREAT | + (input.exclusive ? NodeFS.constants.O_EXCL : NodeFS.constants.O_TRUNC); + yield* Effect.acquireUseRelease( + openNoFollow({ ...input, flags }), + (handle) => + Effect.tryPromise({ + try: () => handle.writeFile(input.contents), + catch: (cause) => + ioError(input.context, input.operation, "failed to write file", { + resolvedPath: input.target, + cause, + }), + }), + closeHandle, + ); + }); +} + +function dirEntryFor(input: { + readonly context: FilesystemPathContext; + readonly operation: FilesystemOperation; + readonly realRoot: string; + readonly absEntry: string; + readonly relativePath: string; + readonly name: string; +}): Effect.Effect { + return Effect.gen(function* () { + const realEntry = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.absEntry), + catch: (cause) => + ioError(input.context, input.operation, "failed to resolve directory entry", { + resolvedPath: input.absEntry, + cause, + }), + }).pipe(Effect.orElseSucceed(() => null)); + if (realEntry === null || !containsRealPath(input.realRoot, realEntry)) { + return null; + } + const stat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realEntry), + catch: (cause) => + ioError(input.context, input.operation, "failed to stat directory entry", { + resolvedPath: realEntry, + cause, + }), + }).pipe(Effect.orElseSucceed(() => null)); + if (stat === null) return null; + return { + name: input.name, + relativePath: input.relativePath, + type: statType(stat), + }; + }); +} + +function makeCapability(input: { + readonly snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]; + readonly grants: PluginWorkspaceGrants; +}): FilesystemCapability { + const prepare = (context: FilesystemPathContext, operation: FilesystemOperation) => + Effect.gen(function* () { + const segments = yield* parseRelativePath(context, operation); + const root = yield* requireRoot({ ...input, context, operation }); + return { ...root, segments }; + }); + + const writeFileBytes = ( + context: FilesystemPathContext, + contents: Uint8Array, + exclusive: boolean, + operation: "write-file" | "create-file-exclusive", + ) => + Effect.gen(function* () { + const { realRoot, segments } = yield* prepare(context, operation); + const { realParent, leaf } = yield* resolveLeafParent({ + context, + operation, + realRoot, + segments, + createParent: true, + }); + yield* writeBytesToTarget({ + context, + operation, + target: NodePath.join(realParent, leaf), + contents, + exclusive, + }); + }); + + const removePath = ( + context: FilesystemPathContext, + realRoot: string, + absPath: string, + operation: FilesystemOperation, + ): Effect.Effect => + Effect.gen(function* () { + const lstat = yield* lstatOrNull(absPath, context, operation, "failed to inspect path"); + if (lstat === null) return; + if (lstat.isSymbolicLink()) { + yield* Effect.tryPromise({ + try: () => NodeFSP.unlink(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove symlink", { + resolvedPath: absPath, + cause, + }), + }); + return; + } + const realPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(absPath), + catch: (cause) => + ioError(context, operation, "failed to resolve path", { resolvedPath: absPath, cause }), + }); + if (!containsRealPath(realRoot, realPath)) { + return yield* pathError(context, operation, "path resolves outside root", { + resolvedRoot: realRoot, + resolvedPath: realPath, + }); + } + if (lstat.isDirectory()) { + const entries = yield* Effect.tryPromise({ + try: () => NodeFSP.readdir(absPath), + catch: (cause) => + ioError(context, operation, "failed to read directory", { + resolvedPath: absPath, + cause, + }), + }); + for (const entry of entries) { + yield* removePath(context, realRoot, NodePath.join(absPath, entry), operation); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.rmdir(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove directory", { + resolvedPath: absPath, + cause, + }), + }); + return; + } + yield* Effect.tryPromise({ + try: () => NodeFSP.unlink(absPath), + catch: (cause) => + ioError(context, operation, "failed to remove file", { resolvedPath: absPath, cause }), + }); + }); + + return { + listRoots: () => + snapshotGrantedRoots(input).pipe( + Effect.map((roots) => [...roots].sort((left, right) => left.localeCompare(right))), + Effect.mapError((cause) => + ioError({ root: "", relativePath: "" }, "list-roots", "failed to read roots", { + cause, + }), + ), + ), + readFile: (context) => + Effect.gen(function* () { + const operation = "read-file" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + return yield* readExistingFile({ + context, + operation, + realPath, + maxBytes: FILE_MAX_BYTES, + failOnOverflow: true, + }); + }), + readFileString: (context) => + Effect.gen(function* () { + const bytes = yield* makeCapability(input).readFile(context); + return new TextDecoder().decode(bytes); + }), + readFileStringCapped: (context) => + Effect.gen(function* () { + const operation = "read-file-string-capped" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const maxBytes = Math.max(0, Math.min(Math.floor(context.maxBytes), FILE_MAX_BYTES)); + const bytes = yield* readExistingFile({ + context, + operation, + realPath, + maxBytes, + failOnOverflow: false, + }); + return new TextDecoder().decode(bytes); + }), + writeFile: (request) => writeFileBytes(request, request.contents, false, "write-file"), + writeFileString: (request) => + writeFileBytes(request, new TextEncoder().encode(request.contents), false, "write-file"), + createFileExclusive: (request) => + writeFileBytes( + request, + typeof request.contents === "string" + ? new TextEncoder().encode(request.contents) + : request.contents, + true, + "create-file-exclusive", + ), + exists: (context) => + Effect.gen(function* () { + const operation = "exists" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const logicalTarget = NodePath.join(context.root, ...segments); + const lstat = yield* lstatOrNull( + logicalTarget, + context, + operation, + "failed to inspect path", + ); + if (lstat === null) return false; + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + return containsRealPath(realRoot, realPath); + }), + stat: (context) => + Effect.gen(function* () { + const operation = "stat" as const; + const { realRoot, segments } = yield* prepare(context, operation); + const realPath = yield* realpathExistingTarget({ context, operation, realRoot, segments }); + const stat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(realPath), + catch: (cause) => + ioError(context, operation, "failed to stat path", { resolvedPath: realPath, cause }), + }); + return { + type: statType(stat), + size: stat.size, + mtime: stat.mtimeMs, + 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)) { + return yield* pathError(fromContext, operation, "source resolves outside root", { + resolvedRoot: realRoot, + resolvedPath: fromReal, + }); + } + } + const toExists = + (yield* lstatOrNull(toAbs, toContext, operation, "failed to inspect destination")) !== + null; + if (toExists) { + return yield* ioError(toContext, operation, "destination already exists", { + resolvedPath: toAbs, + }); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.rename(fromAbs, toAbs), + catch: (cause) => + ioError(fromContext, operation, "failed to rename path", { + fromResolvedPath: fromAbs, + toResolvedPath: toAbs, + cause, + }), + }); + }), + }; +} + +export const makeFilesystemCapability = makeCapability; diff --git a/apps/server/src/plugins/capabilities/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/HttpClientCapability.test.ts b/apps/server/src/plugins/capabilities/HttpClientCapability.test.ts new file mode 100644 index 00000000000..f3e59215464 --- /dev/null +++ b/apps/server/src/plugins/capabilities/HttpClientCapability.test.ts @@ -0,0 +1,256 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/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.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..e35e36dfa3e --- /dev/null +++ b/apps/server/src/plugins/capabilities/HttpClientCapability.ts @@ -0,0 +1,315 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeHttp from "node:http"; +import * as NodeHttps from "node:https"; +import * as NodeStream from "node:stream"; + +import type { HttpClientCapability, HttpClientRequestInput } from "@t3tools/plugin-sdk"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + defaultLookup, + OutboundUrlError, + OutboundUrlValidator, + type ResolvedAddress, + type UrlValidatorDeps, +} from "../OutboundUrlValidator.ts"; +import { readHttpResponseBytesCapped } from "../readHttpResponseBytesCapped.ts"; + +const DEFAULT_RESPONSE_MAX_BYTES = 8 * 1024 * 1024; +const HARD_RESPONSE_MAX_BYTES = 32 * 1024 * 1024; +const REQUEST_BODY_MAX_BYTES = 32 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; +const HARD_TIMEOUT_MS = 120_000; +// Reject header names/values carrying CR/LF or other control chars so a plugin +// forwarding attacker-influenced data cannot inject/smuggle a second header. +const hasControlChars = (value: string): boolean => { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +}; + +export class HttpEgressBlockedError extends Schema.TaggedErrorClass()( + "HttpEgressBlockedError", + { + host: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `HTTP egress to '${this.host}' is blocked: ${this.reason}`; + } +} + +export class HttpClientError extends Schema.TaggedErrorClass()("HttpClientError", { + host: Schema.String, + reason: Schema.String, + data: Schema.optional(Schema.Unknown), +}) { + override get message(): string { + return `HTTP request to '${this.host}' failed: ${this.reason}`; + } +} + +const isHttpClientError = Schema.is(HttpClientError); + +export interface PluginPinnedHttpRequest { + readonly url: URL; + readonly method: string; + readonly headers: Readonly>; + readonly body: Uint8Array | null; + readonly timeoutMs: number; + readonly address: ResolvedAddress; +} + +export type PluginHttpClientTransport = ( + request: PluginPinnedHttpRequest, +) => Effect.Effect; + +type HttpClientJsonRequestInput = Omit & { + readonly body?: unknown; +}; +type HttpClientGetJsonInput = Omit; + +export class PluginHttpClientTransportService extends Context.Service< + PluginHttpClientTransportService, + PluginHttpClientTransport +>()("t3/plugins/capabilities/HttpClientCapability/PluginHttpClientTransportService") {} + +const bodyToBytes = (body: HttpClientRequestInput["body"]): Uint8Array | null => { + if (body === undefined) return null; + return typeof body === "string" ? new TextEncoder().encode(body) : body; +}; + +const clampPositiveInteger = (value: number | undefined, fallback: number, hardMax: number) => { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.min(Math.floor(value), hardMax); +}; + +const hostForMessage = (rawUrl: string): string => { + try { + return new URL(rawUrl).hostname || rawUrl; + } catch { + return rawUrl; + } +}; + +const validateHeaders = ( + headers: Readonly> | undefined, + host: string, +): Effect.Effect>, HttpClientError> => { + if (!headers) return Effect.succeed({}); + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (hasControlChars(name) || hasControlChars(value)) { + return Effect.fail( + new HttpClientError({ host, reason: "request header contains control characters" }), + ); + } + normalized[name] = value; + } + return Effect.succeed(normalized); +}; + +function nodeHeadersToWebHeaders(headers: NodeHttp.IncomingHttpHeaders): Headers { + const webHeaders = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) webHeaders.append(name, item); + } else { + webHeaders.set(name, value); + } + } + return webHeaders; +} + +function makeResponse(input: { + readonly url: URL; + readonly method: string; + readonly response: NodeHttp.IncomingMessage; +}): HttpClientResponse.HttpClientResponse { + const request = HttpClientRequest.make(input.method as "GET")(input.url.toString()); + const body = NodeStream.Readable.toWeb(input.response) as ReadableStream; + return HttpClientResponse.fromWeb( + request, + new Response(body, { + status: input.response.statusCode ?? 0, + headers: nodeHeadersToWebHeaders(input.response.headers), + }), + ); +} + +const nodePinnedTransport: PluginHttpClientTransport = (input) => + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const client = input.url.protocol === "http:" ? NodeHttp : NodeHttps; + const request = client.request( + input.url, + { + method: input.method, + headers: input.headers, + timeout: input.timeoutMs, + lookup: (_hostname, _options, callback) => { + callback(null, input.address.address, input.address.family); + }, + }, + (response) => { + resolve(makeResponse({ url: input.url, method: input.method, response })); + }, + ); + request.on("timeout", () => { + request.destroy(new Error("timeout")); + }); + request.on("error", reject); + if (input.body) { + request.write(Buffer.from(input.body)); + } + request.end(); + }), + catch: (cause) => + // 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, + ); + const response = yield* transport({ + url: resolved.url, + method: requestInput.method.toUpperCase(), + headers, + body: requestBody, + timeoutMs, + address: resolved.addresses[0]!, + }); + const body = yield* readHttpResponseBytesCapped({ + response, + maxBytes: maxResponseBytes, + tooLarge: (actual) => + new HttpClientError({ + host: resolved.url.hostname, + reason: "response body exceeded the size limit", + data: { limit: maxResponseBytes, actual }, + }), + readFailed: (cause) => + isHttpClientError(cause) + ? cause + : new HttpClientError({ + host: resolved.url.hostname, + reason: "failed to read response body", + data: { cause }, + }), + }); + return { + status: response.status, + headers: response.headers, + body, + }; + }); + + return { + request, + requestJson: (jsonInput: HttpClientJsonRequestInput) => + Effect.gen(function* () { + const { body: jsonBody, ...requestRest } = jsonInput; + let body: string | undefined; + if (jsonBody !== undefined) { + // @effect-diagnostics-next-line preferSchemaOverJson:off -- SDK convenience wrapper accepts arbitrary JSON payloads. + body = JSON.stringify(jsonBody); + } + const response = yield* request({ + ...requestRest, + ...(body === undefined ? {} : { body }), + headers: { + accept: "application/json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + ...jsonInput.headers, + }, + }); + return yield* parseJson(response.body, hostForMessage(jsonInput.url)); + }), + getJson: (url: string, jsonInput: HttpClientGetJsonInput = {}) => + request({ + ...jsonInput, + method: "GET", + url, + headers: { + accept: "application/json", + ...jsonInput.headers, + }, + }).pipe(Effect.flatMap((response) => parseJson(response.body, hostForMessage(url)))), + }; +} diff --git a/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts new file mode 100644 index 00000000000..731ff2e007d --- /dev/null +++ b/apps/server/src/plugins/capabilities/PluginCapabilities.test.ts @@ -0,0 +1,566 @@ +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), + 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: () => + 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.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, + 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) }), + generateBoardProposal: (input) => + Effect.succeed({ proposedDefinition: { fromPrompt: input.prompt }, rationale: "test" }), + }); + 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 cloneInputs: unknown[] = []; + const mergeInputs: unknown[] = []; + const detailInputs: unknown[] = []; + const checkInputs: unknown[] = []; + const reviewInputs: unknown[] = []; + const commentInputs: 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", + }), + 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, + }); + + 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); + 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", + draft: true, + }); + assert.equal(createInputs.length, 1); + assert.deepEqual(createInputs[0], { + cwd: "/repo", + baseBranch: "main", + headSelector: "feature", + title: "PR", + bodyFile: "/tmp/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" }); + }), +); + +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.test.ts b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.test.ts new file mode 100644 index 00000000000..e711b229855 --- /dev/null +++ b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.test.ts @@ -0,0 +1,55 @@ +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); + }), + ); +}); diff --git a/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts new file mode 100644 index 00000000000..930fb8b1454 --- /dev/null +++ b/apps/server/src/plugins/capabilities/ProjectionsReadCapability.ts @@ -0,0 +1,92 @@ +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))), + 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))), + }; +} 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..afee094586d --- /dev/null +++ b/apps/server/src/plugins/capabilities/SourceControlCapability.ts @@ -0,0 +1,41 @@ +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), + getRepositoryCloneUrls: (request) => input.github.getRepositoryCloneUrls(request), + createPullRequest: (request) => + 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) => input.github.mergePullRequest(request), + getPullRequestDetail: (request) => input.github.getPullRequestDetail(request), + listPullRequestChecks: (request) => input.github.listPullRequestChecks(request), + listPullRequestReviews: (request) => input.github.listPullRequestReviews(request), + listPullRequestReviewComments: (request) => input.github.listPullRequestReviewComments(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..b485bafaec9 --- /dev/null +++ b/apps/server/src/plugins/capabilities/TextGenerationCapability.ts @@ -0,0 +1,15 @@ +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), + 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 new file mode 100644 index 00000000000..977a106df21 --- /dev/null +++ b/apps/server/src/plugins/capabilities/VcsCapability.test.ts @@ -0,0 +1,413 @@ +// @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, 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 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 { makePluginWorkspaceGrants } from "../PluginWorkspaceGrants.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 grants = yield* makePluginWorkspaceGrants; + const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore, grants }); + + const rejected = yield* Effect.exit(vcs.status({ worktreePath: "relative/path" })); + expect(rejected._tag).toBe("Failure"); + 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); + expect([...(yield* grants.snapshot())]).toContain(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 }); + expect([...(yield* grants.snapshot())]).not.toContain(worktreePath); + 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("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 = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + 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; + const vcs = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + + 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 = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + + 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* () { + 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("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 = 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", + 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("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 = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + + // 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 = makeVcsCapability({ git: gitDriver, checkpoints: checkpointStore }); + 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* () { + 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..b151e1d8052 --- /dev/null +++ b/apps/server/src/plugins/capabilities/VcsCapability.ts @@ -0,0 +1,528 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import { GitCommandError } from "@t3tools/contracts"; +import type { VcsCapability, VcsRef, 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"; +import type { PluginWorkspaceGrants } from "../PluginWorkspaceGrants.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 } : {}), + ...(input.stderr !== undefined ? { stderr: GitCommandError.capStderr(input.stderr) } : {}), + detail: input.detail, + }); +} + +export function makeVcsCapability(input: { + readonly git: GitVcsDriver.GitVcsDriver["Service"]; + readonly checkpoints: CheckpointStore["Service"]; + readonly grants?: PluginWorkspaceGrants | undefined; +}): 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); + const result = yield* input.git.createWorktree({ + cwd, + refName: request.ref, + newRefName: request.newBranch, + baseRefName: request.baseRef, + path, + }); + if (input.grants) { + yield* input.grants.grant(result.worktree.path ?? path); + } + return result; + }), + + removeWorktree: (request) => + 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, + }); + if (input.grants) { + yield* input.grants.revoke(path); + } + }), + + 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, + }), + ), + ), + + removePath: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.git.execute({ + operation: "PluginVcsCapability.removePath", + cwd, + args: ["rm", "-r", "-f", "--ignore-unmatch", "--", request.path], + }), + ), + Effect.asVoid, + ), + + clean: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.git.execute({ + operation: "PluginVcsCapability.clean", + cwd, + args: ["clean", "-f", "-d", "--", request.path], + }), + ), + Effect.asVoid, + ), + + currentBranch: ({ worktreePath }) => + requireAbsolute("worktreePath", worktreePath).pipe( + Effect.flatMap((cwd) => + input.git.execute({ + operation: "PluginVcsCapability.currentBranch", + cwd, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + }), + ), + Effect.map((result) => result.stdout.trim()), + ), + + aheadCount: (request) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + input.git.execute({ + operation: "PluginVcsCapability.aheadCount", + cwd, + args: ["rev-list", "--count", `${request.base}..${request.head}`], + }), + ), + Effect.flatMap((result) => { + const count = Number.parseInt(result.stdout.trim(), 10); + return Number.isFinite(count) + ? Effect.succeed(count) + : Effect.fail( + gitCommandError({ + operation: "PluginVcsCapability.aheadCount", + cwd: request.worktreePath, + args: ["rev-list", "--count", `${request.base}..${request.head}`], + stdout: result.stdout, + stderr: result.stderr, + detail: "git rev-list returned a non-numeric count", + }), + ); + }), + ), + + listRefs: ({ repoRoot }) => + requireAbsolute("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 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 ?? "", + request.noVerify ? { noVerify: true } : {}, + ); + return { + status: "created" as const, + commitSha: result.commitSha, + }; + }), + + merge: (request) => + Effect.gen(function* () { + const cwd = yield* requireAbsolute("worktreePath", request.worktreePath); + const args = [ + "merge", + ...(request.noFf ? ["--no-ff"] : []), + ...(request.noVerify ? ["--no-verify"] : []), + ...(request.message ? ["-m", request.message] : []), + 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) { + if (request.abortOnConflict) { + yield* input.git + .execute({ + operation: "PluginVcsCapability.merge.abort", + cwd, + args: ["merge", "--abort"], + }) + .pipe(Effect.asVoid); + } + return { + status: "conflict" as const, + conflictedFiles, + stdout: result.stdout, + stderr: result.stderr, + }; + } + + 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, + 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}`, + ], + }), + + // 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) => + requireAbsolute("worktreePath", request.worktreePath).pipe( + Effect.flatMap((cwd) => + Effect.gen(function* () { + 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, + `${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((path) => path.length > 0); + const untrackedDiffs = yield* Effect.forEach( + untrackedPaths, + (path) => + input.git.execute({ + operation: "PluginVcsCapability.diffRefToWorkingTree.untracked.diff", + cwd, + args: [ + "diff", + "--no-ext-diff", + "--no-index", + "--patch", + "--minimal", + ...wsArgs, + "--", + "/dev/null", + path, + ], + 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, + }), + ), + ), + + 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/plugins/pluginResolveHooks.ts b/apps/server/src/plugins/pluginResolveHooks.ts new file mode 100644 index 00000000000..f975d410d85 --- /dev/null +++ b/apps/server/src/plugins/pluginResolveHooks.ts @@ -0,0 +1,40 @@ +let pluginsRootUrl = ""; +let hostAnchorUrl: string | undefined; + +export function initialize(data: unknown) { + 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; + } + } +} + +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)) { + // `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/plugins/readHttpResponseBytesCapped.ts b/apps/server/src/plugins/readHttpResponseBytesCapped.ts new file mode 100644 index 00000000000..b5081c37be7 --- /dev/null +++ b/apps/server/src/plugins/readHttpResponseBytesCapped.ts @@ -0,0 +1,51 @@ +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import type { HttpClientResponse } from "effect/unstable/http"; + +interface CappedReadExpectedFailure { + readonly _tag: "CappedReadExpectedFailure"; + readonly error: E; +} + +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) => E; + readonly readFailed: (cause: unknown) => E; +}) => + 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(expectedFailure(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) => + isExpectedFailure(cause) ? cause.error : input.readFailed(cause), + ), + ); 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..aadf11cb2cc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3,9 +3,11 @@ 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, + AuthAdministrativeScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, CommandId, @@ -69,6 +71,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 +115,11 @@ 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 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"; const defaultProjectId = ProjectId.make("project-default"); @@ -705,6 +713,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, }), @@ -731,6 +740,46 @@ 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.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( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -919,9 +968,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 } @@ -1247,6 +1294,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({ @@ -1363,10 +1441,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"); @@ -1384,16 +1459,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 0c632d8486c..10986c5ea47 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -32,6 +32,21 @@ 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 { 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"; +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"; +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"; @@ -44,6 +59,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"; @@ -83,6 +99,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, @@ -237,6 +256,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( @@ -284,7 +310,62 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +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, + ServerSecretStore.layer, + ServerEnvironment.layer, + OutboundUrlLookupLive, + PluginHttpClientTransportLive, +); +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 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, +); + +const RuntimeCoreBaseDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), @@ -293,6 +374,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, @@ -354,6 +439,8 @@ 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/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/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/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..2069be4a705 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,316 @@ 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( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + argumentCount: 5, + exitCode: 1, + detail: "Process exited with a non-zero status.", + failureKind: "command-failed", + stderr, + stderrLength: stderr.length, + stderrTruncated: false, + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .mergePullRequest({ + cwd: "/repo", + number: 42, + strategy: "squash", + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "GitHubCliCommandError"); + assert.equal((error.cause as { readonly stderr?: string }).stderr, stderr); + assert.notInclude(error.message, "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"); + assert.equal((error.cause as Error).message, "bad exit"); + }).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("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..ba07d7a13bd 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -135,6 +135,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 +196,10 @@ export const GitHubCliError = Schema.Union([ GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, + GitHubPullRequestDetailDecodeError, + GitHubPullRequestChecksDecodeError, + GitHubPullRequestReviewsDecodeError, + GitHubPullRequestReviewCommentsDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -196,6 +252,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 +322,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 +373,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 +603,165 @@ 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) => + // `gh pr checks` exits 8 while checks are pending and 1 when some fail, + // yet still prints valid JSON. Tolerate those exit codes (and 0) as long + // as stdout parses; any other exit code is a real failure. + process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "checks", String(input.number), "--json", "name,state,bucket,link"], + 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( + new GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error( + result.stderr.trim() || `gh pr checks exited with code ${exitCode}.`, + ), + }), + ); + } + const raw = result.stdout.trim(); + if (raw.length === 0) { + 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) => + 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..5dd5b4dd989 --- /dev/null +++ b/apps/server/src/textGeneration/BoardProposalNoTool.test.ts @@ -0,0 +1,138 @@ +/** + * 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 (no MCP, hooks, skills, or dev-instructions)", () => { + const args = buildCodexExecArgs({ ...base, ignoreUserConfig: true }); + // `--ignore-user-config` is the Codex analog of Claude's strict-mcp suppression, + // and additionally drops config-driven hooks/skills/developer_instructions — the + // arbitrary-execution surface a no-tool generation must not load. Auth still uses + // CODEX_HOME; model + effort are passed explicitly on the CLI, so they survive. + expect(args).toContain("--ignore-user-config"); + // 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 does NOT ignore the user config (unchanged behavior)", () => { + const args = buildCodexExecArgs(base); + expect(args).not.toContain("--ignore-user-config"); + }); +}); + +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..e3affbaca64 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,49 @@ 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. + * + * `ignoreUserConfig` adds `--ignore-user-config`, which is the no-tool posture + * used by the board-proposal op: it stops Codex from loading + * `$CODEX_HOME/config.toml`, so configured MCP servers, hooks, skills, and + * `developer_instructions` are NOT loaded — the analog of the Claude path's + * `--strict-mcp-config --mcp-config "{}"` suppression, and broader. Auth still + * uses `CODEX_HOME`, and the model + reasoning effort (+ optional service tier) + * are passed explicitly here, so they survive the dropped config. Git ops keep + * the user config (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 ignoreUserConfig?: boolean; +}): Array { + return [ + "exec", + "--ephemeral", + "--skip-git-repo-check", + ...(input.ignoreUserConfig ? ["--ignore-user-config"] : []), + "-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 +141,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateBoardProposal", value: unknown, ): Effect.Effect => encodeJsonString(value).pipe( @@ -116,7 +161,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 +199,23 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func imagePaths = [], cleanupPaths = [], modelSelection, + ignoreUserConfig = 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 (MCP/hooks/skills/dev-instructions). + // Only the board-proposal op sets this; git ops keep the user config. + ignoreUserConfig?: boolean; }): Effect.fn.Return { const schemaJson = yield* encodeJsonForOperation( operation, @@ -180,24 +231,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, + ignoreUserConfig, + }), { env: resolvedEnvironment }, ); const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -395,10 +437,55 @@ 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. `codex exec -s read-only` + // prevents writes, but the agent can still READ repo files from process.cwd(). + // Pointing cwd to an empty temp dir removes the repo from 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 MCP servers, hooks, skills, or + // developer_instructions from the user's Codex config get loaded. + ignoreUserConfig: 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..88d084a54a4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -110,7 +110,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 +134,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); assert.isNumber(error.exitCode); assert.isAbove(error.stderrLength ?? 0, 0); + 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"); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a406cbce549..2e5fc87f909 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -85,6 +85,8 @@ const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze GitCommandError.capStderr(stderr); + type TraceTailState = { processedChars: number; remainder: string; @@ -744,6 +746,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* exitCode, stdoutLength: stdout.text.length, stderrLength: stderr.text.length, + stderr: cappedGitStderr(stderr.text), }); } @@ -825,6 +828,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, stderrLength: result.stderr.length, + stderr: cappedGitStderr(result.stderr), }), ); }), @@ -1564,7 +1568,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..03884f03840 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -107,6 +107,7 @@ describe("VcsProcess.run", () => { exitCode: 2, detail: "Process exited with a non-zero status.", failureKind: "command-failed", + stderr: secretStderr, stderrLength: secretStderr.length, stderrTruncated: false, }); @@ -115,7 +116,7 @@ describe("VcsProcess.run", () => { }).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({ @@ -132,6 +133,7 @@ describe("VcsProcess.run", () => { exitCode: 1, detail: "Authentication failed.", failureKind: "authentication", + stderr: secretStderr, stderrLength: secretStderr.length, stderrTruncated: false, }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9020e99f670..9b154eb0f0c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -18,6 +18,7 @@ import { AuthTerminalOperateScope, AuthAccessReadScope, AuthAccessStreamError, + AuthPluginsManageScope, type AuthAccessStreamEvent, type AuthEnvironmentScope, AuthSessionId, @@ -44,6 +45,7 @@ import { RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, + PLUGINS_WS_METHODS, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -56,6 +58,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 +114,9 @@ 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 { PluginManagementRpcHandlers } from "./plugins/PluginManagementRpcHandlers.ts"; +import { PluginRpcDispatcher } from "./plugins/PluginRpcDispatcher.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -343,6 +349,23 @@ 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], + [PLUGINS_WS_METHODS.sourcesList, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.sourcesAdd, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.sourcesRemove, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.catalog, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installBegin, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installConfirm, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.installAbort, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.setEnabled, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.uninstall, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.upgradeBegin, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.upgradeConfirm, AuthPluginsManageScope], + [PLUGINS_WS_METHODS.checkUpdates, AuthPluginsManageScope], ]); function toAuthAccessStreamEvent( @@ -434,6 +457,9 @@ 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 pluginManagement = yield* PluginManagementRpcHandlers; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -443,14 +469,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 +1199,107 @@ 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, + }, + ), + [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/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/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/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 21525b56b77..36f78d3043d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -126,6 +126,8 @@ 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 { getArm64IntelBuildWarningDescription, @@ -2283,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} + diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index d0fb12f6153..7dbe17cbe16 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -22,6 +22,7 @@ import { AuthTerminalOperateScope, type AuthClientSession, type AuthEnvironmentScope, + type AuthScope, type AuthPairingLink, type AdvertisedEndpoint, type DesktopDiscoveredSshHost, @@ -220,7 +221,10 @@ 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"}`; 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..9b608756806 --- /dev/null +++ b/apps/web/src/components/settings/SettingsSidebarNav.test.ts @@ -0,0 +1,44 @@ +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/plugins", + "/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/plugins", + "/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..a630f972780 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,38 +23,59 @@ 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/plugins" | "/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 }, { 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 }, ]; +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 +95,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/components/settings/plugins/PluginsSettings.logic.test.tsx b/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx new file mode 100644 index 00000000000..f3a9bdbfbde --- /dev/null +++ b/apps/web/src/components/settings/plugins/PluginsSettings.logic.test.tsx @@ -0,0 +1,152 @@ +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, + hasStyles: false, + 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/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 1c5426d953e..f8a5b175277 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; 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..4351a010a0f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,4 @@ +import "./plugins/hostSingletons"; import React from "react"; import ReactDOM from "react-dom/client"; import { ClerkProvider } from "@clerk/react"; diff --git a/apps/web/src/plugins/PluginProjectActions.tsx b/apps/web/src/plugins/PluginProjectActions.tsx new file mode 100644 index 00000000000..9e9185a3861 --- /dev/null +++ b/apps/web/src/plugins/PluginProjectActions.tsx @@ -0,0 +1,55 @@ +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.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..334265b838a --- /dev/null +++ b/apps/web/src/plugins/PluginSidebarSections.tsx @@ -0,0 +1,63 @@ +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"; + +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 router = useRouter(); + const sections = getVisiblePluginSidebarSections(snapshot); + + if (sections.length === 0) { + return null; + } + + 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 + : 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 new file mode 100644 index 00000000000..c915b6cb4c0 --- /dev/null +++ b/apps/web/src/plugins/PluginUiHost.test.tsx @@ -0,0 +1,173 @@ +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, + hasStyles: false, + 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, + }); + ctx.registerCommand({ + id: "refresh", + title: "Refresh", + run: () => undefined, + }); + ctx.registerProjectAction({ + id: "new-board", + render: () => 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.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({}); + }); + + 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.commands).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..4702a3299a0 --- /dev/null +++ b/apps/web/src/plugins/PluginUiHost.tsx @@ -0,0 +1,392 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { + PluginCommandRegistration, + PluginComponent, + PluginProjectActionRenderProps, + PluginRouteComponentProps, + PluginSettingsComponentProps, + PluginSidebarSectionRenderProps, + 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 { + readonly pluginId: PluginId; + readonly path: string; + readonly component: PluginComponent; +} + +export interface RegisteredPluginSidebarSection { + readonly pluginId: PluginId; + readonly id: string; + readonly title: string; + readonly render: (props: PluginSidebarSectionRenderProps) => unknown; +} + +export interface RegisteredPluginSettingsPage { + readonly pluginId: PluginId; + readonly id: string; + readonly title: string; + 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 { + readonly routes: ReadonlyArray; + readonly sidebarSections: ReadonlyArray; + readonly settingsPages: ReadonlyArray; + readonly commands: ReadonlyArray; + readonly projectActions: 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 projectActions: 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([]), + projectActions: 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 projectActions: 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); + projectActions.push(...loaded.projectActions); + } + + return { routes, sidebarSections, settingsPages, commands, projectActions, 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`; +} + +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 { + 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}`)); + + // 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)); + if (pluginsToLoad.length > 0) { + await waitForHost(); + } + + for (const plugin of pluginsToLoad) { + const routes: Array = []; + const sidebarSections: Array = []; + const settingsPages: Array = []; + const commands: Array = []; + const projectActions: 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, context: ctx }); + }, + registerProjectAction: (registration) => { + projectActions.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, + projectActions, + 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: [], + projectActions: [], + 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..4f6e1a32e03 --- /dev/null +++ b/apps/web/src/plugins/hostSingletons.test.ts @@ -0,0 +1,95 @@ +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/contracts", + "@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..36d781c8326 --- /dev/null +++ b/apps/web/src/plugins/hostSingletons.ts @@ -0,0 +1,69 @@ +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"; +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/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, + "react-dom/client": ReactDOMClient, + "react/jsx-runtime": jsxRuntime, + "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); + +export function getPluginHost(): PluginHostSingletons { + if (!globalThis.__T3_PLUGIN_HOST__) { + throw new Error("T3 plugin host singletons have not been published."); + } + return globalThis.__T3_PLUGIN_HOST__; +} diff --git a/apps/web/src/plugins/pluginSdkAtomReact.ts b/apps/web/src/plugins/pluginSdkAtomReact.ts new file mode 100644 index 00000000000..f1f3ccc7e55 --- /dev/null +++ b/apps/web/src/plugins/pluginSdkAtomReact.ts @@ -0,0 +1,20 @@ +export { + HydrationBoundary, + RegistryContext, + RegistryProvider, + TypeId, + make, + scheduleTask, + useAtom, + useAtomInitialValues, + useAtomMount, + useAtomRef, + useAtomRefProp, + useAtomRefPropValue, + useAtomRefresh, + useAtomSet, + useAtomSubscribe, + useAtomSuspense, + useAtomValue, +} from "@effect/atom-react"; +export { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..46cfe75a427 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,13 +15,16 @@ 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' 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', @@ -52,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', @@ -77,6 +85,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,50 +101,65 @@ 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 '/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 '/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 '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/plugins': typeof SettingsPluginsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': 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 '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/plugins': typeof SettingsPluginsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_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,44 +167,53 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/$environmentId/p/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/$environmentId/p/$pluginId/$' id: | '__root__' | '/_chat' | '/pair' | '/settings' + | '/settings/$' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' + | '/settings/plugins' | '/settings/providers' | '/settings/source-control' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/_chat/$environmentId/p/$pluginId/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -229,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' @@ -264,6 +308,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 +329,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,32 +343,38 @@ 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 SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsPluginsRoute: typeof SettingsPluginsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } const SettingsRouteChildren: SettingsRouteChildren = { + SettingsSplatRoute: SettingsSplatRoute, SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsPluginsRoute: SettingsPluginsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } 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..d6240717383 --- /dev/null +++ b/apps/web/src/routes/_chat.$environmentId.p.$pluginId.$.tsx @@ -0,0 +1,77 @@ +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 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)); + + 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, + environmentId: typeof params.environmentId === "string" ? params.environmentId : null, + search: normalizeSearch(search), + })} + + + ); +} + +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/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.test.ts b/apps/web/src/state/plugins.test.ts new file mode 100644 index 00000000000..4c7f764ac4a --- /dev/null +++ b/apps/web/src/state/plugins.test.ts @@ -0,0 +1,151 @@ +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 { + 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 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, + 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, + hasStyles: false, + lastError: null, + }, + ]; + }), + ).pipe(Stream.runCollect); + + // 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, { + 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..5acd5371270 --- /dev/null +++ b/apps/web/src/state/plugins.ts @@ -0,0 +1,322 @@ +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 { + 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, + 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, AtomRegistry } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +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); + }); +} + +// 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, +): Stream.Stream { + const reloads = lifecycleEvents.pipe( + 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, + { + label: "web-plugins:list", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => { + const loadPlugins = listPlugins().pipe( + Effect.map((result) => Option.some(result.plugins)), + Effect.catchCause((cause) => + Effect.logWarning("Could not refresh plugin list", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(Option.none>())), + ), + ); + 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 => + resolvePluginListWithCache( + environmentId, + get( + environmentPluginListResultAtom({ + environmentId: environmentId as never, + input: {}, + }), + ), + lastKnownPluginListByEnvironment, + ), + ).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 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/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/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..d53b8c6dbb6 --- /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", "filesystem", "httpClient"], + "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..a7a78b2f48c --- /dev/null +++ b/fixtures/hello-board/scripts/build.mjs @@ -0,0 +1,178 @@ +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 NodePath.resolve(fixtureRoot, direct.slice("--out-dir=".length)); + const index = argv.indexOf("--out-dir"); + 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 = 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 = NodePath.join(outDir, tarballName); +const shaPath = `${tarballPath}.sha256`; +const marketplacePath = NodePath.join(outDir, "marketplace.json"); + +function run(command, args) { + const result = NodeChildProcess.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), + ]); +} + +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: 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 }, +); +NodeFS.writeFileSync(tarballPath, archive); + +const sha256 = NodeCrypto.createHash("sha256").update(archive).digest("hex"); +NodeFS.writeFileSync(shaPath, `${sha256} ${tarballName}\n`); +NodeFS.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: NodeURL.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..f95530396b8 --- /dev/null +++ b/fixtures/hello-board/server/index.ts @@ -0,0 +1,130 @@ +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* hostApi.database; + const filesystem = yield* hostApi.filesystem; + const httpClient = yield* hostApi.httpClient; + + 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: unknown) => + 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 }; + }), + }, + { + 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/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/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..9c145604de9 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,22 @@ 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, + addPluginSource, + beginPluginInstall, + callPlugin, + checkPluginUpdates, + confirmPluginInstall, + getPluginCatalog, + listPlugins, + listPluginSources, + request, + runStream, + setPluginEnabled, + subscribe, + subscribePlugin, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -111,6 +128,175 @@ 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 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"); + 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..652fc09a563 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,4 +1,18 @@ -import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@t3tools/contracts"; +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"; import type * as Duration from "effect/Duration"; @@ -50,6 +64,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 +255,103 @@ 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 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, + 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/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/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/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/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..591b8da6283 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, @@ -330,14 +394,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/git.ts b/packages/contracts/src/git.ts index aa5cdf8432b..35840bd95e8 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -320,6 +320,11 @@ export const VcsPullResult = Schema.Struct({ export type VcsPullResult = typeof VcsPullResult.Type; // RPC / domain errors +const ERROR_STDERR_MAX_CHARS = 8_192; + +const capErrorStderr = (stderr: string): string => + stderr.length > ERROR_STDERR_MAX_CHARS ? stderr.slice(0, ERROR_STDERR_MAX_CHARS) : stderr; + export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { operation: Schema.String, command: Schema.String, @@ -328,6 +333,7 @@ export class GitCommandError extends Schema.TaggedErrorClass()( exitCode: Schema.optional(Schema.Number), stdoutLength: Schema.optional(Schema.Number), stderrLength: Schema.optional(Schema.Number), + stderr: Schema.optional(Schema.String), outputLength: Schema.optional(Schema.Number), detail: Schema.String, cause: Schema.optional(Schema.Defect()), @@ -335,6 +341,10 @@ export class GitCommandError extends Schema.TaggedErrorClass()( override get message(): string { return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}`; } + + static capStderr(stderr: string): string { + return capErrorStderr(stderr); + } } export class TextGenerationError extends Schema.TaggedErrorClass()( 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/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( diff --git a/packages/contracts/src/plugin.test.ts b/packages/contracts/src/plugin.test.ts new file mode 100644 index 00000000000..c82bdc382db --- /dev/null +++ b/packages/contracts/src/plugin.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +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", + 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", "filesystem", "httpClient"], + entries: { server: "dist/server.js", web: "dist/web.js" }, + }); + expect(decoded.name).toBe("Test Plugin"); + expect(decoded.capabilities).toEqual(["agents", "database", "filesystem", "httpClient"]); + }); + + 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); + }); +}); + +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", + 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 new file mode 100644 index 00000000000..76ccd33c66b --- /dev/null +++ b/packages/contracts/src/plugin.ts @@ -0,0 +1,416 @@ +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+$/; +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"; + +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", + "filesystem", + "httpClient", + "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 Sha256Hex = TrimmedNonEmptyString.check(Schema.isPattern(/^[a-f0-9]{64}$/i)); + +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), + // 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; readonly styles?: 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; + +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, + // 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; + +export const PluginListResult = Schema.Struct({ + plugins: Schema.Array(PluginInfo), +}); +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, + 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", + 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({ + 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/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..e4d81e93ae4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -143,6 +143,29 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; 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 = { // Project registry methods @@ -218,6 +241,23 @@ 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, + 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", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -681,6 +721,97 @@ 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 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, @@ -743,6 +874,21 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, + WsPluginsListRpc, + WsPluginsCallRpc, + WsPluginsSubscribeRpc, + WsPluginsSourcesListRpc, + WsPluginsSourcesAddRpc, + WsPluginsSourcesRemoveRpc, + WsPluginsCatalogRpc, + WsPluginsInstallBeginRpc, + WsPluginsInstallConfirmRpc, + WsPluginsInstallAbortRpc, + WsPluginsSetEnabledRpc, + WsPluginsUninstallRpc, + WsPluginsUpgradeBeginRpc, + WsPluginsUpgradeConfirmRpc, + WsPluginsCheckUpdatesRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, 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/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index c1090f4f39a..423b476c7de 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -86,6 +86,11 @@ export interface VcsProcessExitFailure { readonly stderrTruncated: boolean; } +const ERROR_STDERR_MAX_CHARS = 8_192; + +const capErrorStderr = (stderr: string): string => + stderr.length > ERROR_STDERR_MAX_CHARS ? stderr.slice(0, ERROR_STDERR_MAX_CHARS) : stderr; + export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( "VcsProcessSpawnError", { @@ -118,6 +123,7 @@ export class VcsProcessExitError extends Schema.TaggedErrorClass **Import `effect` from the barrel, not subpaths.** `import { Effect, Schema } from "effect"` ✅ — +> `import * as Effect from "effect/Effect"` ❌ does not resolve in a plugin web bundle. The host +> import map enumerates bare specifiers only. See [Importing `effect`](#importing-effect). + +Thin host-surface barrel for plugin web bundles. Plugin builds should treat these runtime modules +as externals and let the host import map resolve them at runtime: + +- `react` +- `react-dom` +- `react-dom/client` +- `react/jsx-runtime` +- `react/jsx-dev-runtime` +- `@effect/atom-react` +- `effect` +- `@t3tools/plugin-sdk-web` + +## Importing `effect` + +The host import map maps the **bare `effect` specifier only** — not its subpaths. Import effect +modules from the barrel: + +```ts +import { Effect, Stream, Option } from "effect"; // ✅ resolves via the host import map +``` + +Do **not** import effect subpaths in a plugin web bundle: + +```ts +import * as Effect from "effect/Effect"; // ❌ not in the import map — fails to resolve in the browser +``` + +(This differs from server plugins, where a Node resolve hook handles subpaths. Web plugins rely on +the native browser import map, which enumerates the bare specifier.) + +## Tailwind + +Tailwind v4 utilities are emitted by scanning the host build. A separately-built plugin cannot +assume arbitrary Tailwind utility classes will exist in the host CSS. Use host CSS variables such +as `--background`, `--color-*`, and `.dark`, use the exported host design-system components, or +ship compiled plugin CSS for plugin-local classes. diff --git a/packages/plugin-sdk-web/package.json b/packages/plugin-sdk-web/package.json new file mode 100644 index 00000000000..045e3d44555 --- /dev/null +++ b/packages/plugin-sdk-web/package.json @@ -0,0 +1,28 @@ +{ + "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": { + "@pierre/diffs": "catalog:", + "@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..5d9250fce93 --- /dev/null +++ b/packages/plugin-sdk-web/src/index.ts @@ -0,0 +1,230 @@ +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"; +// 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, + 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; + readonly registerProjectAction: (registration: PluginProjectActionRegistration) => 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; + // 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 { + 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 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; +} + +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 projectActions?: 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/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..7754cd6aa33 --- /dev/null +++ b/packages/plugin-sdk/src/index.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Effect from "effect/Effect"; + +import { definePlugin, HOST_API_VERSION, writeFileAtomic } 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"); + }); + + 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 new file mode 100644 index 00000000000..0a2546475f7 --- /dev/null +++ b/packages/plugin-sdk/src/index.ts @@ -0,0 +1,1325 @@ +import type { + ChangeRequestState, + ChatAttachment, + CheckpointRef, + CommandId, + EnvironmentId, + ExecutionEnvironmentDescriptor, + MessageId, + ModelSelection, + OrchestrationCheckpointFile, + OrchestrationCheckpointStatus, + OrchestrationMessage, + OrchestrationProject, + 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 * 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"; + +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 { + /** + * 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 { + /** + * 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; + + /** + * 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. + */ + 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; + + /** + * 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. + */ + 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 { + /** + * 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 { + /** + * 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. + * + * 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 { + /** + * 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>; + + /** + * 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. + */ + readonly listActivitiesByThreadId: (input: { + readonly threadId: ThreadId; + readonly limit?: number; + }) => Effect.Effect, Error>; +} + +export interface EnvironmentsReadCapability { + /** + * 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 { + /** + * 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 { + /** + * 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 FilesystemPathInput { + readonly root: string; + readonly relativePath: string; +} + +export interface FilesystemRenameInput { + readonly root: string; + readonly fromRelativePath: string; + readonly toRelativePath: string; +} + +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 and overwrites are rejected. + */ + 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. + */ + 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. + */ + 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; + + /** + * 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. + */ + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + 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. + */ + 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 { + /** + * 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; + + /** + * 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 { + 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 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 messageId?: MessageId | undefined; + readonly commandId?: CommandId | undefined; + 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 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; + 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 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 = + | { + readonly status: "created"; + readonly commitSha: string; + } + | { + readonly status: "skipped_no_changes"; + }; + +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 = + | { + 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 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; +} + +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; + 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 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; + 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 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; + 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 filesystem: Effect.Effect; + readonly httpClient: 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) => Stream.Stream; +} + +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"; + /** + * 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 { + 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 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, + }), + ), + Effect.catch((error) => + filesystem + .remove({ root: input.root, relativePath: tempRelativePath }) + .pipe(Effect.ignore, Effect.andThen(Effect.fail(error))), + ), + ); + }); +} + +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/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..0adbeb1b67a --- /dev/null +++ b/packages/shared/src/pluginHostWeb.test.ts @@ -0,0 +1,48 @@ +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", () => { + 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(""), + ); + // 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("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..32197ba4475 --- /dev/null +++ b/packages/shared/src/pluginHostWeb.ts @@ -0,0 +1,736 @@ +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_HOST_LAYER_MARKER = "data-t3-plugin-host-layer"; +export const PLUGIN_WEB_BUNDLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; +export const PLUGIN_WEB_SHIM_CACHE_CONTROL = "public, max-age=60"; +// In plugin dev (T3_PLUGIN_DEV=1) plugin bundles are rebuilt in place at the SAME +// version, so the immutable cache would keep serving stale assets. Serve them +// uncacheable in dev so every reload picks up the latest build. +export const PLUGIN_WEB_DEV_CACHE_CONTROL = "no-store"; + +/** + * 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/contracts": "/plugin-host/@t3tools/contracts.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", + // Host UI/util surface re-exported by the SDK for plugin web bundles (kept in + // sync with packages/plugin-sdk-web/src/index.ts; validated by hostSingletons.test). + "cn", + "randomUUID", + "useTheme", + "usePrimarySettings", + "formatDuration", + "primaryServerProvidersAtom", + "deriveProviderInstanceEntries", + "sortProviderInstanceEntries", + "getAppModelOptionsForInstance", + "DiffStatLabel", + "buildFileDiffRenderKey", + "getRenderablePatch", + "resolveDiffThemeName", + "resolveFileDiffPath", + "FileDiff", + "useEnvironmentProjectRefs", + "formatSchemaError", +] as const; + +// Auto-generated from `Object.keys(await import("@t3tools/contracts"))` — the full +// contracts surface, served as a shared host singleton so plugins do not bundle +// @t3tools/contracts (which pulls heavy effect subpaths incl. server HTTP code). +// Validated by hostSingletons.test.ts against the live module exports. +const contractsExports = [ + "AdvertisedEndpoint", "AdvertisedEndpointCompatibility", "AdvertisedEndpointHostedHttpsCompatibility", "AdvertisedEndpointProvider", "AdvertisedEndpointProviderKind", "AdvertisedEndpointReachability", + "AdvertisedEndpointSource", "AdvertisedEndpointStatus", "ApprovalRequestId", "AssetAccessError", "AssetAttachmentNotFoundError", "AssetCreateUrlInput", + "AssetCreateUrlResult", "AssetPreviewTypeValidationError", "AssetProjectFaviconInspectionError", "AssetProjectFaviconNotFoundError", "AssetProjectFaviconResolutionError", "AssetResource", + "AssetSigningKeyLoadError", "AssetWorkspaceAssetInspectionError", "AssetWorkspaceAssetNotFoundError", "AssetWorkspaceContextNotFoundError", "AssetWorkspaceContextResolutionError", "AssetWorkspacePathValidationError", + "AssetWorkspaceResolutionError", "AssetWorkspaceRootNormalizationError", "AssistantDeliveryMode", "AuthAccessReadScope", "AuthAccessSnapshot", "AuthAccessStreamClientRemovedEvent", + "AuthAccessStreamClientUpsertedEvent", "AuthAccessStreamError", "AuthAccessStreamEvent", "AuthAccessStreamPairingLinkRemovedEvent", "AuthAccessStreamPairingLinkUpsertedEvent", "AuthAccessStreamSnapshotEvent", + "AuthAccessTokenResult", "AuthAccessTokenType", "AuthAccessWriteScope", "AuthAdministrativeScopes", "AuthBrowserSessionRequest", "AuthBrowserSessionResult", + "AuthClientMetadata", "AuthClientMetadataDeviceType", "AuthClientPresentationMetadata", "AuthClientSession", "AuthClientSessionRevokeResult", "AuthCreatePairingCredentialInput", + "AuthEnvironmentBootstrapTokenType", "AuthEnvironmentScope", "AuthEnvironmentScopes", "AuthOrchestrationOperateScope", "AuthOrchestrationReadScope", "AuthOtherClientSessionsRevokeResult", + "AuthPairingCredentialResult", "AuthPairingLink", "AuthPairingLinkRevokeResult", "AuthPluginsManageScope", "AuthRelayReadScope", "AuthRelayWriteScope", + "AuthReviewWriteScope", "AuthRevokeClientSessionInput", "AuthRevokePairingLinkInput", "AuthScope", "AuthScopes", "AuthSessionId", + "AuthSessionState", "AuthStandardClientMarkerScopes", "AuthStandardClientScopes", "AuthTerminalOperateScope", "AuthTokenExchangeGrantType", "AuthTokenExchangeRequest", + "AuthWebSocketTicketResult", "BooleanProviderOptionDescriptor", "BrowserNavigationTarget", "CanonicalItemType", "CanonicalRequestType", "ChangeRequest", + "ChangeRequestState", "ChatAttachment", "ChatImageAttachment", "CheckpointRef", "ClaudeSettings", "ClientOrchestrationCommand", + "ClientSettingsPatch", "ClientSettingsSchema", "CodexSettings", "CommandId", "ContextMenuItemSchema", "CorrelationId", + "CursorSettings", "DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL", "DEFAULT_CLIENT_SETTINGS", "DEFAULT_GIT_TEXT_GENERATION_MODEL", "DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER", "DEFAULT_MODEL", + "DEFAULT_MODEL_BY_PROVIDER", "DEFAULT_PROVIDER_INTERACTION_MODE", "DEFAULT_RUNTIME_MODE", "DEFAULT_SERVER_SETTINGS", "DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE", "DEFAULT_SIDEBAR_PROJECT_SORT_ORDER", + "DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT", "DEFAULT_SIDEBAR_THREAD_SORT_ORDER", "DEFAULT_TERMINAL_ID", "DEFAULT_THREAD_OWNER", "DEFAULT_TIMESTAMP_FORMAT", "DEFAULT_UNIFIED_SETTINGS", + "DesktopAppBrandingSchema", "DesktopAppStageLabelSchema", "DesktopBackendBootstrap", "DesktopDiscoveredSshHostSchema", "DesktopEnvironmentBootstrapSchema", "DesktopPreviewAnnotationThemeInputSchema", + "DesktopPreviewAnnotationThemeSchema", "DesktopPreviewArtifactInputSchema", "DesktopPreviewAutomationClickInputSchema", "DesktopPreviewAutomationEvaluateInputSchema", "DesktopPreviewAutomationPressInputSchema", "DesktopPreviewAutomationScrollInputSchema", + "DesktopPreviewAutomationTypeInputSchema", "DesktopPreviewAutomationWaitForInputSchema", "DesktopPreviewConfigInputSchema", "DesktopPreviewNavStatusSchema", "DesktopPreviewNavigateInputSchema", "DesktopPreviewPointerEventSchema", + "DesktopPreviewRecordingArtifactSchema", "DesktopPreviewRecordingFrameSchema", "DesktopPreviewRecordingSaveInputSchema", "DesktopPreviewRegisterWebviewInputSchema", "DesktopPreviewScreenshotArtifactSchema", "DesktopPreviewTabIdSchema", + "DesktopPreviewTabInputSchema", "DesktopPreviewTabStateSchema", "DesktopPreviewWebviewConfigSchema", "DesktopRuntimeArchSchema", "DesktopRuntimeInfoSchema", "DesktopServerExposureModeSchema", + "DesktopServerExposureStateSchema", "DesktopSshBearerBootstrapInputSchema", "DesktopSshBearerRequestInputSchema", "DesktopSshEnvironmentBootstrapSchema", "DesktopSshEnvironmentEnsureInputSchema", "DesktopSshEnvironmentEnsureOptionsSchema", + "DesktopSshEnvironmentEnsureResultSchema", "DesktopSshEnvironmentTargetSchema", "DesktopSshHostSourceSchema", "DesktopSshHttpBaseUrlInputSchema", "DesktopSshPasswordPromptCancelledResultSchema", "DesktopSshPasswordPromptCancelledType", + "DesktopSshPasswordPromptRequestSchema", "DesktopSshPasswordPromptResolutionInputSchema", "DesktopThemeSchema", "DesktopUpdateActionResultSchema", "DesktopUpdateChannelSchema", "DesktopUpdateCheckResultSchema", + "DesktopUpdateStateSchema", "DesktopUpdateStatusSchema", "DesktopWslDistroSchema", "DesktopWslStateSchema", "DiscoveredLocalServer", "DiscoveredLocalServerList", + "DispatchResult", "EDITORS", "EMPTY_PLUGIN_LOCKFILE", "EditorId", "EditorLaunchStyle", "EnvironmentAuthHttpApi", + "EnvironmentAuthInvalidError", "EnvironmentAuthInvalidReason", "EnvironmentAuthenticatedAuth", "EnvironmentAuthenticatedPrincipal", "EnvironmentAuthorizationError", "EnvironmentCloudEndpointUnavailableError", + "EnvironmentCloudLinkStateResult", "EnvironmentCloudPreferencesRequest", "EnvironmentCloudRelayConfigResult", "EnvironmentConnectHttpApi", "EnvironmentConnectionState", "EnvironmentHttpApi", + "EnvironmentHttpBadRequestError", "EnvironmentHttpCommonError", "EnvironmentHttpConflictError", "EnvironmentHttpForbiddenError", "EnvironmentHttpInternalServerError", "EnvironmentHttpUnauthorizedError", + "EnvironmentId", "EnvironmentInternalError", "EnvironmentInternalErrorReason", "EnvironmentMetadataHttpApi", "EnvironmentOperationForbiddenError", "EnvironmentOperationForbiddenReason", + "EnvironmentOrchestrationHttpApi", "EnvironmentRequestInvalidError", "EnvironmentRequestInvalidReason", "EnvironmentScopeRequiredError", "EventId", "ExecutionEnvironmentCapabilities", + "ExecutionEnvironmentDescriptor", "ExecutionEnvironmentPlatform", "ExecutionEnvironmentPlatformArch", "ExecutionEnvironmentPlatformOs", "ExternalLauncherBrowserSpawnError", "ExternalLauncherCommandNotFoundError", + "ExternalLauncherEditorSpawnError", "ExternalLauncherError", "ExternalLauncherUnknownEditorError", "ExternalLauncherUnsupportedEditorError", "FILL_PREVIEW_VIEWPORT", "FilesystemBrowseEntry", + "FilesystemBrowseError", "FilesystemBrowseFailure", "FilesystemBrowseInput", "FilesystemBrowseResult", "GitActionProgressEvent", "GitActionProgressKind", + "GitActionProgressPhase", "GitActionProgressStream", "GitCommandError", "GitManagerError", "GitManagerServiceError", "GitPreparePullRequestThreadInput", + "GitPreparePullRequestThreadResult", "GitPullRequestMaterializationError", "GitPullRequestRefInput", "GitResolvePullRequestResult", "GitRunStackedActionInput", "GitRunStackedActionResult", + "GitRunStackedActionToastRunAction", "GitStackedAction", "GrokSettings", "HOST_API_VERSION", "IsoDateTime", "ItemLifecyclePayload", + "KeybindingCommand", "KeybindingRule", "KeybindingShortcut", "KeybindingValue", "KeybindingWhen", "KeybindingWhenNode", + "KeybindingsConfig", "KeybindingsConfigError", "LaunchEditorInput", "MAX_KEYBINDINGS_COUNT", "MAX_KEYBINDING_VALUE_LENGTH", "MAX_KEYBINDING_WHEN_LENGTH", + "MAX_SCRIPT_ID_LENGTH", "MAX_SIDEBAR_THREAD_PREVIEW_COUNT", "MAX_WHEN_EXPRESSION_DEPTH", "MIN_SIDEBAR_THREAD_PREVIEW_COUNT", "MODEL_PICKER_JUMP_KEYBINDING_COMMANDS", "MODEL_PICKER_KEYBINDING_COMMANDS", + "MODEL_SLUG_ALIASES_BY_PROVIDER", "MarketplaceEntry", "MarketplaceVersion", "MessageId", "ModelCapabilities", "ModelSelection", + "NonNegativeInt", "ORCHESTRATION_WS_METHODS", "ObservabilitySettings", "OpenCodeSettings", "OrchestrationActorKind", "OrchestrationAggregateKind", + "OrchestrationCheckpointFile", "OrchestrationCheckpointStatus", "OrchestrationCheckpointSummary", "OrchestrationCommand", "OrchestrationCommandReceiptStatus", "OrchestrationDispatchCommandError", + "OrchestrationEvent", "OrchestrationEventMetadata", "OrchestrationEventType", "OrchestrationGetFullThreadDiffError", "OrchestrationGetFullThreadDiffInput", "OrchestrationGetFullThreadDiffResult", + "OrchestrationGetSnapshotError", "OrchestrationGetTurnDiffError", "OrchestrationGetTurnDiffInput", "OrchestrationGetTurnDiffResult", "OrchestrationLatestTurn", "OrchestrationMessage", + "OrchestrationMessageRole", "OrchestrationProject", "OrchestrationProjectShell", "OrchestrationProposedPlan", "OrchestrationProposedPlanId", "OrchestrationReadModel", + "OrchestrationReplayEventsError", "OrchestrationReplayEventsInput", "OrchestrationRpcSchemas", "OrchestrationSession", "OrchestrationSessionStatus", "OrchestrationShellSnapshot", + "OrchestrationShellStreamEvent", "OrchestrationShellStreamItem", "OrchestrationSubscribeThreadInput", "OrchestrationThread", "OrchestrationThreadActivity", "OrchestrationThreadActivityTone", + "OrchestrationThreadDetailSnapshot", "OrchestrationThreadShell", "OrchestrationThreadStreamItem", "PLUGINS_WS_METHODS", "PLUGIN_ID_PATTERN_SOURCE", "PREVIEW_AUTOMATION_OPERATIONS", + "PREVIEW_AUTOMATION_V1_OPERATIONS", "PREVIEW_VIEWPORT_MAX_AREA", "PREVIEW_VIEWPORT_MAX_DIMENSION", "PREVIEW_VIEWPORT_MIN_DIMENSION", "PREVIEW_VIEWPORT_PRESET_IDS", "PRIMARY_LOCAL_ENVIRONMENT_ID", + "PROVIDER_DISPLAY_NAMES", "PROVIDER_SEND_TURN_MAX_ATTACHMENTS", "PROVIDER_SEND_TURN_MAX_IMAGE_BYTES", "PROVIDER_SEND_TURN_MAX_INPUT_CHARS", "PersistedSavedEnvironmentRecordSchema", "PickFolderOptionsSchema", + "PickedElementPayloadSchema", "PickedElementStackFrameSchema", "PluginCapability", "PluginCatalogInput", "PluginCatalogResult", "PluginCheckUpdatesResult", + "PluginId", "PluginInfo", "PluginInstallAbortInput", "PluginInstallBeginInput", "PluginInstallConfirmInput", "PluginInstallConfirmResult", + "PluginInstallStaged", "PluginListResult", "PluginLockfile", "PluginManagementError", "PluginManifest", "PluginMethodInput", + "PluginRpcError", "PluginScope", "PluginSetEnabledInput", "PluginSource", "PluginSourceError", "PluginSourcesAddInput", + "PluginSourcesAddResult", "PluginSourcesListResult", "PluginSourcesRemoveInput", "PluginState", "PluginUninstallInput", "PluginUpdateInfo", + "PluginUpgradeBeginInput", "PluginUpgradeConfirmInput", "PluginUpgradeConfirmResult", "PortSchema", "PositiveInt", "PreviewAnnotationElementTargetSchema", + "PreviewAnnotationPayloadSchema", "PreviewAnnotationPointSchema", "PreviewAnnotationRectSchema", "PreviewAnnotationRegionTargetSchema", "PreviewAnnotationScreenshotSchema", "PreviewAnnotationStrokeTargetSchema", + "PreviewAnnotationStyleChangeSchema", "PreviewAutomationActionEvent", "PreviewAutomationClickInput", "PreviewAutomationClientDisconnectedError", "PreviewAutomationClientId", "PreviewAutomationConnectionId", + "PreviewAutomationConsoleEntry", "PreviewAutomationControlInterruptedError", "PreviewAutomationElement", "PreviewAutomationError", "PreviewAutomationEvaluateInput", "PreviewAutomationExecutionError", + "PreviewAutomationHost", "PreviewAutomationHostFocus", "PreviewAutomationHostIdentity", "PreviewAutomationInvalidSelectorError", "PreviewAutomationMalformedResponseError", "PreviewAutomationNavigateInput", + "PreviewAutomationNetworkEntry", "PreviewAutomationNoAvailableHostError", "PreviewAutomationOpenInput", "PreviewAutomationOperation", "PreviewAutomationPressInput", "PreviewAutomationRecordingArtifact", + "PreviewAutomationRecordingStatus", "PreviewAutomationRemoteUnavailableError", "PreviewAutomationRequest", "PreviewAutomationRequestQueueClosedError", "PreviewAutomationResizeInput", "PreviewAutomationResizeResult", + "PreviewAutomationResponse", "PreviewAutomationResultTooLargeError", "PreviewAutomationScrollInput", "PreviewAutomationSnapshot", "PreviewAutomationStatus", "PreviewAutomationStreamEvent", + "PreviewAutomationTabNotFoundError", "PreviewAutomationTabTargetInput", "PreviewAutomationTargetNotEditableError", "PreviewAutomationTimeoutError", "PreviewAutomationTypeInput", "PreviewAutomationUnavailableError", + "PreviewAutomationUnsupportedClientError", "PreviewAutomationWaitForInput", "PreviewCloseInput", "PreviewError", "PreviewEvent", "PreviewInvalidUrlError", + "PreviewListInput", "PreviewListResult", "PreviewNavStatus", "PreviewNavigateInput", "PreviewOpenInput", "PreviewRefreshInput", + "PreviewRenderedViewportSize", "PreviewReportStatusInput", "PreviewResizeInput", "PreviewSessionLookupError", "PreviewSessionSnapshot", "PreviewTabId", + "PreviewUrlResolution", "PreviewViewportPresetId", "PreviewViewportSetting", "PreviewViewportSize", "ProjectCreateCommand", "ProjectCreatedPayload", + "ProjectDeletedPayload", "ProjectEntriesFailure", "ProjectEntry", "ProjectFileFailure", "ProjectFileOperation", "ProjectId", + "ProjectListEntriesError", "ProjectListEntriesInput", "ProjectListEntriesResult", "ProjectMetaUpdatedPayload", "ProjectReadFileError", "ProjectReadFileInput", + "ProjectReadFileResult", "ProjectScript", "ProjectScriptIcon", "ProjectSearchEntriesError", "ProjectSearchEntriesInput", "ProjectSearchEntriesResult", + "ProjectWriteFileError", "ProjectWriteFileInput", "ProjectWriteFileResult", "ProjectionPendingApprovalDecision", "ProjectionPendingApprovalStatus", "ProviderApprovalDecision", + "ProviderApprovalPolicy", "ProviderDriverKind", "ProviderEvent", "ProviderInstanceConfig", "ProviderInstanceConfigMap", "ProviderInstanceEnvironment", + "ProviderInstanceEnvironmentVariable", "ProviderInstanceEnvironmentVariableName", "ProviderInstanceId", "ProviderInstanceRef", "ProviderInteractionMode", "ProviderInterruptTurnInput", + "ProviderItemId", "ProviderOptionChoice", "ProviderOptionDescriptor", "ProviderOptionDescriptorType", "ProviderOptionSelection", "ProviderOptionSelectionValue", + "ProviderOptionSelections", "ProviderRequestKind", "ProviderRespondToRequestInput", "ProviderRespondToUserInputInput", "ProviderRuntimeEvent", "ProviderRuntimeEventV2", + "ProviderRuntimeTurnStatus", "ProviderSandboxMode", "ProviderSendTurnInput", "ProviderSession", "ProviderSessionRuntimeStatus", "ProviderSessionStartInput", + "ProviderStopSessionInput", "ProviderTurnStartResult", "ProviderUserInputAnswers", "RelayClientInstallFailedError", "RelayClientInstallFailureReasonSchema", "RelayClientInstallProgressEventSchema", + "RelayClientInstallProgressStageSchema", "RelayClientStatusSchema", "RepositoryIdentity", "RepositoryIdentityLocator", "ResolvedKeybindingRule", "ResolvedKeybindingsConfig", + "ReviewDiffPreviewError", "ReviewDiffPreviewInput", "ReviewDiffPreviewResult", "ReviewDiffPreviewSource", "ReviewDiffPreviewSourceKind", "RuntimeEventRaw", + "RuntimeItemId", "RuntimeMode", "RuntimeRequestId", "RuntimeSessionId", "RuntimeTaskId", "SCRIPT_RUN_COMMAND_PATTERN", + "ScopedProjectRef", "ScopedThreadRef", "ScopedThreadSessionRef", "SelectProviderOptionDescriptor", "ServerAuthBootstrapMethod", "ServerAuthDescriptor", + "ServerAuthPolicy", "ServerAuthSessionMethod", "ServerConfig", "ServerConfigIssue", "ServerConfigKeybindingsUpdatedPayload", "ServerConfigProviderStatusesPayload", + "ServerConfigSettingsUpdatedPayload", "ServerConfigStreamEvent", "ServerConfigStreamKeybindingsUpdatedEvent", "ServerConfigStreamProviderStatusesEvent", "ServerConfigStreamSettingsUpdatedEvent", "ServerConfigStreamSnapshotEvent", + "ServerConfigUpdatedPayload", "ServerLifecyclePluginStateChangedPayload", "ServerLifecycleReadyPayload", "ServerLifecycleStreamEvent", "ServerLifecycleStreamPluginsEvent", "ServerLifecycleStreamReadyEvent", + "ServerLifecycleStreamWelcomeEvent", "ServerLifecycleWelcomePayload", "ServerObservability", "ServerProcessDiagnosticsEntry", "ServerProcessDiagnosticsResult", "ServerProcessResourceHistoryBucket", + "ServerProcessResourceHistoryFailureTag", "ServerProcessResourceHistoryInput", "ServerProcessResourceHistoryResult", "ServerProcessResourceHistorySummary", "ServerProcessSignal", "ServerProvider", + "ServerProviderAuth", "ServerProviderAuthStatus", "ServerProviderAvailability", "ServerProviderContinuation", "ServerProviderModel", "ServerProviderSkill", + "ServerProviderSlashCommand", "ServerProviderSlashCommandInput", "ServerProviderState", "ServerProviderUpdateError", "ServerProviderUpdateInput", "ServerProviderUpdateState", + "ServerProviderUpdateStatus", "ServerProviderUpdatedPayload", "ServerProviderVersionAdvisory", "ServerProviderVersionAdvisoryStatus", "ServerProviders", "ServerRemoveKeybindingInput", + "ServerRemoveKeybindingResult", "ServerSettings", "ServerSettingsError", "ServerSettingsOperation", "ServerSettingsPatch", "ServerSignalProcessInput", + "ServerSignalProcessResult", "ServerTraceDiagnosticsErrorKind", "ServerTraceDiagnosticsFailureSummary", "ServerTraceDiagnosticsLogEvent", "ServerTraceDiagnosticsRecentFailure", "ServerTraceDiagnosticsResult", + "ServerTraceDiagnosticsSpanOccurrence", "ServerTraceDiagnosticsSpanSummary", "ServerUpsertKeybindingInput", "ServerUpsertKeybindingResult", "SidebarProjectGroupingMode", "SidebarProjectSortOrder", + "SidebarThreadPreviewCount", "SidebarThreadSortOrder", "SourceControlCloneProtocol", "SourceControlCloneRepositoryInput", "SourceControlCloneRepositoryResult", "SourceControlDiscoveryResult", + "SourceControlDiscoveryStatus", "SourceControlProviderAuth", "SourceControlProviderAuthStatus", "SourceControlProviderDiscoveryItem", "SourceControlProviderError", "SourceControlProviderInfo", + "SourceControlProviderKind", "SourceControlPublishRepositoryInput", "SourceControlPublishRepositoryResult", "SourceControlPublishStatus", "SourceControlRepositoryCloneUrls", "SourceControlRepositoryError", + "SourceControlRepositoryInfo", "SourceControlRepositoryLookupInput", "SourceControlRepositoryVisibility", "THREAD_JUMP_KEYBINDING_COMMANDS", "THREAD_KEYBINDING_COMMANDS", "TOOL_LIFECYCLE_ITEM_TYPES", + "TerminalAttachInput", "TerminalAttachStreamEvent", "TerminalClearInput", "TerminalCloseInput", "TerminalCwdError", "TerminalCwdNotDirectoryError", + "TerminalCwdNotFoundError", "TerminalCwdStatError", "TerminalError", "TerminalEvent", "TerminalHistoryError", "TerminalMetadataStreamEvent", + "TerminalNotRunningError", "TerminalOpenInput", "TerminalResizeError", "TerminalResizeInput", "TerminalRestartInput", "TerminalSessionLookupError", + "TerminalSessionSnapshot", "TerminalSessionStatus", "TerminalSummary", "TerminalThreadInput", "TerminalWriteError", "TerminalWriteInput", + "TextGenerationError", "ThreadActivityAppendedPayload", "ThreadApprovalResponseRequestedPayload", "ThreadArchivedPayload", "ThreadCheckpointRevertRequestedPayload", "ThreadCreatedPayload", + "ThreadDeletedPayload", "ThreadEnvMode", "ThreadId", "ThreadInteractionModeSetPayload", "ThreadMessageSentPayload", "ThreadMetaUpdatedPayload", + "ThreadOwner", "ThreadProposedPlanUpsertedPayload", "ThreadRevertedPayload", "ThreadRuntimeModeSetPayload", "ThreadSessionSetPayload", "ThreadSessionStopRequestedPayload", + "ThreadTokenUsageSnapshot", "ThreadTurnDiff", "ThreadTurnDiffCompletedPayload", "ThreadTurnInterruptRequestedPayload", "ThreadTurnStartCommand", "ThreadTurnStartRequestedPayload", + "ThreadUnarchivedPayload", "TimestampFormat", "ToolLifecycleItemType", "TrimmedNonEmptyString", "TrimmedString", "TurnCountRange", + "TurnId", "UserInputQuestion", "VcsCreateRefInput", "VcsCreateRefResult", "VcsCreateWorktreeInput", "VcsCreateWorktreeResult", + "VcsDiscoveryItem", "VcsDriverCapabilities", "VcsDriverKind", "VcsError", "VcsFreshness", "VcsFreshnessSource", + "VcsInitInput", "VcsListRefsInput", "VcsListRefsResult", "VcsListRemotesResult", "VcsListWorkspaceFilesResult", "VcsOutputDecodeError", + "VcsProcessExitError", "VcsProcessExitFailureKind", "VcsProcessMissingExitCodeError", "VcsProcessOutputLimitError", "VcsProcessOutputReadError", "VcsProcessSpawnError", + "VcsProcessStdinWriteError", "VcsProcessTimeoutError", "VcsPullInput", "VcsPullResult", "VcsRef", "VcsRemote", + "VcsRemoveWorktreeInput", "VcsRepositoryDetectionError", "VcsRepositoryIdentity", "VcsStatusInput", "VcsStatusLocalResult", "VcsStatusRemoteResult", + "VcsStatusResult", "VcsStatusStreamEvent", "VcsSwitchRefInput", "VcsSwitchRefResult", "VcsUnsupportedOperationError", "WS_METHODS", + "WsAssetsCreateUrlRpc", "WsCloudGetRelayClientStatusRpc", "WsCloudInstallRelayClientRpc", "WsFilesystemBrowseRpc", "WsGitPreparePullRequestThreadRpc", "WsGitResolvePullRequestRpc", + "WsGitRunStackedActionRpc", "WsOrchestrationDispatchCommandRpc", "WsOrchestrationGetArchivedShellSnapshotRpc", "WsOrchestrationGetFullThreadDiffRpc", "WsOrchestrationGetTurnDiffRpc", "WsOrchestrationReplayEventsRpc", + "WsOrchestrationSubscribeShellRpc", "WsOrchestrationSubscribeThreadRpc", "WsPluginsCallRpc", "WsPluginsCatalogRpc", "WsPluginsCheckUpdatesRpc", "WsPluginsInstallAbortRpc", + "WsPluginsInstallBeginRpc", "WsPluginsInstallConfirmRpc", "WsPluginsListRpc", "WsPluginsSetEnabledRpc", "WsPluginsSourcesAddRpc", "WsPluginsSourcesListRpc", + "WsPluginsSourcesRemoveRpc", "WsPluginsSubscribeRpc", "WsPluginsUninstallRpc", "WsPluginsUpgradeBeginRpc", "WsPluginsUpgradeConfirmRpc", "WsPreviewAutomationConnectRpc", + "WsPreviewAutomationFocusHostRpc", "WsPreviewAutomationRespondRpc", "WsPreviewCloseRpc", "WsPreviewListRpc", "WsPreviewNavigateRpc", "WsPreviewOpenRpc", + "WsPreviewRefreshRpc", "WsPreviewReportStatusRpc", "WsPreviewResizeRpc", "WsProjectsListEntriesRpc", "WsProjectsReadFileRpc", "WsProjectsSearchEntriesRpc", + "WsProjectsWriteFileRpc", "WsReviewGetDiffPreviewRpc", "WsRpcGroup", "WsServerDiscoverSourceControlRpc", "WsServerGetConfigRpc", "WsServerGetProcessDiagnosticsRpc", + "WsServerGetProcessResourceHistoryRpc", "WsServerGetSettingsRpc", "WsServerGetTraceDiagnosticsRpc", "WsServerRefreshProvidersRpc", "WsServerRemoveKeybindingRpc", "WsServerSignalProcessRpc", + "WsServerUpdateProviderRpc", "WsServerUpdateSettingsRpc", "WsServerUpsertKeybindingRpc", "WsShellOpenInEditorRpc", "WsSourceControlCloneRepositoryRpc", "WsSourceControlLookupRepositoryRpc", + "WsSourceControlPublishRepositoryRpc", "WsSubscribeAuthAccessRpc", "WsSubscribeDiscoveredLocalServersRpc", "WsSubscribePreviewEventsRpc", "WsSubscribeServerConfigRpc", "WsSubscribeServerLifecycleRpc", + "WsSubscribeTerminalEventsRpc", "WsSubscribeTerminalMetadataRpc", "WsSubscribeVcsStatusRpc", "WsTerminalAttachRpc", "WsTerminalClearRpc", "WsTerminalCloseRpc", + "WsTerminalOpenRpc", "WsTerminalResizeRpc", "WsTerminalRestartRpc", "WsTerminalWriteRpc", "WsVcsCreateRefRpc", "WsVcsCreateWorktreeRpc", + "WsVcsInitRpc", "WsVcsListRefsRpc", "WsVcsPullRpc", "WsVcsRefreshStatusRpc", "WsVcsRemoveWorktreeRpc", "WsVcsSwitchRefRpc", + "authEnvironmentScopes", "defaultInstanceIdForDriver", "hostApiSatisfies", "isAuthEnvironmentScope", "isExternalLauncherError", "isPluginScope", + "isProviderAvailable", "isProviderDriverKind", "isToolLifecycleItemType", "makeProviderSettingsSchema", "pluginOperateScope", "pluginReadScope", + "satisfiesScope", +] 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/contracts": contractsExports, + "@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 [ + "", + // Establish the lowest-priority `plugins` cascade layer in the HTML so + // it is registered FIRST, before any runtime-injected CSS. Plugin stylesheets + // are wrapped in `@layer plugins`, so host styles always win conflicts. This + // must be in the head (not only host source CSS) because in dev the host CSS is + // injected by JS at runtime — if the plugin's parsed first, `plugins` + // would otherwise be created as a HIGH-priority layer and clobber host layout. + ``, + ``, + ``, + "", + ].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 ef7146e194a..29e1778b2fe 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) @@ -553,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 @@ -657,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': @@ -801,6 +832,42 @@ 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/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': 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/*