diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40895f68c833..2f1eb3bf0be7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,41 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + hermes_plugin: + name: Hermes Companion Plugin + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + # actions/checkout's persist-credentials:false cleanup currently fails on + # the read-only vendored gitlinks that intentionally have no root + # .gitmodules entry. Fetch with a process-local auth header instead: the + # token is available only to this step and never lands in Git config. + - name: Checkout without persisted credentials + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init . + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + auth_header=$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n') + git -c "http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION: basic ${auth_header}" \ + fetch --no-tags --depth=1 origin "${GITHUB_SHA}" + git checkout --detach --force FETCH_HEAD + git remote remove origin + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Lint + run: pipx run ruff==0.16.3 check integrations/hermes-t3-gateway + + - name: Test + run: python -m unittest discover -s integrations/hermes-t3-gateway/tests -v + check: name: Check runs-on: ubuntu-24.04 diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f00736772766..90bae9cc761f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -193,6 +193,47 @@ function MessageAttachmentImage(props: { ); } +function MessageAttachmentFile(props: { + readonly environmentId: EnvironmentId; + readonly attachmentId: string; + readonly name: string; + readonly mimeType: string; +}) { + const uri = useAssetUrl(props.environmentId, { + _tag: "attachment", + attachmentId: props.attachmentId, + fileName: props.name, + mimeType: props.mimeType, + }); + const iconColor = useThemeColor("--color-icon-subtle"); + + return ( + { + if (uri !== null) void tryOpenExternalUrl(uri, "file-preview"); + }} + className="mt-1.5 min-h-12 flex-row items-center gap-2 rounded-[14px] border border-neutral-200 bg-neutral-100 px-3 py-2 dark:border-white/[0.08] dark:bg-neutral-900" + > + {uri === null ? ( + + ) : ( + + )} + + + {props.name} + + + {props.mimeType} + + + + ); +} + const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", @@ -906,7 +947,7 @@ function renderFeedEntry( /> ) : null} {attachments.map((attachment) => { - return ( + return attachment.type === "image" ? ( + ) : ( + ); })} @@ -967,7 +1016,7 @@ function renderFeedEntry( ) ) : null} {attachments.map((attachment) => { - return ( + return attachment.type === "image" ? ( + ) : ( + ); })} {showAssistantMeta ? ( diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0a1972c2827c..7c58a3557ce1 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -208,6 +208,37 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("signs MIME response metadata for opaque file attachments", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.bin`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([37, 80, 68, 70])); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + fileName: "release notes.pdf", + mimeType: "application/pdf", + }, + }); + expect(result.relativeUrl.endsWith("/release%20notes.pdf")).toBe(true); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect(yield* resolveAsset(suffix.slice(0, separatorIndex), "release notes.pdf")).toEqual({ + kind: "file", + path: attachmentPath, + contentType: "application/pdf", + downloadName: "release notes.pdf", + }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues project favicon capabilities with a signed fallback", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 7157513b14d3..449e1012f237 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -79,6 +79,8 @@ const AssetClaimsSchema = Schema.Union([ version: Schema.Literal(1), kind: Schema.Literal("attachment"), attachmentId: Schema.String, + contentType: Schema.optional(Schema.String), + downloadName: Schema.optional(Schema.String), expiresAt: Schema.Number, }), Schema.Struct({ @@ -95,7 +97,22 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +export type ResolvedAsset = { + readonly kind: "file"; + readonly path: string; + readonly contentType?: string; + readonly downloadName?: string; +}; + +const SAFE_MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i; + +/** Normalize signed response metadata before it can reach an HTTP header. */ +function normalizeAttachmentContentType(value: string | undefined): string { + const normalized = value?.trim().toLowerCase() ?? ""; + return normalized.length <= 100 && SAFE_MEDIA_TYPE.test(normalized) + ? normalized + : "application/octet-stream"; +} function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -273,9 +290,23 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i version: 1, kind: "attachment", attachmentId: input.resource.attachmentId, + // Generic files deliberately use an opaque `.bin` physical path so + // user-controlled names and extensions never influence storage. Carry + // their MIME/name as signed response metadata instead. The HTTP route + // adds Content-Disposition: attachment, preventing an HTML-like MIME + // from executing in T3's origin. + ...(attachmentPath.endsWith(".bin") + ? { + contentType: normalizeAttachmentContentType(input.resource.mimeType), + downloadName: input.resource.fileName?.trim() || "attachment", + } + : {}), expiresAt, }; - fileName = path.basename(attachmentPath); + fileName = + attachmentPath.endsWith(".bin") && input.resource.fileName + ? input.resource.fileName + : path.basename(attachmentPath); break; } case "project-favicon": { @@ -428,7 +459,12 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => Option.none()), ); return Option.isSome(info) && info.value.type === "File" - ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) + ? ({ + kind: "file", + path: attachmentPath, + ...(claims.contentType !== undefined ? { contentType: claims.contentType } : {}), + ...(claims.downloadName !== undefined ? { downloadName: claims.downloadName } : {}), + } satisfies ResolvedAsset) : null; } diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db217..f5545fd13cce 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -34,12 +34,15 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { return segment; } -export function createAttachmentId(threadId: string): string | null { +export function createAttachmentId( + threadId: string, + uniqueId: string = NodeCrypto.randomUUID(), +): string | null { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { return null; } - return `${threadSegment}-${NodeCrypto.randomUUID()}`; + return `${threadSegment}-${uniqueId}`; } export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { @@ -63,6 +66,10 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "file": + // Keep user-controlled names and MIME-derived extensions out of paths. + // The asset service supplies safe download semantics for this opaque file. + return `${attachment.id}.bin`; } } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370a..ffdbcedff3cb 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -39,6 +39,12 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [WS_METHODS.hermesGatewayCreateEnrollment]: AuthOrchestrationOperateScope, + [WS_METHODS.hermesGatewayGetInstanceStatus]: AuthOrchestrationReadScope, + [WS_METHODS.hermesGatewayListInstances]: AuthOrchestrationReadScope, + [WS_METHODS.hermesGatewayRenameInstance]: AuthOrchestrationOperateScope, + [WS_METHODS.hermesGatewayRevokeInstance]: AuthOrchestrationOperateScope, + [WS_METHODS.hermesGatewayRemoveInstance]: AuthOrchestrationOperateScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index ec4d2aae16e6..5515e2360775 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,12 @@ import { expect, it } from "@effect/vitest"; import { describe } from "vite-plus/test"; -import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { + assetResponseHeaders, + assetResponseOptions, + isLoopbackHostname, + resolveDevRedirectUrl, +} from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -44,4 +49,24 @@ describe("assetResponseHeaders", () => { "X-Content-Type-Options": "nosniff", }); }); + + it("serves opaque attachments with their signed MIME and safe download disposition", () => { + expect( + assetResponseOptions({ + kind: "file", + path: "/attachments/opaque.bin", + contentType: "application/pdf", + downloadName: 'release "notes".pdf', + }), + ).toEqual({ + status: 200, + contentType: "application/pdf", + headers: { + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + "Content-Disposition": + "attachment; filename=\"attachment\"; filename*=UTF-8''release%20%22notes%22.pdf", + }, + }); + }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0da55686b92f..ca85b752a471 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -27,7 +27,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; -import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { ASSET_ROUTE_PREFIX, resolveAsset, type ResolvedAsset } from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -45,16 +45,39 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -export function assetResponseHeaders(filePath: string): Record { +function encodeContentDispositionFileName(fileName: string): string { + return encodeURIComponent(fileName).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +export function assetResponseHeaders( + filePath: string, + downloadName?: string, +): Record { return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", + ...(downloadName !== undefined + ? { + "Content-Disposition": `attachment; filename="attachment"; filename*=UTF-8''${encodeContentDispositionFileName(downloadName)}`, + } + : {}), ...(filePath.toLowerCase().endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), }; } +export function assetResponseOptions(asset: ResolvedAsset) { + return { + status: 200 as const, + ...(asset.contentType !== undefined ? { contentType: asset.contentType } : {}), + headers: assetResponseHeaders(asset.path, asset.downloadName), + }; +} + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -217,10 +240,7 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(asset.path, { - status: 200, - headers: assetResponseHeaders(asset.path), - }).pipe( + return yield* HttpServerResponse.file(asset.path, assetResponseOptions(asset)).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); }), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index ea62f0d9ef5c..4c9985157063 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off import { CheckpointRef, CommandId, @@ -10,6 +11,9 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -22,7 +26,10 @@ import { describe, expect, it } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; -import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore, type OrchestrationEventStoreShape, @@ -46,10 +53,17 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -async function createOrchestrationSystem() { - const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-orchestration-engine-test-", - }); +async function createOrchestrationSystem(options?: { + readonly baseDir?: string; + readonly dbPath?: string; +}) { + const ServerConfigLayer = ServerConfig.layerTest( + process.cwd(), + options?.baseDir ?? { prefix: "t3-orchestration-engine-test-" }, + ); + const persistenceLayer = options?.dbPath + ? makeSqlitePersistenceLive(options.dbPath) + : SqlitePersistenceMemory; const orchestrationLayer = Layer.mergeAll( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), @@ -62,7 +76,7 @@ async function createOrchestrationSystem() { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), - Layer.provide(SqlitePersistenceMemory), + Layer.provide(persistenceLayer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); @@ -1294,6 +1308,121 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("deduplicates a durable notification across restart and destination re-designation", async () => { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notification-restart-")); + const dbPath = NodePath.join(baseDir, "state.sqlite"); + const createdAt = now(); + const projectId = asProjectId("project-notification-restart"); + const threadId = ThreadId.make("thread-notification-restart"); + const notification = { + type: "thread.notification.deliver", + commandId: CommandId.make("hermes-delivery-restart-proof"), + threadId, + expectedProviderInstanceId: ProviderInstanceId.make("hermes"), + messageId: asMessageId("hermes-delivery-restart-proof"), + deliveryId: "delivery-restart-proof", + kind: "cron", + label: "Cron: restart proof", + text: "Persist exactly once.", + createdAt, + } as const; + + try { + let firstSequence = 0; + const first = await createOrchestrationSystem({ baseDir, dbPath }); + try { + await first.run( + first.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-notification-restart-project"), + projectId, + title: "Notification Restart", + workspaceRoot: NodePath.join(baseDir, "workspace"), + defaultModelSelection: { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes", + }, + createdAt, + }), + ); + await first.run( + first.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-notification-restart-thread"), + threadId, + projectId, + title: "Home", + modelSelection: { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await expect( + first.run( + first.engine.dispatch({ + ...notification, + expectedProviderInstanceId: ProviderInstanceId.make("another-hermes-instance"), + }), + ), + ).rejects.toThrow("is no longer owned by provider instance"); + firstSequence = (await first.run(first.engine.dispatch(notification))).sequence; + } finally { + await first.dispose(); + } + + const second = await createOrchestrationSystem({ baseDir, dbPath }); + try { + const replacementThreadId = ThreadId.make("thread-notification-replacement-home"); + await second.run( + second.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-notification-replacement-home"), + threadId: replacementThreadId, + projectId, + title: "Replacement Home", + modelSelection: { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + const retried = await second.run( + second.engine.dispatch({ ...notification, threadId: replacementThreadId }), + ); + expect(retried.sequence).toBe(firstSequence); + + const readModel = await second.readModel(); + const thread = readModel.threads.find((candidate) => candidate.id === threadId); + const replacement = readModel.threads.find( + (candidate) => candidate.id === replacementThreadId, + ); + const delivered = thread?.messages.filter( + (message) => message.id === notification.messageId, + ); + expect(delivered).toHaveLength(1); + expect( + replacement?.messages.filter((message) => message.id === notification.messageId), + ).toHaveLength(0); + expect(delivered?.[0]?.text).toBe("> Cron: restart proof\n\nPersist exactly once."); + } finally { + await second.dispose(); + } + } finally { + NodeFS.rmSync(baseDir, { recursive: true, force: true }); + } + }); + it("rejects reusing an accepted command id for a different aggregate", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 6b600f6501e6..d31d76c8b46d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -141,30 +141,39 @@ const makeOrchestrationEngine = Effect.gen(function* () { commandId: envelope.command.commandId, }); if (Option.isSome(existingReceipt)) { - // A receipt only proves this exact command was handled. Replaying it - // for a command aimed at another aggregate would report success for - // work that never happened. - if ( - existingReceipt.value.aggregateKind !== aggregateRef.aggregateKind || - existingReceipt.value.aggregateId !== aggregateRef.aggregateId - ) { - return yield* new OrchestrationCommandIdConflictError({ + // Notification ownership failures are transient: Home may be + // re-designated before the plugin retries. Only an accepted receipt + // is terminal for that command type; a later successful attempt + // replaces its rejected receipt. + const retryRejectedNotification = + envelope.command.type === "thread.notification.deliver" && + existingReceipt.value.status === "rejected"; + if (!retryRejectedNotification) { + // A receipt only proves this exact command was handled. Durable + // notifications are the exception: their identity belongs to the + // source delivery and intentionally survives destination changes. + const aggregateMismatch = + existingReceipt.value.aggregateKind !== aggregateRef.aggregateKind || + existingReceipt.value.aggregateId !== aggregateRef.aggregateId; + if (envelope.command.type !== "thread.notification.deliver" && aggregateMismatch) { + return yield* new OrchestrationCommandIdConflictError({ + commandId: envelope.command.commandId, + receiptAggregateKind: existingReceipt.value.aggregateKind, + receiptAggregateId: existingReceipt.value.aggregateId, + commandAggregateKind: aggregateRef.aggregateKind, + commandAggregateId: aggregateRef.aggregateId, + }); + } + if (existingReceipt.value.status === "accepted") { + return { + sequence: existingReceipt.value.resultSequence, + }; + } + return yield* new OrchestrationCommandPreviouslyRejectedError({ commandId: envelope.command.commandId, - receiptAggregateKind: existingReceipt.value.aggregateKind, - receiptAggregateId: existingReceipt.value.aggregateId, - commandAggregateKind: aggregateRef.aggregateKind, - commandAggregateId: aggregateRef.aggregateId, + detail: existingReceipt.value.error ?? "Previously rejected.", }); } - if (existingReceipt.value.status === "accepted") { - return { - sequence: existingReceipt.value.resultSequence, - }; - } - return yield* new OrchestrationCommandPreviouslyRejectedError({ - commandId: envelope.command.commandId, - detail: existingReceipt.value.error ?? "Previously rejected.", - }); } const eventBase = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 6970cd20dc1f..76aa20d19b5a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -338,9 +338,6 @@ function collectThreadAttachmentRelativePaths( const relativePaths = new Set(); for (const message of messages) { for (const attachment of message.attachments ?? []) { - if (attachment.type !== "image") { - continue; - } const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { continue; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 381181477b68..d020d36cdee1 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -945,6 +945,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getThreadArchiveStateRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: Schema.Struct({ + projectId: ProjectId, + archivedAt: Schema.NullOr(Schema.String), + deletedAt: Schema.NullOr(Schema.String), + }), + execute: ({ threadId }) => + sql` + SELECT + project_id AS "projectId", + archived_at AS "archivedAt", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + const getActiveThreadRowById = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadDbRowSchema, @@ -2447,6 +2466,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); }); + const getThreadArchiveStateById: NonNullable< + ProjectionSnapshotQueryShape["getThreadArchiveStateById"] + > = (threadId) => + getThreadArchiveStateRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadArchiveStateById:query", + "ProjectionSnapshotQuery.getThreadArchiveStateById:decodeRow", + ), + ), + Effect.map( + Option.flatMap((row) => + row.deletedAt === null + ? Option.some({ projectId: row.projectId, archivedAt: row.archivedAt }) + : Option.none<{ readonly projectId: ProjectId; readonly archivedAt: string | null }>(), + ), + ), + ); + const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ @@ -2855,6 +2893,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, + getThreadArchiveStateById, getThreadDetailById, getThreadDetailSnapshot, } satisfies ProjectionSnapshotQueryShape; diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 24c65900b296..b43566f08703 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -7,6 +7,7 @@ import { type IsoDateTime, type OrchestrationCommand, OrchestrationDispatchCommandError, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; @@ -109,16 +110,24 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { + const isImage = attachment.type === "image"; + if ( + !parsed || + (isImage && !parsed.mimeType.startsWith("image/")) || + (!isImage && parsed.mimeType.startsWith("image/")) + ) { return yield* new OrchestrationDispatchCommandError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, + message: `Invalid ${attachment.type} attachment payload for '${attachment.name}'.`, }); } const bytes = Buffer.from(parsed.base64, "base64"); - if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + const maxBytes = isImage + ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + : PROVIDER_SEND_TURN_MAX_FILE_BYTES; + if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) { return yield* new OrchestrationDispatchCommandError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, + message: `${isImage ? "Image" : "File"} attachment '${attachment.name}' is empty or too large.`, }); } @@ -130,7 +139,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => } const persistedAttachment = { - type: "image" as const, + type: attachment.type, id: attachmentId, name: attachment.name, mimeType: parsed.mimeType.toLowerCase(), diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 0a00253a2285..d99c665ed39b 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -163,6 +163,14 @@ export interface ProjectionSnapshotQueryShape { threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read ownership and archive state without treating an archived row as deleted. */ + readonly getThreadArchiveStateById?: ( + threadId: ThreadId, + ) => Effect.Effect< + Option.Option<{ readonly projectId: ProjectId; readonly archivedAt: string | null }>, + ProjectionRepositoryError + >; + /** * Read a single active thread detail snapshot by id. */ diff --git a/apps/server/src/orchestration/agentProjects.ts b/apps/server/src/orchestration/agentProjects.ts new file mode 100644 index 000000000000..68f10539594a --- /dev/null +++ b/apps/server/src/orchestration/agentProjects.ts @@ -0,0 +1,145 @@ +/** + * Agent projects — the synthetic project rows that back directoryless threads. + * + * `projection_threads.project_id` is `NOT NULL`, so a thread with a provider + * that owns its own machine (Hermes) still needs a project row to point at. + * That row is *synthetic*: its deterministic workspace root is a real but + * empty directory under `/agents//`. The path is also the + * stable lookup key, avoiding a Hermes-specific field in core project state. + * + * ## Why converge-on-read instead of create-on-enrollment + * + * The obvious design is to create the project when an instance enrolls and + * delete it when the instance is removed. That leaves the system able to reach + * states it cannot recover from on its own: + * + * - enrollment succeeds but the project dispatch fails → the instance has no + * project, forever, and every thread start fails + * - instances enrolled before this code shipped have no project at all + * - a project soft-deleted by hand (or by a removal that later gets undone) + * leaves live threads unopenable + * + * `getOrCreateAgentProject` is instead a **precondition every caller runs**, + * not a lifecycle event one caller owns. It reads, and creates only what is + * missing. Any of the states above self-heals on the next call, and callers + * never have to reason about ordering. Enrollment still calls it, but only to + * warm the row — nothing depends on that call having happened. + * + * @module orchestration/agentProjects + */ +import { + CommandId, + ProjectId, + type OrchestrationProject, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { ServerConfig } from "../config.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; + +/** Directory name under `` holding one directory per agent instance. */ +export const AGENT_WORKSPACE_DIR = "agents"; + +/** + * Workspace root for one agent instance. + * + * A real directory, not a marker string: `OrchestrationProject.workspaceRoot` + * is a non-empty string that plenty of code will happily `stat`, and handing + * it a path that cannot exist would turn a cosmetic problem into a crash. + */ +export function agentWorkspaceRoot(input: { + readonly baseDir: string; + readonly instanceId: ProviderInstanceId; + readonly join: (...parts: ReadonlyArray) => string; +}): string { + return input.join(input.baseDir, AGENT_WORKSPACE_DIR, input.instanceId); +} + +/** + * Resolve the agent project for one provider instance, creating it if absent. + * + * Safe to call on every request: the common path is a single indexed read. + * Callers should treat this as a precondition rather than a mutation — it is + * idempotent, and concurrent callers converge on one row (see the recovery + * read below). + */ +export const getOrCreateAgentProject = Effect.fn("getOrCreateAgentProject")(function* (input: { + readonly instanceId: ProviderInstanceId; + /** Instance nickname, used as the project title on first creation. */ + readonly title: string; +}) { + const query = yield* ProjectionSnapshotQuery; + const serverConfig = yield* ServerConfig; + const path = yield* Path.Path; + const workspaceRoot = agentWorkspaceRoot({ + baseDir: serverConfig.baseDir, + instanceId: input.instanceId, + join: path.join, + }); + + const existing = yield* query.getActiveProjectByWorkspaceRoot(workspaceRoot); + if (Option.isSome(existing)) { + return existing.value; + } + + const engine = yield* OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(workspaceRoot, { recursive: true }); + + const projectId = ProjectId.make(yield* crypto.randomUUIDv4); + const createdAt = DateTime.formatIso(yield* DateTime.now); + + const dispatched = yield* Effect.result( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + projectId, + title: input.title.trim() || input.instanceId, + workspaceRoot, + createWorkspaceRootIfMissing: true, + createdAt, + }), + ); + + if (dispatched._tag === "Failure") { + // The decider rejects a second project on the same workspace root, so a + // racing caller that won shows up here as a dispatch failure rather than a + // duplicate row. Re-read before surfacing the error: if the row now + // exists, the race resolved correctly and this caller can use it. + const raced = yield* query.getActiveProjectByWorkspaceRoot(workspaceRoot); + if (Option.isSome(raced)) { + return raced.value; + } + return yield* Effect.fail(dispatched.failure); + } + + // Read back rather than synthesizing the row locally, so the caller always + // sees exactly what the projection committed. + const created = yield* query.getActiveProjectByWorkspaceRoot(workspaceRoot); + if (Option.isSome(created)) { + return created.value; + } + + // The dispatch succeeded but the projection has not caught up. Returning a + // locally-built row keeps the caller moving; the next call reads the real + // one. Every field matches what the decider emitted. + return { + id: projectId, + title: input.title.trim() || input.instanceId, + workspaceRoot, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + deletedAt: null, + } satisfies OrchestrationProject; +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5b5267c233f9..565bd978d18a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1417,6 +1417,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" return [unsettledEvent, activityAppendedEvent]; } + case "thread.notification.deliver": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.modelSelection.instanceId !== command.expectedProviderInstanceId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' is no longer owned by provider instance '${command.expectedProviderInstanceId}'.`, + }); + } + const messageEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.messageId, + role: "assistant", + text: `> ${command.label}\n\n${command.text}`, + ...(command.attachments !== undefined ? { attachments: command.attachments } : {}), + turnId: command.turnId ?? null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + + if (command.kind === "lifecycle") { + return messageEvent; + } + + const wakeEvents: Array> = []; + if (thread.settledOverride === "settled") { + wakeEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + if (thread.snoozedUntil != null) { + wakeEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + return wakeEvents.length === 0 ? messageEvent : [...wakeEvents, messageEvent]; + } + default: { command satisfies never; const fallback = command as never as { type: string }; diff --git a/apps/server/src/orchestration/homeThreads.test.ts b/apps/server/src/orchestration/homeThreads.test.ts new file mode 100644 index 000000000000..e3fcdbeeea6d --- /dev/null +++ b/apps/server/src/orchestration/homeThreads.test.ts @@ -0,0 +1,468 @@ +import { assert, it } from "@effect/vitest"; +import { + DEFAULT_HERMES_MODEL, + HERMES_DRIVER_KIND, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationProject, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + HOME_THREAD_TITLE, + getDesignatedHomeThreadId, + getOrCreateHomeThread, + readHomeThreadId, +} from "./homeThreads.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; + +const INSTANCE_ID = ProviderInstanceId.make("hermes-workstation-abc123"); +const HOME_THREAD_ID = ThreadId.make("thread-home-1"); +const BASE_DIR = "/tmp/t3-home-threads-test"; + +const agentProject: OrchestrationProject = { + id: ProjectId.make("agent-project-1"), + title: "Hermes Workstation", + workspaceRoot: `${BASE_DIR}/agents/${INSTANCE_ID}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}; + +const threadShell = (id: ThreadId): OrchestrationThreadShell => + ({ + id, + projectId: agentProject.id, + title: HOME_THREAD_TITLE, + modelSelection: { instanceId: INSTANCE_ID, model: DEFAULT_HERMES_MODEL }, + 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, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }) as OrchestrationThreadShell; + +const hermesInstance = (config: Record) => ({ + driver: HERMES_DRIVER_KIND, + displayName: "Hermes Workstation", + enabled: true, + config, +}); + +/** + * Query stub answering thread reads from a mutable queue, so a test can model + * "designated but deleted" — the self-healing path this module exists for. + */ +const makeQueryLayer = ( + threads: Ref.Ref>>, + archiveStates?: ReadonlyArray< + Option.Option<{ readonly projectId: ProjectId; readonly archivedAt: string | null }> + >, +) => + Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some(agentProject)), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadArchiveStateById: () => + Effect.gen(function* () { + if (archiveStates?.[0] !== undefined) return archiveStates[0]; + // Default: mirror the shell queue, since a thread visible to the shell + // query is also present. Tests about archiving pass this explicitly. + const queue = yield* Ref.get(threads); + return Option.map(queue[0] ?? Option.none(), (thread) => ({ + projectId: thread.projectId, + archivedAt: null, + })); + }), + getThreadShellById: () => + Effect.gen(function* () { + const queue = yield* Ref.get(threads); + const [head, ...rest] = queue; + if (head === undefined) return Option.none(); + yield* Ref.set(threads, rest.length > 0 ? rest : [head]); + return head; + }), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + } as unknown as ProjectionSnapshotQuery["Service"]); + +const makeEngineLayer = ( + dispatched: Ref.Ref>, + options: { + readonly fail?: boolean; + /** + * Runs after each command is recorded. The only hook that fires *inside* + * `getOrCreateHomeThread`, which is what lets a test land a competing + * settings write between this caller's read and its persist. + */ + readonly onDispatch?: (command: OrchestrationCommand) => Effect.Effect; + } = {}, +) => + Layer.succeed(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: (command: OrchestrationCommand) => + Ref.update(dispatched, (calls) => [...calls, command]).pipe( + Effect.andThen(options.onDispatch ? options.onDispatch(command) : Effect.void), + Effect.andThen( + options.fail + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: "thread.create", + detail: "Simulated dispatch failure.", + }), + ) + : Effect.succeed({ sequence: 1 }), + ), + ), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: PubSub.unbounded().pipe( + Effect.flatMap(PubSub.subscribe), + ), + latestSequence: Effect.succeed(0), + } as OrchestrationEngine.OrchestrationEngineService["Service"]); + +const configLayer = Layer.succeed(ServerConfig, { + baseDir: BASE_DIR, +} as unknown as ServerConfig["Service"]); + +const testLayer = (input: { + readonly threads: Ref.Ref>>; + readonly dispatched: Ref.Ref>; + readonly providerInstances: Record; + readonly failDispatch?: boolean; + /** Fires inside `getOrCreateHomeThread`, right after `thread.create`. */ + readonly onDispatch?: (command: OrchestrationCommand) => Effect.Effect; + /** + * Settings layer to use instead of a fresh one. A test that needs to write + * settings from `onDispatch` builds the layer itself so both sides share one + * store. + */ + readonly settingsLayer?: ReturnType; + /** + * Archive-state rows. Unlike the shell queue these ignore archive state, so + * a test can model "archived, therefore invisible to the shell query but + * still very much present" — the case that used to mint a second Home. + */ + readonly archiveStates?: ReadonlyArray< + Option.Option<{ readonly projectId: ProjectId; readonly archivedAt: string | null }> + >; +}) => + Layer.mergeAll( + makeQueryLayer(input.threads, input.archiveStates), + makeEngineLayer(input.dispatched, { + ...(input.failDispatch ? { fail: true } : {}), + ...(input.onDispatch ? { onDispatch: input.onDispatch } : {}), + }), + configLayer, + input.settingsLayer ?? + ServerSettings.layerTest({ + providerInstances: input.providerInstances, + } as never), + NodeServices.layer, + ); + +it("reads a designation only from a Hermes envelope", () => { + assert.equal( + readHomeThreadId(hermesInstance({ homeThreadId: HOME_THREAD_ID }) as never), + HOME_THREAD_ID, + ); + assert.equal(readHomeThreadId(hermesInstance({}) as never), undefined); + // An empty string is "not designated", not a thread whose id is "". + assert.equal(readHomeThreadId(hermesInstance({ homeThreadId: "" }) as never), undefined); + // A designation on a non-Hermes envelope is not ours to honour. + assert.equal( + readHomeThreadId({ driver: "codex", homeThreadId: HOME_THREAD_ID } as never), + undefined, + ); +}); + +it.effect("returns the designated thread without dispatching anything", () => + Effect.gen(function* () { + const threads = yield* Ref.make>>([ + Option.some(threadShell(HOME_THREAD_ID)), + ]); + const dispatched = yield* Ref.make>([]); + + const resolved = yield* getOrCreateHomeThread({ + instanceId: INSTANCE_ID, + title: "Hermes Workstation", + }).pipe( + Effect.provide( + testLayer({ + threads, + dispatched, + providerInstances: { + [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }), + }, + }), + ), + ); + + assert.equal(resolved, HOME_THREAD_ID); + // This runs on every handshake; it must not create anything on the happy path. + assert.deepEqual(yield* Ref.get(dispatched), []); + }), +); + +it.effect("creates and persists a Home thread when none is designated", () => + Effect.gen(function* () { + const threads = yield* Ref.make>>([ + Option.none(), + ]); + const dispatched = yield* Ref.make>([]); + + yield* Effect.gen(function* () { + const resolved = yield* getOrCreateHomeThread({ + instanceId: INSTANCE_ID, + title: "Hermes Workstation", + }); + + const commands = yield* Ref.get(dispatched); + const created = commands.find((command) => command.type === "thread.create"); + assert.isDefined(created); + if (created?.type === "thread.create") { + assert.equal(created.threadId, resolved); + assert.equal(created.title, HOME_THREAD_TITLE); + assert.equal(created.projectId, agentProject.id); + // Binds to the fixed Hermes slug, not to whatever model the plugin + // currently reports — otherwise the thread orphans on a model change. + assert.equal(created.modelSelection.model, DEFAULT_HERMES_MODEL); + } + + // The designation must be durable, or the next handshake mints a second + // Home and the first one's history is stranded. + assert.equal(yield* getDesignatedHomeThreadId(INSTANCE_ID), resolved); + }).pipe( + Effect.provide( + testLayer({ + threads, + dispatched, + providerInstances: { [INSTANCE_ID]: hermesInstance({}) }, + }), + ), + ); + }), +); + +it.effect("self-heals a designation whose thread no longer exists", () => + Effect.gen(function* () { + // A deleted home thread must not strand the instance: it re-designates + // rather than failing every future delivery. + const threads = yield* Ref.make>>([ + Option.none(), + ]); + const dispatched = yield* Ref.make>([]); + + const resolved = yield* getOrCreateHomeThread({ + instanceId: INSTANCE_ID, + title: "Hermes Workstation", + }).pipe( + Effect.provide( + testLayer({ + threads, + dispatched, + providerInstances: { + [INSTANCE_ID]: hermesInstance({ homeThreadId: "thread-deleted-long-ago" }), + }, + }), + ), + ); + + assert.notEqual(resolved, "thread-deleted-long-ago"); + const commands = yield* Ref.get(dispatched); + assert.isDefined(commands.find((command) => command.type === "thread.create")); + }), +); + +it.effect("adopts a racing caller's designation when its own dispatch fails", () => + Effect.gen(function* () { + const threads = yield* Ref.make>>([ + Option.none(), + ]); + const dispatched = yield* Ref.make>([]); + + // The loser of the race sees a dispatch failure but must still return the + // winner's thread rather than surfacing an error to the handshake. + const resolved = yield* getOrCreateHomeThread({ + instanceId: INSTANCE_ID, + title: "Hermes Workstation", + }).pipe( + Effect.provide( + testLayer({ + threads, + dispatched, + failDispatch: true, + providerInstances: { + // Designated, but the thread read says gone — so it falls through + // to create, fails, and re-reads the designation. + [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }), + }, + }), + ), + ); + + assert.equal(resolved, HOME_THREAD_ID); + }), +); + +it.effect("does not clobber a designation that landed while it was creating", () => + Effect.gen(function* () { + // Two handshakes race: both read "no designation", both create a thread. + // The one that persists *second* must stand down rather than overwrite — + // otherwise the first caller re-read the winner's id and returned it while + // this one overwrites with its own, so the two disagree about Home and + // concurrent proactive deliveries land in two different threads. + const threads = yield* Ref.make>>([ + Option.none(), + ]); + const dispatched = yield* Ref.make>([]); + const settingsLayer = ServerSettings.layerTest({ + providerInstances: { [INSTANCE_ID]: hermesInstance({}) }, + } as never); + + yield* Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + + const resolved = yield* getOrCreateHomeThread({ + instanceId: INSTANCE_ID, + title: "Hermes Workstation", + }).pipe( + Effect.provide( + testLayer({ + threads, + dispatched, + providerInstances: {}, + settingsLayer, + // The racing caller wins the settings write while this one is + // still between `thread.create` and its own persist. + onDispatch: () => + settings + .updateSettingsWith((latest) => ({ + providerInstances: { + ...latest.providerInstances, + [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }), + }, + })) + .pipe(Effect.ignore), + }), + ), + ); + + // Both callers agree on the winner's thread, and the loser's own thread + // stays an empty thread in the agent project rather than a second Home. + assert.equal(resolved, HOME_THREAD_ID); + assert.equal(yield* getDesignatedHomeThreadId(INSTANCE_ID), HOME_THREAD_ID); + }).pipe(Effect.provide(settingsLayer)); + }), +); + +it.effect("keeps an archived Home rather than minting a replacement", () => + Effect.gen(function* () { + // The bug this pins: `getThreadShellById` filters `archived_at IS NULL`, so + // an archived Home read as *deleted* and the self-healing path created a + // second one — stranding the real history and silently re-pointing the + // designation at an empty thread. + const threads = yield* Ref.make>>([ + Option.none(), + ]); + const dispatched = yield* Ref.make>([]); + + const resolved = yield* getOrCreateHomeThread({ + instanceId: INSTANCE_ID, + title: "Hermes Workstation", + }).pipe( + Effect.provide( + testLayer({ + threads, + dispatched, + archiveStates: [ + Option.some({ + projectId: agentProject.id, + archivedAt: "2026-01-02T00:00:00.000Z", + }), + ], + providerInstances: { + [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }), + }, + }), + ), + ); + + assert.equal(resolved, HOME_THREAD_ID); + const commands = yield* Ref.get(dispatched); + assert.isUndefined( + commands.find((command) => command.type === "thread.create"), + "an archived Home must never be replaced by a new thread", + ); + // A delivery is the agent raising its hand, so it brings the thread back + // rather than landing somewhere the user cannot see. + const unarchive = commands.find((command) => command.type === "thread.unarchive"); + assert.isDefined(unarchive); + if (unarchive?.type === "thread.unarchive") { + assert.equal(unarchive.threadId, HOME_THREAD_ID); + } + }), +); + +it.effect("does not un-archive a Home that was never archived", () => + Effect.gen(function* () { + const threads = yield* Ref.make>>([ + Option.some(threadShell(HOME_THREAD_ID)), + ]); + const dispatched = yield* Ref.make>([]); + + yield* getOrCreateHomeThread({ + instanceId: INSTANCE_ID, + title: "Hermes Workstation", + }).pipe( + Effect.provide( + testLayer({ + threads, + dispatched, + archiveStates: [Option.some({ projectId: agentProject.id, archivedAt: null })], + providerInstances: { + [INSTANCE_ID]: hermesInstance({ homeThreadId: HOME_THREAD_ID }), + }, + }), + ), + ); + + // The common path stays free of writes; this runs on every handshake. + assert.deepEqual(yield* Ref.get(dispatched), []); + }), +); diff --git a/apps/server/src/orchestration/homeThreads.ts b/apps/server/src/orchestration/homeThreads.ts new file mode 100644 index 000000000000..690f845a6a12 --- /dev/null +++ b/apps/server/src/orchestration/homeThreads.ts @@ -0,0 +1,259 @@ +/** + * Home threads — the one thread per agent instance that receives its agent's + * proactive output. + * + * Hermes has a first-class "home channel" concept: a per-platform default + * destination for output nobody asked for at a specific address — a cron job's + * result, an agent-initiated `send_message`, a gateway online notice, a + * `/handoff`. Every other Hermes surface designates one interactively via + * `/sethome`. T3 designates one automatically instead, because there is no + * "current channel" here for a user to point at: threads are created on + * demand, and asking someone to pick one before their first cron job can fire + * is setup for a decision they have no basis to make. + * + * ## Converge-on-read, like agent projects + * + * `getOrCreateHomeThread` is a **precondition every caller runs**, not a + * lifecycle event one caller owns — the same reasoning as + * `getOrCreateAgentProject`, and for the same failure modes: an instance + * enrolled before this shipped has no designation, a dispatch that failed once + * would otherwise leave the instance permanently unable to receive, and a + * thread deleted out from under the designation would strand every future + * delivery. Each of those self-heals on the next handshake. + * + * ## Why settings and not a table + * + * The designation is per-instance enrollment metadata, exactly like + * `connectorUrl` and `revoked`, and it lives beside them in the Hermes + * instance's config blob. That keeps one source of truth for "facts about this + * enrollment" and avoids a migration for a single nullable id. + * + * The plugin's `T3_HOME_CHANNEL` env var is a *cache* of this value, reconciled + * on every `connection.accepted`. This module is authoritative; drift is + * bounded by one reconnect. + * + * @module orchestration/homeThreads + */ +import { + CommandId, + DEFAULT_HERMES_MODEL, + DEFAULT_PROVIDER_INTERACTION_MODE, + HERMES_DRIVER_KIND, + ThreadId, + type ProviderInstanceConfig, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { ServerSettingsService } from "../serverSettings.ts"; +import { getOrCreateAgentProject } from "./agentProjects.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; + +/** + * Title of the home thread. Plain, no emoji: it sits in the same list as the + * user's own threads and should not shout. + */ +export const HOME_THREAD_TITLE = "Home"; + +/** Read the designated home thread id out of a Hermes instance envelope. */ +export const readHomeThreadId = ( + config: ProviderInstanceConfig | undefined, +): ThreadId | undefined => { + if (!config || config.driver !== HERMES_DRIVER_KIND) return undefined; + const blob = config.config && typeof config.config === "object" ? config.config : {}; + const raw = (blob as Record).homeThreadId; + return typeof raw === "string" && raw !== "" ? ThreadId.make(raw) : undefined; +}; + +/** + * Read one instance's designation straight from settings. + * + * Callers that only need to *know* the designation use this rather than + * `getOrCreateHomeThread`, so a read never has the side effect of creating a + * thread. + */ +export const getDesignatedHomeThreadId = Effect.fn("getDesignatedHomeThreadId")(function* ( + instanceId: ProviderInstanceId, +) { + const settings = yield* ServerSettingsService; + const current = yield* settings.getSettings; + return readHomeThreadId(current.providerInstances[instanceId]); +}); + +/** + * Read the designated thread's row regardless of archive state. + * + * The snapshot query's thread reads filter `archived_at IS NULL`, which is + * correct for "what should the user see" and wrong for "does this row exist". + * Going to the repository directly is what keeps an archived Home from looking + * deleted — and thereby keeps this module from minting a second one. A + * soft-deleted row still counts as gone. + */ +const readHomeThreadRow = Effect.fn("readHomeThreadRow")(function* (threadId: ThreadId) { + const query = yield* ProjectionSnapshotQuery; + if (query.getThreadArchiveStateById !== undefined) { + const row = yield* query.getThreadArchiveStateById(threadId); + return Option.getOrUndefined(row); + } + + // Compatibility for embedders and test layers implementing the older + // query shape: active and archived snapshots together distinguish a parked + // thread from one that was actually deleted. Production uses the indexed + // query above, so this O(n) fallback is not on the gateway hot path. + const active = yield* query.getThreadShellById(threadId); + if (Option.isSome(active)) { + return { projectId: active.value.projectId, archivedAt: active.value.archivedAt }; + } + const archived = yield* query.getArchivedShellSnapshot(); + const row = archived.threads.find((thread) => thread.id === threadId); + return row === undefined ? undefined : { projectId: row.projectId, archivedAt: row.archivedAt }; +}); + +/** + * Persist a designation into the instance's Hermes config blob. + * + * A compare-and-set against `expected` — the designation this caller read + * before deciding to create — rather than a blind write. Two concurrent + * callers that both mint a thread would otherwise each persist their own id, + * and the one that wrote first re-reads before the other's overwrite lands, so + * the two return *different* threads and one is orphaned from the final + * designation. Standing down when the designation moved out from under us is + * what makes the caller's re-read below an actual tiebreaker. + * + * `expected` still has to be honoured rather than "never overwrite": the + * self-healing path arrives here with a stale designation pointing at a thread + * that is really gone, and replacing exactly that value is the whole point. + */ +const persistHomeThreadId = (input: { + readonly instanceId: ProviderInstanceId; + readonly threadId: ThreadId; + /** Designation observed before creating, or `undefined` if there was none. */ + readonly expected: ThreadId | undefined; +}) => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + yield* settings.updateSettingsWith((latest) => { + // Re-read under the settings write lock: the envelope may have been + // replaced (or the driver changed) between our read and this write, and + // writing a Hermes designation onto a non-Hermes envelope would corrupt + // it. Returning an empty patch is the established no-op here. + const existing = latest.providerInstances[input.instanceId]; + if (!existing || existing.driver !== HERMES_DRIVER_KIND) return {}; + // Someone else designated (or re-designated) while we were creating. + // Adopt theirs; ours stays an empty thread in the agent project. + if (readHomeThreadId(existing) !== input.expected) return {}; + const currentConfig = + existing.config && typeof existing.config === "object" + ? (existing.config as Record) + : {}; + return { + providerInstances: { + ...latest.providerInstances, + [input.instanceId]: { + ...existing, + config: { ...currentConfig, homeThreadId: input.threadId }, + }, + }, + }; + }); + }); + +/** + * Resolve the home thread for one provider instance, creating it if absent. + * + * Safe to call on every handshake: the common path is one settings read plus + * one indexed thread read. Idempotent, and concurrent callers converge — + * the loser of a race re-reads and adopts the winner's thread. + */ +export const getOrCreateHomeThread = Effect.fn("getOrCreateHomeThread")(function* (input: { + readonly instanceId: ProviderInstanceId; + /** Instance nickname, used as the agent project's title on first creation. */ + readonly title: string; +}) { + const engine = yield* OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + + const designated = yield* getDesignatedHomeThreadId(input.instanceId); + if (designated !== undefined) { + // Deliberately NOT `getThreadShellById`: that query filters + // `archived_at IS NULL`, so an archived Home reads as absent and this + // function would "self-heal" by minting a second Home — stranding the + // history in the archived one and silently re-pointing the designation. + // Archiving is a user parking a thread, not destroying it; only a row + // that is really gone justifies a replacement. + const existing = yield* readHomeThreadRow(designated); + if (existing !== undefined) { + // Un-archive rather than deliver into a hidden thread. A delivery is + // the agent raising its hand, and the same rule the decider applies to + // settled/snoozed threads applies here: incoming activity brings the + // thread back. Best-effort — a failure here must not cost the delivery. + if (existing.archivedAt !== null) { + yield* engine + .dispatch({ + type: "thread.unarchive", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId: designated, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("could not un-archive the home thread", { + instanceId: input.instanceId, + threadId: designated, + cause: Cause.pretty(cause), + }), + ), + ); + } + return designated; + } + } + + const project = yield* getOrCreateAgentProject({ + instanceId: input.instanceId, + title: input.title, + }); + + const threadId = ThreadId.make(yield* crypto.randomUUIDv4); + const createdAt = DateTime.formatIso(yield* DateTime.now); + + const dispatched = yield* Effect.result( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + projectId: project.id, + title: HOME_THREAD_TITLE, + // The same fixed slug every Hermes thread binds to — the reported model + // name is display-only, and following it here would orphan the thread + // whenever Hermes' own config changed. + modelSelection: { instanceId: input.instanceId, model: DEFAULT_HERMES_MODEL }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt, + }), + ); + + if (dispatched._tag === "Failure") { + // A concurrent handshake may have won and already persisted its thread. + // Re-read before surfacing the error, matching getOrCreateAgentProject. + const raced = yield* getDesignatedHomeThreadId(input.instanceId); + if (raced !== undefined) return raced; + return yield* Effect.fail(dispatched.failure); + } + + yield* persistHomeThreadId({ instanceId: input.instanceId, threadId, expected: designated }); + + // Re-read rather than trusting our own write: if a racing caller persisted + // first, both callers must agree on one thread, and settings is the + // tiebreaker. Our orphaned thread stays as an empty thread in the project + // rather than becoming a second home. + const settled = yield* getDesignatedHomeThreadId(input.instanceId); + return settled ?? threadId; +}); diff --git a/apps/server/src/provider/Layers/HermesConnectionRegistry.ts b/apps/server/src/provider/Layers/HermesConnectionRegistry.ts new file mode 100644 index 000000000000..69913a834b3f --- /dev/null +++ b/apps/server/src/provider/Layers/HermesConnectionRegistry.ts @@ -0,0 +1,224 @@ +/** + * Live {@link HermesConnectionRegistry}. See the service module for why this + * state is memory-only and how generation fencing works. + * + * @module Layers/HermesConnectionRegistry + */ +import type { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import type { + HermesActiveConnection, + HermesConnectionRegistry, + HermesInstanceLiveness, + HermesLivenessStatusFields, + HermesObservedConnection, +} from "../Services/HermesConnectionRegistry.ts"; + +/** Strip the transport/scope/generation from a connection to keep as `lastSeen`. */ +const toObserved = (connection: HermesActiveConnection): HermesObservedConnection => ({ + pluginVersion: connection.pluginVersion, + hermesVersion: connection.hermesVersion, + capabilities: connection.capabilities, + model: connection.model, + connectedAt: connection.connectedAt, + activeSessionCount: connection.activeSessionCount, +}); + +/** + * Project liveness into the status fields it owns. Prefers the live connection, + * falls back to `lastSeen`, and finally to whatever the incompatible plugin + * advertised. + */ +export const livenessStatusFields = ( + liveness: HermesInstanceLiveness | undefined, +): HermesLivenessStatusFields => { + const observed = liveness?.connection ?? liveness?.lastSeen; + return { + lastConnectedAt: observed?.connectedAt ?? null, + pluginVersion: observed?.pluginVersion ?? liveness?.upgradeRequired?.pluginVersion ?? null, + hermesVersion: observed?.hermesVersion ?? liveness?.upgradeRequired?.hermesVersion ?? null, + model: observed?.model ?? liveness?.upgradeRequired?.model ?? null, + activeSessionCount: liveness?.connection?.activeSessionCount ?? 0, + protocolVersion: + observed?.capabilities.protocolVersion ?? liveness?.upgradeRequired?.protocolVersion ?? null, + capabilities: observed?.capabilities ?? null, + // Exposed so consumers can tell a replacement (old socket dies as a new + // one is accepted, publishing a single `connected` status) from a + // continuous connection. Watching connectedness alone cannot. + connectionGeneration: liveness?.connection?.generation ?? null, + connected: liveness?.connection !== undefined, + upgradeRequired: liveness?.upgradeRequired !== undefined, + }; +}; + +export const makeHermesConnectionRegistry = Effect.gen(function* () { + const states = yield* Ref.make(new Map()); + const generation = yield* Ref.make(0); + + /** Write liveness for one instance, dropping the entry when it goes empty. */ + const setLiveness = ( + current: ReadonlyMap, + instanceId: ProviderInstanceId, + liveness: HermesInstanceLiveness, + ) => { + const next = new Map(current); + if ( + liveness.connection === undefined && + liveness.lastSeen === undefined && + liveness.upgradeRequired === undefined + ) { + next.delete(instanceId); + } else { + next.set(instanceId, liveness); + } + return next; + }; + + const liveness = (instanceId: ProviderInstanceId) => + Ref.get(states).pipe(Effect.map((current) => current.get(instanceId))); + + const accept: HermesConnectionRegistry["accept"] = (input) => + Effect.gen(function* () { + const nextGeneration = yield* Ref.getAndUpdate(generation, (value) => value + 1); + // The connection scope is a child of nothing in particular: it is closed + // explicitly on disconnect/replace so per-connection fibers (ping) die + // exactly with the connection they belong to. + const scope = yield* Scope.make(); + const connection: HermesActiveConnection = { + ...input.observed, + generation: nextGeneration, + transport: input.transport, + scope, + }; + + const displaced = yield* Ref.modify(states, (current) => { + const found = current.get(input.instanceId); + return [ + found?.connection, + setLiveness(current, input.instanceId, { + connection, + lastSeen: toObserved(connection), + // Accepting a compatible connection clears any stale + // upgrade-required observation for this instance. + upgradeRequired: undefined, + }), + ] as const; + }); + + // Replacing the registry entry also retires everything scoped to the + // old generation (notably its liveness ping). Closing only the socket at + // the broker boundary would leave that fiber probing a fenced transport + // forever after every reconnect. + if (displaced) yield* Scope.close(displaced.scope, Exit.void).pipe(Effect.ignore); + + return { generation: nextGeneration, displaced }; + }); + + const markUpgradeRequired: HermesConnectionRegistry["markUpgradeRequired"] = (input) => + Effect.gen(function* () { + const displaced = yield* Ref.modify(states, (current) => { + const found = current.get(input.instanceId); + return [ + found?.connection, + setLiveness(current, input.instanceId, { + connection: undefined, + lastSeen: found?.lastSeen, + upgradeRequired: input.upgradeRequired, + }), + ] as const; + }); + if (displaced) yield* Scope.close(displaced.scope, Exit.void).pipe(Effect.ignore); + return displaced; + }); + + const recordSessionCount: HermesConnectionRegistry["recordSessionCount"] = ( + registration, + activeSessionCount, + ) => + Ref.modify(states, (current) => { + const found = current.get(registration.instanceId); + // Fence inside the update so a replacement that lands between a read and + // this write cannot be clobbered. + if (found?.connection?.generation !== registration.generation) { + return [false, current] as const; + } + const connection: HermesActiveConnection = { ...found.connection, activeSessionCount }; + return [ + true, + setLiveness(current, registration.instanceId, { + ...found, + connection, + lastSeen: toObserved(connection), + }), + ] as const; + }); + + const disconnect: HermesConnectionRegistry["disconnect"] = (registration) => + Effect.gen(function* () { + const retired = yield* Ref.modify(states, (current) => { + const found = current.get(registration.instanceId); + if (found?.connection?.generation !== registration.generation) { + return [undefined, current] as const; + } + return [ + found.connection, + setLiveness(current, registration.instanceId, { + connection: undefined, + lastSeen: toObserved(found.connection), + upgradeRequired: found.upgradeRequired, + }), + ] as const; + }); + if (retired) yield* Scope.close(retired.scope, Exit.void).pipe(Effect.ignore); + return retired; + }); + + const clearConnection: HermesConnectionRegistry["clearConnection"] = (instanceId) => + Effect.gen(function* () { + const cleared = yield* Ref.modify(states, (current) => { + const found = current.get(instanceId); + if (!found?.connection) return [undefined, current] as const; + return [ + found.connection, + setLiveness(current, instanceId, { + connection: undefined, + lastSeen: toObserved(found.connection), + upgradeRequired: found.upgradeRequired, + }), + ] as const; + }); + if (cleared) yield* Scope.close(cleared.scope, Exit.void).pipe(Effect.ignore); + return cleared; + }); + + const forget = (instanceId: ProviderInstanceId) => + Effect.gen(function* () { + const dropped = yield* Ref.modify(states, (current) => { + const found = current.get(instanceId); + if (!found) return [undefined, current] as const; + const next = new Map(current); + next.delete(instanceId); + return [found.connection, next] as const; + }); + if (dropped) yield* Scope.close(dropped.scope, Exit.void).pipe(Effect.ignore); + }); + + return { + liveness, + accept, + markUpgradeRequired, + recordSessionCount, + disconnect, + clearConnection, + forget, + isConnected: (instanceId) => + liveness(instanceId).pipe(Effect.map((found) => found?.connection !== undefined)), + connection: (instanceId) => liveness(instanceId).pipe(Effect.map((found) => found?.connection)), + transport: (instanceId) => + liveness(instanceId).pipe(Effect.map((found) => found?.connection?.transport)), + } satisfies HermesConnectionRegistry; +}); diff --git a/apps/server/src/provider/Layers/HermesEnrollmentStore.ts b/apps/server/src/provider/Layers/HermesEnrollmentStore.ts new file mode 100644 index 000000000000..6804d45803f5 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesEnrollmentStore.ts @@ -0,0 +1,106 @@ +/** + * Live {@link HermesEnrollmentStore}. See the service module for the token + * rules this upholds. + * + * @module Layers/HermesEnrollmentStore + */ +import { HermesGatewayEnrollmentToken, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Ref from "effect/Ref"; + +import type { + HermesEnrollmentStore, + PendingEnrollment, +} from "../Services/HermesEnrollmentStore.ts"; + +const ENROLLMENT_TOKEN_BYTES = 32; + +export interface MakeHermesEnrollmentStoreOptions { + readonly ttl: Duration.Duration; +} + +export const makeHermesEnrollmentStore = ( + options: MakeHermesEnrollmentStoreOptions, +): Effect.Effect => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const enrollments = yield* Ref.make(new Map()); + + const mint: HermesEnrollmentStore["mint"] = (input) => + Effect.gen(function* () { + const bytes = yield* crypto.randomBytes(ENROLLMENT_TOKEN_BYTES).pipe(Effect.orDie); + const token = HermesGatewayEnrollmentToken.make(Encoding.encodeBase64Url(bytes)); + const expiresAtMillis = (yield* Clock.currentTimeMillis) + Duration.toMillis(options.ttl); + + yield* Ref.update(enrollments, (current) => { + const next = new Map(current); + // Minting supersedes this instance's previous tokens so only the + // newest unconsumed token is redeemable. + for (const [pendingToken, pending] of current) { + if (pending.input.instanceId === input.instanceId) next.delete(pendingToken); + } + return next.set(token, { input, expiresAtMillis }); + }); + + return { token, expiresAtMillis }; + }); + + const peek: HermesEnrollmentStore["peek"] = (token) => + Effect.gen(function* () { + const found = (yield* Ref.get(enrollments)).get(token); + if (!found) return undefined; + return found.expiresAtMillis < (yield* Clock.currentTimeMillis) ? undefined : found; + }); + + const consume: HermesEnrollmentStore["consume"] = (token, expected) => + Effect.gen(function* () { + // Compare-and-swap: delete only if the entry is still the exact object + // the caller authenticated against, so a concurrent redemption or a + // re-mint in between cannot be consumed by this caller. + const claimed = yield* Ref.modify(enrollments, (current) => { + const found = current.get(token); + if (found !== expected) return [undefined, current] as const; + const next = new Map(current); + next.delete(token); + return [found, next] as const; + }); + if (claimed === undefined) return undefined; + // Re-check expiry after winning the swap: the entry may have aged out + // between authentication and consumption. + return claimed.expiresAtMillis < (yield* Clock.currentTimeMillis) ? undefined : claimed; + }); + + const forget = (instanceId: ProviderInstanceId) => + Ref.update(enrollments, (current) => { + const next = new Map(current); + for (const [token, pending] of current) { + if (pending.input.instanceId === instanceId) next.delete(token); + } + return next; + }); + + const sweep = Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => + Ref.update(enrollments, (current) => { + const next = new Map(current); + for (const [token, pending] of current) { + if (pending.expiresAtMillis < now) next.delete(token); + } + return next.size === current.size ? current : next; + }), + ), + ); + + return { + mint, + peek, + consume, + forget, + sweep, + size: Ref.get(enrollments).pipe(Effect.map((current) => current.size)), + } satisfies HermesEnrollmentStore; + }); diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts new file mode 100644 index 000000000000..b4d458b398ac --- /dev/null +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.test.ts @@ -0,0 +1,1421 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + HERMES_GATEWAY_PROTOCOL_VERSION, + HermesGatewayCredential, + HermesGatewayRequestId, + HermesGatewaySessionId, + ProviderInstanceId, + ThreadId, + type HermesGatewayConnectionHello, + type HermesGatewayT3ToPluginMessage, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Deferred from "effect/Deferred"; +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 ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import { + type HermesGatewayBrokerShape, + type HermesGatewayConnectionRegistration, + type HermesGatewayTransport, +} from "../Services/HermesGatewayBroker.ts"; +import { + hermesGatewayCredentialSecretName, + hermesGatewayLegacyMetadataSecretName, + makeHermesGatewayBroker, +} from "./HermesGatewayBroker.ts"; + +/** + * A version this server does not speak. Expressed relative to the supported + * version rather than as a literal, so bumping the protocol does not turn + * these "must be rejected" cases into "is the current version" cases. + */ +const UNSUPPORTED_PROTOCOL_VERSION = HERMES_GATEWAY_PROTOCOL_VERSION + 1; + +const instanceId = ProviderInstanceId.make("hermes_remote"); +const otherInstanceId = ProviderInstanceId.make("hermes_other"); +const defaultHermesInstanceId = ProviderInstanceId.make("hermes"); + +const capabilities = { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + // Part of the v4 contract itself: a hello advertising `false` fails the + // strict capabilities check and is rejected at the version gate. + attachments: true, +} as const; + +const makeSecretStore = () => { + const values = new Map(); + const service: ServerSecretStore.ServerSecretStore["Service"] = { + get: (name) => Effect.succeed(Option.fromUndefinedOr(values.get(name))), + set: (name, value) => Effect.sync(() => values.set(name, value)).pipe(Effect.asVoid), + create: (name, value) => Effect.sync(() => values.set(name, value)).pipe(Effect.asVoid), + getOrCreateRandom: (_name, bytes) => Effect.succeed(new Uint8Array(bytes)), + remove: (name) => Effect.sync(() => values.delete(name)).pipe(Effect.asVoid), + }; + return service; +}; + +const hermesInstances = { + [defaultHermesInstanceId]: { driver: "hermes", displayName: "Hermes", config: {} }, + [instanceId]: { driver: "hermes", displayName: "Remote", config: {} }, + [otherInstanceId]: { driver: "hermes", displayName: "Other", config: {} }, +} as const; + +/** + * Broker over the ambient settings service, so a test can assert on what the + * broker wrote and can build a second broker over the same durable state to + * model a server restart. + */ +const makeBroker = (secrets: ServerSecretStore.ServerSecretStore["Service"]) => + makeHermesGatewayBroker.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provide(NodeServices.layer), + ); + +/** + * Broker over an explicitly supplied settings service, so a test can interpose + * on `getSettings` and hold a handshake at a chosen read while another + * operation runs to completion. + */ +const makeBrokerWith = ( + secrets: ServerSecretStore.ServerSecretStore["Service"], + settingsService: ServerSettings.ServerSettingsService["Service"], +) => + makeHermesGatewayBroker.pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provideService(ServerSettings.ServerSettingsService, settingsService), + Effect.provide(NodeServices.layer), + ); + +/** Ambient layer: one settings service shared by the test and its brokers. */ +const testLayer = Layer.mergeAll( + ServerSettings.layerTest({ providerInstances: hermesInstances }), + NodeServices.layer, +); + +it.effect("materializes the fresh-install default Hermes slot when enrolling", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const settings = yield* ServerSettings.ServerSettingsService; + + assert.isUndefined((yield* settings.getSettings).providerInstances[defaultHermesInstanceId]); + const enrollment = yield* broker.createEnrollment({ + instanceId: defaultHermesInstanceId, + nickname: "Default Hermes", + connectorUrl: "https://t3.example.test/api/hermes-gateway/ws", + }); + + assert.equal(enrollment.instanceId, defaultHermesInstanceId); + const configured = (yield* settings.getSettings).providerInstances[defaultHermesInstanceId]; + assert.equal(configured?.driver, "hermes"); + assert.equal(configured?.displayName, "Default Hermes"); + assert.deepInclude(configured?.config as object, { + binaryPath: "hermes-acp", + connectorUrl: "https://t3.example.test/api/hermes-gateway/ws", + revoked: false, + }); + }).pipe(Effect.provide(Layer.mergeAll(ServerSettings.layerTest(), NodeServices.layer))), +); + +const hello = ( + authentication: HermesGatewayConnectionHello["authentication"], + protocolVersion: number = HERMES_GATEWAY_PROTOCOL_VERSION, + overrides: { + readonly model?: string; + readonly role?: HermesGatewayConnectionHello["role"]; + } = {}, +): HermesGatewayConnectionHello => ({ + type: "connection.hello", + requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`), + protocolVersion, + pluginVersion: "0.2.0", + hermesVersion: "1.0.0", + capabilities: { ...capabilities, protocolVersion }, + authentication, + role: overrides.role ?? "gateway", + ...(overrides.model !== undefined ? { model: overrides.model } : {}), +}); + +/** Transport that records everything it is asked to do. */ +const recordingTransport = () => { + const sent: Array = []; + const closes: Array = []; + const transport: HermesGatewayTransport = { + send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid), + close: (code) => Effect.sync(() => closes.push(code)).pipe(Effect.asVoid), + }; + return { sent, closes, transport }; +}; + +const enroll = (broker: HermesGatewayBrokerShape, id: ProviderInstanceId, nickname: string) => + broker.createEnrollment({ + instanceId: id, + nickname, + connectorUrl: "https://t3.example.test", + }); + +it.effect("authenticates before applying incompatible connection state", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const broker = yield* makeBroker(secrets); + yield* enroll(broker, defaultHermesInstanceId, "Default Hermes"); + const enrollment = yield* enroll(broker, instanceId, "Remote Hermes"); + + const first = recordingTransport(); + const registered = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + first.transport, + ); + assert.isTrue(registered.accepted.credential !== undefined); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); + + const second = recordingTransport(); + const replacement = yield* broker.registerConnection( + hello({ + type: "instance-credential", + instanceId, + credential: registered.accepted.credential!, + }), + second.transport, + ); + assert.deepEqual(first.closes, [4001]); + + const threadId = ThreadId.make("pending-thread"); + const pendingRequestId = HermesGatewayRequestId.make("pending-session"); + const pendingRequest = yield* broker + .request(instanceId, { + type: "session.ensure", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.equal(second.sent.at(-1)?.type, "session.ensure"); + + // A bad credential carried on an incompatible protocol version must be + // rejected for the credential, never for the version: rejecting on version + // first would let an unauthenticated caller knock the instance offline. + const malicious = yield* Effect.flip( + broker.registerConnection( + hello( + { + type: "instance-credential", + instanceId, + credential: HermesGatewayCredential.make("not-the-real-credential"), + }, + UNSUPPORTED_PROTOCOL_VERSION, + ), + second.transport, + ), + ); + assert.equal(malicious.code, "invalid-authentication"); + assert.deepEqual(second.closes, []); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); + assert.isUndefined(pendingRequest.pollUnsafe()); + + yield* broker.receive(replacement, { + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + sessionId: HermesGatewaySessionId.make("pending-session-id"), + resumed: false, + }); + assert.equal((yield* Fiber.join(pendingRequest)).type, "session.ready"); + + const incompatible = yield* Effect.flip( + broker.registerConnection( + hello( + { + type: "instance-credential", + instanceId, + credential: registered.accepted.credential!, + }, + UNSUPPORTED_PROTOCOL_VERSION, + ), + second.transport, + ), + ); + assert.equal(incompatible.code, "version-incompatible"); + assert.deepEqual(second.closes, [4004]); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "upgrade-required"); + + const otherEnrollment = yield* enroll(broker, otherInstanceId, "Other Hermes"); + const incompatibleEnrollment = yield* Effect.flip( + broker.registerConnection( + hello( + { type: "enrollment-token", token: otherEnrollment.oneTimeToken }, + UNSUPPORTED_PROTOCOL_VERSION, + ), + second.transport, + ), + ); + assert.equal(incompatibleEnrollment.code, "version-incompatible"); + const enrolledAfterUpgrade = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }), + second.transport, + ); + assert.equal(enrolledAfterUpgrade.instanceId, otherInstanceId); + + const revoked = yield* broker.revokeInstance(instanceId); + assert.equal(revoked.status, "revoked"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("invalidates the previous credential when replacement enrollment begins", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Remote Hermes"); + const { closes, transport } = recordingTransport(); + const first = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + transport, + ); + const delivery = yield* broker.registerConnection( + hello( + { + type: "instance-credential", + instanceId, + credential: first.accepted.credential!, + }, + HERMES_GATEWAY_PROTOCOL_VERSION, + { role: "delivery" }, + ), + recordingTransport().transport, + ); + const replacement = yield* enroll(broker, instanceId, "Remote Hermes"); + assert.deepEqual(closes, [4001]); + + const staleDelivery = yield* Effect.flip( + broker.withAuthorizedConnection(delivery, Effect.void), + ); + assert.include(staleDelivery.detail, "no longer authorized"); + + const staleCredential = yield* Effect.flip( + broker.registerConnection( + hello({ + type: "instance-credential", + instanceId, + credential: first.accepted.credential!, + }), + transport, + ), + ); + assert.equal(staleCredential.code, "invalid-authentication"); + const replacementConnection = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: replacement.oneTimeToken }), + transport, + ); + assert.equal(replacementConnection.instanceId, instanceId); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("accepts only the newest unconsumed enrollment token for an instance", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const older = yield* enroll(broker, instanceId, "Remote Hermes"); + const newest = yield* enroll(broker, instanceId, "Remote Hermes"); + const { transport } = recordingTransport(); + + const olderTokenError = yield* Effect.flip( + broker.registerConnection( + hello({ type: "enrollment-token", token: older.oneTimeToken }), + transport, + ), + ); + assert.equal(olderTokenError.code, "enrollment-expired"); + const newestConnection = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: newest.oneTimeToken }), + transport, + ); + assert.equal(newestConnection.instanceId, instanceId); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("redeems an enrollment token exactly once under concurrent redemption", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Remote Hermes"); + + const raceFirst = yield* broker + .registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + const raceSecond = yield* broker + .registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + + const outcomes = [yield* Fiber.join(raceFirst), yield* Fiber.join(raceSecond)]; + const accepted = outcomes.filter((outcome) => outcome._tag === "Success"); + const rejected = outcomes.filter((outcome) => outcome._tag === "Failure"); + assert.equal(accepted.length, 1); + assert.equal(rejected.length, 1); + assert.equal( + rejected[0]?._tag === "Failure" ? rejected[0].failure.code : "", + "enrollment-expired", + ); + }).pipe(Effect.provide(testLayer)), +); + +/** + * Secret store that can hold the *first* `set` — the credential write at the + * heart of the handshake — open until a test releases it. That is the exact + * point a competing enrollment or revocation used to be able to slip past a + * half-finished handshake. + */ +const gatedCredentialWriteSecretStore = () => + Effect.gen(function* () { + const inner = makeSecretStore(); + const reachedWrite = yield* Deferred.make(); + const releaseWrite = yield* Deferred.make(); + let gated = false; + const service: ServerSecretStore.ServerSecretStore["Service"] = { + ...inner, + set: (name, value) => + Effect.suspend(() => { + if (gated) return inner.set(name, value); + gated = true; + return Deferred.succeed(reachedWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseWrite)), + Effect.andThen(inner.set(name, value)), + ); + }), + }; + return { secrets: service, reachedWrite, releaseWrite } as const; + }); + +// The credential write and the acceptance that follows it are the mutation +// half of the handshake. Before they were fenced by the instance lock, a +// `createEnrollment` could complete entirely inside that window: it removed the +// outgoing credential and minted a replacement token, and then the suspended +// handshake resumed and wrote a *fresh* credential for the enrollment that had +// just been superseded — leaving the old enrollee able to authenticate forever. +it.effect("does not resurrect a credential for an enrollment superseded mid-handshake", () => + Effect.gen(function* () { + const { secrets, reachedWrite, releaseWrite } = yield* gatedCredentialWriteSecretStore(); + const broker = yield* makeBrokerWith(secrets, yield* ServerSettings.ServerSettingsService); + const enrollment = yield* enroll(broker, instanceId, "Superseded Hermes"); + + const handshake = yield* broker + .registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + // The handshake is now parked with its token already consumed, holding the + // instance lock. + yield* Deferred.await(reachedWrite); + + const replacement = yield* enroll(broker, instanceId, "Superseded Hermes").pipe( + Effect.forkChild({ startImmediately: true }), + ); + // The replacement enrollment must not be able to interleave with the + // handshake's mutation section: it waits for the lock instead. + for (let i = 0; i < 20; i += 1) yield* Effect.yieldNow; + assert.isUndefined( + replacement.pollUnsafe(), + "a replacement enrollment must not run inside a handshake's mutation section", + ); + + yield* Deferred.succeed(releaseWrite, undefined); + const registered = yield* Fiber.join(handshake); + yield* Fiber.join(replacement); + + // Whichever order they serialize in, the enrollment that ran last wins: the + // superseded enrollee's credential is gone from the store... + assert.isTrue( + Option.isNone(yield* secrets.get(hermesGatewayCredentialSecretName(instanceId))), + "the superseded enrollment's credential must not survive the replacement", + ); + // ...and cannot be replayed to authenticate. + assert.equal(registered._tag, "Success"); + const credential = + registered._tag === "Success" ? registered.success.accepted.credential : undefined; + if (!credential) { + return yield* Effect.die(new Error("the handshake did not issue a credential to replay")); + } + const replayed = yield* Effect.flip( + broker.registerConnection( + hello({ type: "instance-credential", instanceId, credential }), + recordingTransport().transport, + ), + ); + assert.equal(replayed.code, "invalid-authentication"); + }).pipe(Effect.provide(testLayer)), +); + +// The mirror of the above for revocation. `registerConnection` used to read a +// non-revoked record, pause, and then call `connections.accept` after a +// concurrent `revokeInstance` had already persisted `revoked: true` and cleared +// the connection — re-establishing a live socket for a revoked instance that +// nothing would reap until the next reconnect. +it.effect("leaves no live connection when revocation races a handshake", () => + Effect.gen(function* () { + const { secrets, reachedWrite, releaseWrite } = yield* gatedCredentialWriteSecretStore(); + const broker = yield* makeBrokerWith(secrets, yield* ServerSettings.ServerSettingsService); + const enrollment = yield* enroll(broker, instanceId, "Revoked Race Hermes"); + const { closes, transport } = recordingTransport(); + + const handshake = yield* broker + .registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + transport, + ) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(reachedWrite); + + const revoke = yield* broker + .revokeInstance(instanceId) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + for (let i = 0; i < 20; i += 1) yield* Effect.yieldNow; + assert.isUndefined( + revoke.pollUnsafe(), + "revocation must not run inside a handshake's mutation section", + ); + + yield* Deferred.succeed(releaseWrite, undefined); + yield* Fiber.join(handshake); + const revoked = yield* Fiber.join(revoke); + assert.equal(revoked._tag, "Success"); + + // Serializing the two is enough: the handshake may win the lock and be + // accepted, but revocation then runs to completion behind it and tears the + // socket down. What must never hold is a revoked instance with a live + // connection. + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "revoked"); + assert.isFalse( + yield* broker.isConnected(instanceId), + "a revoked instance must not be left connected", + ); + assert.deepEqual(closes, [4003]); + assert.isTrue(Option.isNone(yield* secrets.get(hermesGatewayCredentialSecretName(instanceId)))); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("expires enrollment tokens from memory without a redemption attempt", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Remote Hermes"); + + // Past the 10 minute TTL and past a sweep tick. + yield* TestClock.adjust(Duration.minutes(11)); + + const expired = yield* Effect.flip( + broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ), + ); + assert.equal(expired.code, "enrollment-expired"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("finalizes revocation when credential deletion fails", () => + Effect.gen(function* () { + const storedSecrets = makeSecretStore(); + let failRemovals = false; + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...storedSecrets, + remove: (name) => + failRemovals + ? Effect.fail( + new ServerSecretStore.SecretStoreRemoveError({ + resource: `secret ${name}`, + cause: new Error("forced credential deletion failure"), + }), + ) + : storedSecrets.remove(name), + }; + const broker = yield* makeBroker(secrets); + const enrollment = yield* enroll(broker, instanceId, "Remote Hermes"); + const { sent, closes, transport } = recordingTransport(); + const registration = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + transport, + ); + const credential = registration.accepted.credential; + if (!credential) { + return yield* Effect.die(new Error("enrollment did not issue a credential")); + } + + const threadId = ThreadId.make("revoke-pending-thread"); + const pendingRequestId = HermesGatewayRequestId.make("revoke-pending-request"); + const pendingRequest = yield* broker + .request(instanceId, { + type: "session.ensure", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.equal(sent.at(-1)?.type, "session.ensure"); + const statusEvent = yield* Stream.runHead(broker.streamStatuses).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + failRemovals = true; + const revokeError = yield* Effect.flip(broker.revokeInstance(instanceId)); + assert.equal(revokeError.code, "persistence-failed"); + assert.deepEqual(closes, [4003]); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "revoked"); + assert.equal(Option.getOrUndefined(yield* Fiber.join(statusEvent))?.status, "revoked"); + const pendingError = yield* Effect.flip(Fiber.join(pendingRequest)); + assert.include(pendingError.detail, "revoked"); + + const staleReconnect = yield* Effect.flip( + broker.registerConnection( + hello({ type: "instance-credential", instanceId, credential }), + recordingTransport().transport, + ), + ); + assert.equal(staleReconnect.code, "instance-revoked"); + }).pipe(Effect.provide(testLayer)), +); + +// ── Durable config lives in settings; liveness never does ────────── + +it.effect("stores durable enrollment facts in the provider instance config", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const settings = yield* ServerSettings.ServerSettingsService; + yield* broker.createEnrollment({ + instanceId, + nickname: "Durable Hermes", + connectorUrl: "https://durable.example.test", + }); + + const configured = (yield* settings.getSettings).providerInstances[instanceId]; + assert.equal(configured?.displayName, "Durable Hermes"); + assert.deepEqual(configured?.config, { + connectorUrl: "https://durable.example.test", + revoked: false, + }); + + yield* broker.revokeInstance(instanceId); + assert.deepEqual((yield* settings.getSettings).providerInstances[instanceId]?.config, { + connectorUrl: "https://durable.example.test", + revoked: true, + }); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("never writes liveness into settings, so reconnects cannot restart instances", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const settings = yield* ServerSettings.ServerSettingsService; + const enrollment = yield* enroll(broker, instanceId, "Liveness Hermes"); + + const beforeConnect = (yield* settings.getSettings).providerInstances[instanceId]; + const registration = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ); + yield* broker.receive(registration, { + type: "connection.status", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + activeSessionCount: 3, + }); + + // Connecting and reporting sessions is observable on status... + const connected = yield* broker.getInstanceStatus(instanceId); + assert.equal(connected.status, "connected"); + assert.equal(connected.activeSessionCount, 3); + assert.isNotNull(connected.lastConnectedAt); + + // ...but the settings envelope is unchanged, so the provider instance + // registry sees no config change and never closes the instance scope — + // which would otherwise stop every live Hermes session. + assert.deepEqual((yield* settings.getSettings).providerInstances[instanceId], beforeConnect); + + yield* broker.disconnect(registration); + assert.deepEqual((yield* settings.getSettings).providerInstances[instanceId], beforeConnect); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("reports liveness as offline after a restart until the plugin reconnects", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const broker = yield* makeBroker(secrets); + const enrollment = yield* enroll(broker, instanceId, "Restart Hermes"); + yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); + + // A fresh broker over the same durable state: enrollment facts survive, + // liveness deliberately does not. + const restarted = yield* makeBroker(secrets); + const status = yield* restarted.getInstanceStatus(instanceId); + assert.equal(status.status, "offline"); + assert.equal(status.nickname, "Restart Hermes"); + assert.equal(status.connectorUrl, "https://t3.example.test"); + assert.isNull(status.lastConnectedAt); + assert.isNull(status.model); + assert.equal(status.activeSessionCount, 0); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("surfaces the model reported at handshake and clears it on restart", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const broker = yield* makeBroker(secrets); + const enrollment = yield* enroll(broker, instanceId, "Model Hermes"); + + assert.isNull((yield* broker.getInstanceStatus(instanceId)).model); + + yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }, undefined, { + model: "hermes-4-preview", + }), + recordingTransport().transport, + ); + assert.equal((yield* broker.getInstanceStatus(instanceId)).model, "hermes-4-preview"); + + const restarted = yield* makeBroker(secrets); + assert.isNull((yield* restarted.getInstanceStatus(instanceId)).model); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("reports a null model for a plugin that predates the field", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Legacy Model Hermes"); + yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ); + const status = yield* broker.getInstanceStatus(instanceId); + assert.equal(status.status, "connected"); + assert.isNull(status.model); + }).pipe(Effect.provide(testLayer)), +); + +// ── Ping loop ───────────────────────────────────────────────────── + +it.effect("processes primary responses while a durable delivery holds the lifecycle lock", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Concurrent Hermes"); + const registration = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ); + const releaseDelivery = yield* Deferred.make(); + const delivery = yield* broker + .withAuthorizedConnection(registration, Deferred.await(releaseDelivery)) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + const response = yield* broker + .receive(registration, { + type: "pong", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make("pong-while-delivering"), + sentAt: "2026-07-23T12:00:00.000Z", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + for (let i = 0; i < 20 && response.pollUnsafe() === undefined; i += 1) { + yield* Effect.yieldNow; + } + assert.isDefined( + response.pollUnsafe(), + "liveness responses must not wait behind media persistence", + ); + + yield* Deferred.succeed(releaseDelivery, undefined); + yield* Fiber.join(delivery); + yield* Fiber.join(response); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("pings an active connection and stays connected while pongs arrive", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Ping Hermes"); + + const sent: Array = []; + const closes: Array = []; + // The registration only exists after registerConnection returns, but the + // ping loop starts inside it — so the responder waits on this gate rather + // than racing it. + const registrationReady = yield* Deferred.make(); + const pongDelivered = yield* Deferred.make(); + const transport: HermesGatewayTransport = { + send: (message) => + Effect.gen(function* () { + sent.push(message); + if (message.type !== "ping") return; + const registration = yield* Deferred.await(registrationReady); + yield* broker.receive(registration, { + type: "pong", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + sentAt: message.sentAt, + }); + yield* Deferred.succeed(pongDelivered, undefined); + }), + close: (code) => Effect.sync(() => closes.push(code)).pipe(Effect.asVoid), + }; + + const registration = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + transport, + ); + yield* Deferred.succeed(registrationReady, registration); + + yield* TestClock.adjust(Duration.seconds(25)); + yield* Deferred.await(pongDelivered); + assert.isTrue(sent.some((message) => message.type === "ping")); + assert.deepEqual(closes, []); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("marks a half-open connection offline after consecutive missed pongs", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Half Open Hermes"); + + // A half-open socket: writes succeed, nothing ever answers. + const { sent, closes, transport } = recordingTransport(); + yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + transport, + ); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected"); + + const pending = yield* broker + .request(instanceId, { + type: "session.ensure", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make("half-open-request"), + threadId: ThreadId.make("half-open-thread"), + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + // Two probe cycles, each sending a ping that times out unanswered. + yield* TestClock.adjust(Duration.seconds(90)); + + assert.isTrue(sent.filter((message) => message.type === "ping").length >= 2); + assert.deepEqual(closes, [4008]); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "offline"); + // Pending work fails immediately instead of waiting out its own timeout. + const failure = yield* Effect.flip(Fiber.join(pending)); + assert.include(failure.detail, "stopped responding"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("stops pinging once the connection is retired", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Retired Hermes"); + const { sent, transport } = recordingTransport(); + const registration = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + transport, + ); + + yield* broker.disconnect(registration); + const afterDisconnect = sent.length; + // The ping fiber lives in the connection scope, so retiring the connection + // interrupts it rather than leaving it probing a dead socket. + yield* TestClock.adjust(Duration.minutes(2)); + assert.equal(sent.length, afterDisconnect); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("stops the displaced generation's ping loop on reconnect", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Reconnected Hermes"); + const first = recordingTransport(); + const initial = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + first.transport, + ); + const credential = initial.accepted.credential; + if (!credential) return yield* Effect.die(new Error("enrollment did not issue a credential")); + + const replacement = recordingTransport(); + yield* broker.registerConnection( + hello({ type: "instance-credential", instanceId, credential }), + replacement.transport, + ); + const displacedFrameCount = first.sent.length; + + yield* TestClock.adjust(Duration.seconds(11)); + // The new generation starts its liveness probe. The old generation's + // child scope was closed during replacement, so it never probes again. + assert.isTrue(replacement.sent.some((message) => message.type === "ping")); + assert.equal(first.sent.length, displacedFrameCount); + }).pipe(Effect.provide(testLayer)), +); + +// ── Management operations ───────────────────────────────────────── + +it.effect("renames the display label while preserving instance identity", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const settings = yield* ServerSettings.ServerSettingsService; + yield* enroll(broker, instanceId, "Remote Hermes"); + yield* enroll(broker, otherInstanceId, "Other Hermes"); + + const renamed = yield* broker.renameInstance({ + instanceId, + nickname: "Research Hermes", + }); + assert.equal(renamed.instanceId, instanceId); + assert.equal(renamed.nickname, "Research Hermes"); + assert.equal( + (yield* settings.getSettings).providerInstances[instanceId]?.displayName, + "Research Hermes", + ); + + // displayName is a free-text label, exactly like every other provider: + // reusing one is allowed, because the instance id is the identity. + const duplicate = yield* broker.renameInstance({ + instanceId: otherInstanceId, + nickname: " Research Hermes ", + }); + assert.equal(duplicate.instanceId, otherInstanceId); + assert.equal(duplicate.nickname, "Research Hermes"); + assert.equal((yield* broker.getInstanceStatus(instanceId)).nickname, "Research Hermes"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("preserves the ACP provider and concurrent settings edits during companion removal", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const settings = yield* ServerSettings.ServerSettingsService; + yield* enroll(broker, instanceId, "Concurrent Hermes"); + + yield* broker.renameInstance({ instanceId, nickname: "Concurrent Research" }); + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + codex_concurrent: { driver: "codex", displayName: "Concurrent Codex", config: {} }, + }, + })); + + yield* broker.revokeInstance(instanceId); + yield* broker.removeInstance(instanceId); + yield* settings.updateSettingsWith((current) => ({ + providerInstances: { + ...current.providerInstances, + claude_concurrent: { + driver: "claudeAgent", + displayName: "Concurrent Claude", + config: {}, + }, + }, + })); + + const finalSettings = yield* settings.getSettings; + assert.equal( + finalSettings.providerInstances[ProviderInstanceId.make("codex_concurrent")]?.displayName, + "Concurrent Codex", + ); + assert.equal( + finalSettings.providerInstances[ProviderInstanceId.make("claude_concurrent")]?.displayName, + "Concurrent Claude", + ); + assert.equal(finalSettings.providerInstances[instanceId]?.driver, "hermes"); + assert.equal(finalSettings.providerInstances[instanceId]?.displayName, "Concurrent Research"); + assert.deepEqual(finalSettings.providerInstances[instanceId]?.config, {}); + }).pipe(Effect.provide(testLayer)), +); + +// `createEnrollment`'s settings callback no-ops when the instance is gone (or +// changed driver) between its read and its write. Continuing past that meant +// minting a token `registerConnection` can only ever reject — the user is +// handed a `hermes t3 connect …` command that fails with no explanation. +it.effect("fails rather than minting a token when the instance vanishes mid-enrollment", () => + Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + const inner = makeSecretStore(); + // The credential invalidation sits exactly between the read and the write, + // so removing the instance from there lands the race deterministically. + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...inner, + remove: (name) => + settings + .updateSettingsWith((current) => { + const providerInstances = { ...current.providerInstances }; + delete providerInstances[instanceId]; + return { providerInstances }; + }) + .pipe(Effect.ignore, Effect.andThen(inner.remove(name))), + }; + const broker = yield* makeBrokerWith(secrets, settings); + + const error = yield* Effect.flip(enroll(broker, instanceId, "Vanishing Hermes")); + assert.equal(error.operation, "create-enrollment"); + assert.equal(error.code, "instance-not-found"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("requires revocation before removing a companion enrollment", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const broker = yield* makeBroker(secrets); + const settings = yield* ServerSettings.ServerSettingsService; + const enrollment = yield* enroll(broker, instanceId, "Disposable Hermes"); + yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + recordingTransport().transport, + ); + + const liveError = yield* Effect.flip(broker.removeInstance(instanceId)); + assert.equal(liveError.operation, "remove-instance"); + assert.equal(liveError.code, "instance-not-revoked"); + + yield* settings.updateSettingsWith((current) => { + const configured = current.providerInstances[instanceId]; + if (!configured) return {}; + const existingConfig = + configured.config && typeof configured.config === "object" + ? (configured.config as Record) + : {}; + return { + providerInstances: { + ...current.providerInstances, + [instanceId]: { + ...configured, + config: { + ...existingConfig, + binaryPath: "/opt/hermes-acp", + homeThreadId: "home-1", + }, + }, + }, + }; + }); + yield* broker.revokeInstance(instanceId); + assert.deepEqual(yield* broker.removeInstance(instanceId), { instanceId }); + const configured = (yield* settings.getSettings).providerInstances[instanceId]; + assert.equal(configured?.driver, "hermes"); + assert.equal(configured?.displayName, "Disposable Hermes"); + assert.deepEqual(configured?.config, { binaryPath: "/opt/hermes-acp" }); + assert.isFalse( + (yield* broker.listInstances).some((status) => status.instanceId === instanceId), + ); + const missing = yield* Effect.flip(broker.getInstanceStatus(instanceId)); + assert.equal(missing.code, "instance-not-found"); + // Removal drops the credential, so a stale one cannot be replayed. + assert.isTrue(Option.isNone(yield* secrets.get(hermesGatewayCredentialSecretName(instanceId)))); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("leaves an unenrolled Hermes ACP provider configured", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + const settings = yield* ServerSettings.ServerSettingsService; + + assert.deepEqual(yield* broker.removeInstance(instanceId), { instanceId }); + assert.equal((yield* settings.getSettings).providerInstances[instanceId]?.driver, "hermes"); + assert.equal( + (yield* Effect.flip(broker.getInstanceStatus(instanceId))).code, + "instance-not-found", + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("lists only enrolled Hermes instances", () => + Effect.gen(function* () { + const broker = yield* makeBroker(makeSecretStore()); + yield* enroll(broker, instanceId, "Listed Hermes"); + + assert.deepEqual( + (yield* broker.listInstances).map((status) => status.instanceId), + [instanceId], + ); + }).pipe(Effect.provide(testLayer)), +); + +// ── Legacy metadata migration ───────────────────────────────────── + +const legacyBlob = (metadata: Record) => + new TextEncoder().encode(JSON.stringify(metadata)); + +it.effect("migrates legacy metadata into settings and deletes the secret file", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const secretName = hermesGatewayLegacyMetadataSecretName(instanceId); + yield* secrets.set( + secretName, + legacyBlob({ + nickname: "Legacy Remote", + connectorUrl: "https://legacy.example.test/api/hermes-gateway/ws", + revoked: false, + // Liveness from a v1 plugin. Migration must drop it rather than carry + // it into the config envelope. + lastSeen: { + pluginVersion: "0.1.0", + hermesVersion: "0.19.0", + capabilities: { + protocolVersion: 1, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, + }, + connectedAt: "2026-07-24T00:00:00.000Z", + activeSessionCount: 0, + }, + }), + ); + yield* secrets.set( + hermesGatewayCredentialSecretName(instanceId), + new TextEncoder().encode("legacy-credential"), + ); + + const broker = yield* makeBroker(secrets); + const settings = yield* ServerSettings.ServerSettingsService; + + assert.deepEqual((yield* settings.getSettings).providerInstances[instanceId]?.config, { + connectorUrl: "https://legacy.example.test/api/hermes-gateway/ws", + revoked: false, + }); + + const status = yield* broker.getInstanceStatus(instanceId); + assert.equal(status.connectorUrl, "https://legacy.example.test/api/hermes-gateway/ws"); + assert.equal(status.status, "offline"); + // Liveness from the blob is not resurrected. + assert.isNull(status.lastConnectedAt); + assert.isNull(status.protocolVersion); + assert.equal(status.activeSessionCount, 0); + + // The blob is gone, but the credential of a still-configured, unrevoked + // instance is kept so its plugin can reconnect. + assert.isTrue(Option.isNone(yield* secrets.get(secretName))); + assert.isTrue(Option.isSome(yield* secrets.get(hermesGatewayCredentialSecretName(instanceId)))); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("deletes tombstoned legacy metadata and its orphaned credential", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const secretName = hermesGatewayLegacyMetadataSecretName(instanceId); + // Shape taken from a real orphan found on disk: revoked + removed. The old + // code only ever passed this name to get/set, never remove, so tombstones + // leaked for the lifetime of the install. + yield* secrets.set( + secretName, + legacyBlob({ + nickname: "Research Renamed", + connectorUrl: "https://t3.example.test:7446/api/hermes-gateway/ws", + revoked: true, + removed: true, + }), + ); + yield* secrets.set( + hermesGatewayCredentialSecretName(instanceId), + new TextEncoder().encode("orphaned-credential"), + ); + + const broker = yield* makeBroker(secrets); + const settings = yield* ServerSettings.ServerSettingsService; + + assert.isTrue(Option.isNone(yield* secrets.get(secretName))); + assert.isTrue(Option.isNone(yield* secrets.get(hermesGatewayCredentialSecretName(instanceId)))); + // A tombstone contributes nothing to settings, and the id stays reusable. + assert.deepEqual((yield* settings.getSettings).providerInstances[instanceId]?.config, {}); + assert.isFalse( + (yield* broker.listInstances).some((status) => status.instanceId === instanceId), + ); + }).pipe(Effect.provide(testLayer)), +); + +/** + * Orphan cleanup can only be exercised against a real secrets directory: the + * whole point is that these files belong to instances settings no longer + * mentions, so they are unreachable by name and must be discovered by scanning. + * The two blobs below are verbatim copies of orphans found on a real install. + */ +it.effect("discovers and deletes orphaned legacy metadata left on disk", () => { + const removedInstanceId = ProviderInstanceId.make("hermes-first-hermes-demo-7f63c3052018"); + const renamedInstanceId = ProviderInstanceId.make("hermes-research-e4fcf4b39fa1"); + const configLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-hermes-orphan-migration-test-", + }).pipe(Layer.provide(NodeServices.layer)); + + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + + const writeOrphan = (id: ProviderInstanceId, metadata: Record) => + Effect.gen(function* () { + yield* secrets.set(hermesGatewayLegacyMetadataSecretName(id), legacyBlob(metadata)); + yield* secrets.set( + hermesGatewayCredentialSecretName(id), + new TextEncoder().encode(`credential-for-${id}`), + ); + }); + + yield* writeOrphan(removedInstanceId, { + nickname: "First Hermes Demo", + connectorUrl: "https://t3.example.test:7446/api/hermes-gateway/ws", + revoked: true, + lastSeen: { + pluginVersion: "0.1.0", + hermesVersion: "0.19.0", + capabilities: { + protocolVersion: 1, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, + }, + connectedAt: "2026-07-25T01:11:26.467Z", + activeSessionCount: 0, + }, + removed: true, + }); + yield* writeOrphan(renamedInstanceId, { + nickname: "Research Renamed", + connectorUrl: "https://t3.example.test:7446/api/hermes-gateway/ws", + revoked: true, + removed: true, + }); + + // Neither id appears in settings, so name-based lookup alone would miss + // both files entirely. + yield* makeHermesGatewayBroker.pipe( + Effect.provide(ServerSettings.layerTest({ providerInstances: {} })), + ); + + for (const id of [removedInstanceId, renamedInstanceId]) { + assert.isTrue(Option.isNone(yield* secrets.get(hermesGatewayLegacyMetadataSecretName(id)))); + assert.isTrue(Option.isNone(yield* secrets.get(hermesGatewayCredentialSecretName(id)))); + } + const remaining = yield* fs.readDirectory(config.secretsDir); + assert.deepEqual( + remaining.filter((entry) => entry.includes("hermes-gateway")), + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSecretStore.layer.pipe(Layer.provide(configLayer), Layer.provide(NodeServices.layer)), + configLayer, + NodeServices.layer, + ), + ), + ); +}); + +it.effect("keeps legacy metadata when the settings write fails", () => + Effect.gen(function* () { + const storedSecrets = makeSecretStore(); + const secretName = hermesGatewayLegacyMetadataSecretName(instanceId); + yield* storedSecrets.set( + secretName, + legacyBlob({ + nickname: "Deferred Hermes", + connectorUrl: "https://deferred.example.test", + revoked: false, + }), + ); + + // Deleting the blob is conditional on committing its durable equivalent, + // so a settings failure must leave the input intact for the next boot. + let metadataRemovals = 0; + const secrets: ServerSecretStore.ServerSecretStore["Service"] = { + ...storedSecrets, + remove: (name) => + name === secretName + ? Effect.sync(() => { + metadataRemovals += 1; + }).pipe(Effect.andThen(storedSecrets.remove(name))) + : storedSecrets.remove(name), + }; + + const failingSettings = Layer.effect( + ServerSettings.ServerSettingsService, + ServerSettings.layerTest({ providerInstances: hermesInstances }).pipe( + Layer.build, + Effect.map((context) => { + const inner = Context.get(context, ServerSettings.ServerSettingsService); + return { + ...inner, + updateSettingsWith: () => Effect.die(new Error("settings write failed")), + } satisfies ServerSettings.ServerSettingsService["Service"]; + }), + ), + ); + + yield* makeHermesGatewayBroker.pipe( + Effect.provide(failingSettings), + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provide(NodeServices.layer), + Effect.scoped, + ); + + assert.equal(metadataRemovals, 0); + assert.isTrue(Option.isSome(yield* storedSecrets.get(secretName))); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("discards unreadable legacy metadata instead of failing boot", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + const secretName = hermesGatewayLegacyMetadataSecretName(instanceId); + yield* secrets.set(secretName, new TextEncoder().encode("{not-valid-json")); + + const broker = yield* makeBroker(secrets); + assert.isTrue(Option.isNone(yield* secrets.get(secretName))); + // Boot survives; the instance is simply un-enrolled. + assert.equal( + (yield* Effect.flip(broker.getInstanceStatus(instanceId))).code, + "instance-not-found", + ); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("migrates a revoked legacy instance and drops its credential", () => + Effect.gen(function* () { + const secrets = makeSecretStore(); + yield* secrets.set( + hermesGatewayLegacyMetadataSecretName(instanceId), + legacyBlob({ + nickname: "Revoked Legacy", + connectorUrl: "https://revoked.example.test", + revoked: true, + }), + ); + yield* secrets.set( + hermesGatewayCredentialSecretName(instanceId), + new TextEncoder().encode("revoked-credential"), + ); + + const broker = yield* makeBroker(secrets); + assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "revoked"); + assert.isTrue(Option.isNone(yield* secrets.get(hermesGatewayCredentialSecretName(instanceId)))); + + const reconnect = yield* Effect.flip( + broker.registerConnection( + hello({ + type: "instance-credential", + instanceId, + credential: HermesGatewayCredential.make("revoked-credential"), + }), + recordingTransport().transport, + ), + ); + assert.equal(reconnect.code, "invalid-authentication"); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("a delivery connection never displaces the live gateway connection", () => + Effect.gen(function* () { + // The failure this guards: an out-of-process cron run dials in to hand + // over one delivery, `registerConnection` treats it as a replacement, and + // the real plugin gets closed with 4001 — knocking Hermes offline for the + // duration of a cron job. + const broker = yield* makeBroker(makeSecretStore()); + const enrollment = yield* enroll(broker, instanceId, "Remote Hermes"); + const live = recordingTransport(); + const gateway = yield* broker.registerConnection( + hello({ type: "enrollment-token", token: enrollment.oneTimeToken }), + live.transport, + ); + assert.equal(gateway.role, "gateway"); + assert.isTrue(yield* broker.isConnected(instanceId)); + + const credential = gateway.accepted.credential!; + const cron = recordingTransport(); + const delivery = yield* broker.registerConnection( + hello( + { type: "instance-credential", instanceId, credential }, + HERMES_GATEWAY_PROTOCOL_VERSION, + { role: "delivery" }, + ), + cron.transport, + ); + + assert.equal(delivery.role, "delivery"); + // No generation: it was never registered, so there is nothing for it to + // be stale against — and a generation would let it be compared with, and + // mistaken for, the live connection. + assert.equal(delivery.generation, null); + assert.deepEqual(live.closes, [], "the live gateway socket must not be closed"); + assert.isTrue(yield* broker.isConnected(instanceId)); + + // A delivery socket holds no session, so it must not be able to answer + // the live connection's outstanding requests — otherwise a throwaway cron + // socket could speak for the real plugin. + const threadId = ThreadId.make("delivery-fencing-thread"); + const pendingRequestId = HermesGatewayRequestId.make("delivery-fencing-session"); + const pending = yield* broker + .request(instanceId, { + type: "session.ensure", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + yield* broker.receive(delivery, { + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + sessionId: HermesGatewaySessionId.make("delivery-forged-session"), + resumed: false, + }); + yield* Effect.yieldNow; + assert.isUndefined( + pending.pollUnsafe(), + "a delivery connection must not complete the live connection's requests", + ); + + // The live connection still can. + yield* broker.receive(gateway, { + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: pendingRequestId, + threadId, + sessionId: HermesGatewaySessionId.make("real-session"), + resumed: false, + }); + assert.equal((yield* Fiber.join(pending)).type, "session.ready"); + + // Closing the delivery socket is its normal lifecycle and must leave the + // instance's liveness untouched. + yield* broker.disconnect(delivery); + assert.isTrue(yield* broker.isConnected(instanceId)); + assert.deepEqual(live.closes, []); + + yield* broker.revokeInstance(instanceId); + const revokedDelivery = yield* Effect.flip( + broker.withAuthorizedConnection(delivery, Effect.void), + ); + assert.include(revokedDelivery.detail, "no longer authorized"); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/provider/Layers/HermesGatewayBroker.ts b/apps/server/src/provider/Layers/HermesGatewayBroker.ts new file mode 100644 index 000000000000..ed759df7317f --- /dev/null +++ b/apps/server/src/provider/Layers/HermesGatewayBroker.ts @@ -0,0 +1,1526 @@ +/** + * HermesGatewayBroker — enrollment, authentication, and message routing for + * Hermes gateway plugin connections. + * + * State ownership + * --------------- + * The broker splits gateway state three ways, by durability: + * + * - **Credential** → `ServerSecretStore`. A 0600 file is exactly the right + * home for a secret, and nothing else needs to enumerate it. + * + * - **Durable config** (display name, connector URL, revoked) → the Hermes + * provider instance config in `ServerSettings`, written through + * `updateSettingsWith` so the read-modify-write happens under the settings + * write lock and concurrent edits to unrelated instances are preserved. + * This replaced a JSON blob in the secret store, which had no listing API, + * no index, and no transactions — and therefore forced O(N) secret-file + * scans, whole-file rewrites on every connect, and hand-rolled + * compensating writes. + * + * - **Volatile liveness** (connection, transport, lastSeen, session count, + * reported model, upgrade-required) → `HermesConnectionRegistry`, in + * memory only. See that module for why persisting it would stop every live + * Hermes session on every reconnect. + * + * After a T3 restart the UI reports "never connected" until the plugin dials + * back in. That is intended, and is the correct reading of the facts: T3 has + * no live connection until one is re-established. + * + * Locking + * ------- + * There is no global broker lock. Everything that mutates one instance's + * enrollment — `createEnrollment`, `revokeInstance`, `removeInstance`, + * `renameInstance`, and the state-changing half of `registerConnection` — + * serializes on that instance's lock, so a slow credential write during one + * instance's enrollment still cannot block another instance's WebSocket + * handshake. `registerConnection` authenticates *before* taking the lock, so an + * unauthenticated caller cannot queue on it, and then re-validates the durable + * record and the credential once inside — the pre-lock read is only a filter. + * Where two writers could still race within a locked section — token + * redemption, connection replacement — correctness comes from compare-and-swap + * and generation fencing rather than from the mutex. + * + * @module HermesGatewayBroker + */ +import * as NodeCrypto from "node:crypto"; +import { + HERMES_GATEWAY_PROTOCOL_VERSION, + HERMES_DRIVER_KIND, + defaultInstanceIdForDriver, + HermesGatewayCredential, + HermesGatewayCapabilities, + HermesGatewayManagementError, + ProviderInstanceId, + type HermesGatewayConnectionHello, + type HermesGatewayCreateEnrollmentInput, + type HermesGatewayEnrollmentResult, + type HermesGatewayInstanceStatus, + type HermesGatewayPluginToT3Message, + type HermesGatewayRemoveInstanceResult, + type HermesGatewayRenameInstanceInput, + type HermesGatewayRenameInstanceResult, + type HermesGatewayRevokeInstanceResult, + type HermesGatewayT3ToPluginMessage, + type ProviderInstanceConfig, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderAdapterRequestError } from "../Errors.ts"; +import { + HermesGatewayBroker, + type HermesGatewayConnectionRegistration, + type HermesGatewayEnvelope, + type HermesGatewayTransport, + type HermesGatewayBrokerShape, +} from "../Services/HermesGatewayBroker.ts"; +import type { + HermesActiveConnection, + HermesObservedConnection, +} from "../Services/HermesConnectionRegistry.ts"; +import type { PendingEnrollment } from "../Services/HermesEnrollmentStore.ts"; +import { livenessStatusFields, makeHermesConnectionRegistry } from "./HermesConnectionRegistry.ts"; +import { makeHermesEnrollmentStore } from "./HermesEnrollmentStore.ts"; +import { makeRequestCorrelator } from "./RequestCorrelator.ts"; + +const ENROLLMENT_TTL = Duration.minutes(10); +const REQUEST_TIMEOUT = Duration.seconds(30); +/** Safety net for pending requests whose awaiting fiber died abnormally. */ +const REQUEST_MAX_AGE = Duration.seconds(90); +/** How often expired enrollment tokens and abandoned requests are reaped. */ +const SWEEP_INTERVAL = Duration.minutes(1); +/** + * Server→plugin liveness probe. Without it a half-open socket reads as + * "connected" indefinitely: `send` succeeds into the void and the failure only + * surfaces when some unrelated request times out. + * + * These are sized so worst-case detection (first probe immediately, then + * `PING_MAX_MISSED` failures) lands inside `REQUEST_TIMEOUT`. That ordering is + * the whole point: an in-flight request over a dead socket should fail with + * "the connection stopped responding" rather than sit out a generic timeout. + */ +const PING_INTERVAL = Duration.seconds(10); +/** + * How long a single ping may go unanswered before it counts as missed. + * + * Generous on purpose. The plugin answers pings from its socket read loop + * without touching Hermes, so a slow reply means the process is genuinely + * starved rather than merely busy — but a Python event loop under a heavy + * turn can still be slow to schedule, and killing a working connection is far + * worse than noticing a dead one a few seconds later. + */ +const PING_TIMEOUT = Duration.seconds(6); +/** + * Consecutive missed pings tolerated before the connection is torn down. + * + * Worst case detection is `PING_INTERVAL` (the wait before the first probe) + * plus `PING_TIMEOUT * PING_MAX_MISSED`, since a miss re-probes immediately. + * That total must stay under `REQUEST_TIMEOUT` so an in-flight request over a + * dead socket fails with the liveness reason rather than a generic timeout: + * 10 + 6*3 = 28s against a 30s request timeout. + */ +const PING_MAX_MISSED = 3; + +const CREDENTIAL_BYTES = 32; + +const textEncoder = new TextEncoder(); +const isStrictCapabilities = Schema.is(HermesGatewayCapabilities); +const isProviderInstanceId = Schema.is(ProviderInstanceId); + +type PluginMessage = Exclude; +type RequestOwner = string; + +const requestOwner = (instanceId: ProviderInstanceId, generation: number): RequestOwner => + `${instanceId}\0${generation}`; + +/** + * Durable enrollment facts, read from and written to the Hermes provider + * instance config in settings. + */ +interface InstanceRecord { + readonly nickname: string; + readonly connectorUrl: string; + readonly revoked: boolean; +} + +/** + * A Hermes instance can be *configured* (someone added the envelope) without + * being *enrolled* (no plugin has ever been paired). Only enrollment produces + * a connector URL, so its presence is what distinguishes the two. Status, + * rename, and revoke all require an enrolled instance; create-enrollment and + * remove deliberately accept a merely-configured one. + */ +const isEnrolled = (record: InstanceRecord) => record.connectorUrl !== ""; + +const credentialSecretName = (instanceId: ProviderInstanceId) => + `hermes-gateway-credential-${Buffer.from(instanceId, "utf8").toString("base64url")}`; + +/** Legacy metadata blob location, read once at boot then deleted. */ +const legacyMetadataSecretName = (instanceId: ProviderInstanceId) => + `hermes-gateway-metadata-${Buffer.from(instanceId, "utf8").toString("base64url")}`; + +/** + * Schema for the legacy secret-store metadata blob. Retained only so boot can + * migrate pre-existing files into settings and delete them; nothing writes it. + */ +const LegacyInstanceMetadata = Schema.Struct({ + nickname: Schema.String, + connectorUrl: Schema.String, + revoked: Schema.Boolean, + removed: Schema.optionalKey(Schema.Boolean), +}); +type LegacyInstanceMetadata = typeof LegacyInstanceMetadata.Type; +const decodeLegacyMetadata = Schema.decodeUnknownEffect( + // The blob also carried a `lastSeen` liveness object. It is intentionally + // dropped: liveness is no longer persisted at all. + Schema.fromJsonString(LegacyInstanceMetadata), +); + +const managementError = ( + operation: HermesGatewayManagementError["operation"], + code: HermesGatewayManagementError["code"], + message: string, + instanceId?: ProviderInstanceId, +) => + new HermesGatewayManagementError({ + operation, + code, + message, + ...(instanceId ? { instanceId } : {}), + }); + +const rejection = ( + requestId: HermesGatewayConnectionHello["requestId"], + code: Extract["code"], + message: string, +): Extract => ({ + type: "connection.rejected", + requestId, + code, + message, + expectedProtocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, +}); + +const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +/** + * Constant-time credential comparison. The length pre-check is required: + * `timingSafeEqual` throws on mismatched lengths, and comparing lengths first + * leaks only the length, which is not secret. + */ +const credentialsEqual = (left: Uint8Array, right: string) => { + const rightBytes = textEncoder.encode(right); + return left.byteLength === rightBytes.byteLength && NodeCrypto.timingSafeEqual(left, rightBytes); +}; + +const normalizeNickname = (nickname: string) => nickname.trim(); + +const DEFAULT_HERMES_INSTANCE_ID = defaultInstanceIdForDriver(HERMES_DRIVER_KIND); + +/** + * Resolve an explicit Hermes instance, or synthesize the built-in default + * slot from its legacy settings mirror. The provider registry and web settings + * panel expose that same default slot before `providerInstances.hermes` has + * ever been persisted; companion enrollment must accept what those surfaces + * truthfully show. Custom instances still have to be explicit. + */ +const resolveHermesProviderInstance = ( + settings: ServerSettings, + instanceId: ProviderInstanceId, +): ProviderInstanceConfig | undefined => { + const explicit = settings.providerInstances[instanceId]; + if (explicit !== undefined) { + return explicit.driver === HERMES_DRIVER_KIND ? explicit : undefined; + } + if (instanceId !== DEFAULT_HERMES_INSTANCE_ID) return undefined; + return { + driver: HERMES_DRIVER_KIND, + enabled: settings.providers.hermes.enabled, + config: settings.providers.hermes, + }; +}; + +/** Read a Hermes instance's durable config out of a settings envelope. */ +const readHermesConfig = (config: ProviderInstanceConfig | undefined) => { + if (!config || config.driver !== HERMES_DRIVER_KIND) return undefined; + const raw = config.config; + const blob = raw && typeof raw === "object" ? (raw as Record) : {}; + return { + displayName: config.displayName, + connectorUrl: typeof blob.connectorUrl === "string" ? blob.connectorUrl : undefined, + revoked: blob.revoked === true, + }; +}; + +const recordFrom = ( + instanceId: ProviderInstanceId, + config: ProviderInstanceConfig | undefined, +): InstanceRecord | undefined => { + const hermes = readHermesConfig(config); + if (!hermes) return undefined; + return { + nickname: hermes.displayName ?? instanceId, + connectorUrl: hermes.connectorUrl ?? "", + revoked: hermes.revoked, + }; +}; + +/** Merge durable config edits into an existing Hermes envelope. */ +const withHermesConfig = ( + existing: ProviderInstanceConfig, + patch: { + readonly nickname?: string | undefined; + readonly connectorUrl?: string | undefined; + readonly revoked?: boolean | undefined; + }, +): ProviderInstanceConfig => { + const currentConfig = + existing.config && typeof existing.config === "object" + ? (existing.config as Record) + : {}; + return { + ...existing, + ...(patch.nickname !== undefined ? { displayName: patch.nickname } : {}), + config: { + ...currentConfig, + ...(patch.connectorUrl !== undefined ? { connectorUrl: patch.connectorUrl } : {}), + ...(patch.revoked !== undefined ? { revoked: patch.revoked } : {}), + }, + }; +}; + +export const makeHermesGatewayBroker = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const settings = yield* ServerSettingsService; + + const connections = yield* makeHermesConnectionRegistry; + const enrollmentStore = yield* makeHermesEnrollmentStore({ ttl: ENROLLMENT_TTL }); + const correlator = yield* makeRequestCorrelator({ + provider: HERMES_DRIVER_KIND, + timeout: REQUEST_TIMEOUT, + maxAge: REQUEST_MAX_AGE, + }); + + const events = yield* PubSub.unbounded(); + const statusEvents = yield* PubSub.unbounded(); + + /** + * Per-instance enrollment lock. Serializes the read-modify-write of one + * instance's durable config against itself without coupling unrelated + * instances — and, critically, without gating the WebSocket handshake. + */ + const enrollmentLocks = yield* Ref.make(new Map()); + const withInstanceLock = ( + instanceId: ProviderInstanceId, + effect: Effect.Effect, + ) => + Effect.gen(function* () { + const existing = (yield* Ref.get(enrollmentLocks)).get(instanceId); + const semaphore = existing ?? (yield* Semaphore.make(1)); + if (!existing) { + // Two fibers can reach here concurrently for a new instance; the + // modify below keeps whichever landed first so both share one lock. + const winner = yield* Ref.modify(enrollmentLocks, (current) => { + const found = current.get(instanceId); + if (found) return [found, current] as const; + return [semaphore, new Map(current).set(instanceId, semaphore)] as const; + }); + return yield* winner.withPermits(1)(effect); + } + return yield* semaphore.withPermits(1)(effect); + }); + + const readSettings = (operation: HermesGatewayManagementError["operation"]) => + settings.getSettings.pipe( + Effect.mapError(() => + managementError(operation, "internal-error", "Failed to read server settings."), + ), + ); + + /** Durable record for one instance, or `undefined` if not a Hermes instance. */ + const readRecord = ( + instanceId: ProviderInstanceId, + operation: HermesGatewayManagementError["operation"], + ) => + readSettings(operation).pipe( + Effect.map((current) => recordFrom(instanceId, current.providerInstances[instanceId])), + ); + + const requireRecord = ( + instanceId: ProviderInstanceId, + operation: HermesGatewayManagementError["operation"], + ) => + readRecord(instanceId, operation).pipe( + Effect.flatMap((record) => + record && isEnrolled(record) + ? Effect.succeed(record) + : Effect.fail( + managementError( + operation, + "instance-not-found", + record + ? `Hermes gateway instance '${instanceId}' has not been enrolled.` + : `Hermes gateway instance '${instanceId}' is not configured.`, + instanceId, + ), + ), + ), + ); + + /** + * Compose a public status from the durable record plus volatile liveness. + * `revoked` wins over everything: a revoked instance is revoked even if a + * stale connection object has not been reaped yet. + */ + const statusOf = (instanceId: ProviderInstanceId, record: InstanceRecord) => + connections.liveness(instanceId).pipe( + Effect.map((liveness) => { + const fields = livenessStatusFields(liveness); + const { connected, upgradeRequired, capabilities, ...rest } = fields; + return { + ...rest, + // A capability shape from a newer protocol is reported as null + // rather than passed through as if T3 understood it. + capabilities: + capabilities !== null && isStrictCapabilities(capabilities) ? capabilities : null, + instanceId, + nickname: record.nickname, + connectorUrl: record.connectorUrl, + status: record.revoked + ? "revoked" + : upgradeRequired + ? "upgrade-required" + : connected + ? "connected" + : "offline", + } satisfies HermesGatewayInstanceStatus; + }), + ); + + const publishStatus = (instanceId: ProviderInstanceId, record: InstanceRecord) => + statusOf(instanceId, record).pipe( + Effect.flatMap((status) => PubSub.publish(statusEvents, status)), + Effect.asVoid, + ); + + /** Publish using whatever durable record settings currently holds. */ + const publishCurrentStatus = ( + instanceId: ProviderInstanceId, + operation: HermesGatewayManagementError["operation"], + ) => + readRecord(instanceId, operation).pipe( + Effect.flatMap((record) => (record ? publishStatus(instanceId, record) : Effect.void)), + Effect.ignore, + ); + + const failPendingRequests = ( + instanceId: ProviderInstanceId, + generation: number, + detail: string, + ) => correlator.failOwner(requestOwner(instanceId, generation), detail); + + /** + * Tear down a connection: fail its in-flight requests, then close the + * socket. Ordering matters — callers waiting on a request should see the + * specific reason rather than a generic disconnect. + */ + const teardownConnection = ( + instanceId: ProviderInstanceId, + connection: HermesActiveConnection, + code: number, + reason: string, + ) => + failPendingRequests(instanceId, connection.generation, reason).pipe( + Effect.andThen(connection.transport.close(code, reason)), + ); + + // ── Management operations ───────────────────────────────────────── + + const createEnrollment = (input: HermesGatewayCreateEnrollmentInput) => + withInstanceLock( + input.instanceId, + Effect.gen(function* () { + const nickname = normalizeNickname(input.nickname); + const currentSettings = yield* readSettings("create-enrollment"); + const configured = resolveHermesProviderInstance(currentSettings, input.instanceId); + if (!configured) { + return yield* managementError( + "create-enrollment", + "instance-not-found", + `Provider instance '${input.instanceId}' is not configured with the Hermes driver.`, + input.instanceId, + ); + } + + // Invalidate the outgoing credential before anything else: from here + // on the old credential must not authenticate, even if a later step + // fails. + yield* secretStore + .remove(credentialSecretName(input.instanceId)) + .pipe( + Effect.mapError(() => + managementError( + "create-enrollment", + "persistence-failed", + "Failed to invalidate the previous Hermes gateway credential.", + input.instanceId, + ), + ), + ); + + // A live socket authenticated with the credential just invalidated is + // no longer trusted. Retire it before the settings write: if that write + // fails, leaving the old socket registered would preserve indefinite + // access with a credential that can no longer reconnect. + const displaced = yield* connections.clearConnection(input.instanceId); + if (displaced) { + yield* teardownConnection( + input.instanceId, + displaced, + 4001, + "A new enrollment was created for this instance", + ); + } + + const committed = yield* settings + .updateSettingsWith((latest) => { + const latestConfigured = resolveHermesProviderInstance(latest, input.instanceId); + if (!latestConfigured) return {}; + return { + providerInstances: { + ...latest.providerInstances, + [input.instanceId]: withHermesConfig(latestConfigured, { + nickname, + connectorUrl: input.connectorUrl, + revoked: false, + }), + }, + }; + }) + .pipe( + Effect.mapError(() => + managementError( + "create-enrollment", + "persistence-failed", + "Failed to persist the Hermes gateway enrollment.", + input.instanceId, + ), + ), + ); + + // Same committed-settings check `renameInstance` and `removeInstance` + // make, and for the sharper reason: the callback above no-ops when the + // instance vanished (or changed driver) between `readSettings` and the + // write, and continuing would mint a token `registerConnection` can + // only ever reject — handing the user a `hermes t3 connect` command + // that fails with no explanation. Failing here says so instead. + if (!recordFrom(input.instanceId, committed.providerInstances[input.instanceId])) { + return yield* managementError( + "create-enrollment", + "instance-not-found", + `Provider instance '${input.instanceId}' is no longer configured with the Hermes driver.`, + input.instanceId, + ); + } + + const { token, expiresAtMillis } = yield* enrollmentStore.mint({ ...input, nickname }); + + const record: InstanceRecord = { + nickname, + connectorUrl: input.connectorUrl, + revoked: false, + }; + yield* publishStatus(input.instanceId, record); + + return { + instanceId: input.instanceId, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMillis)), + connectorUrl: input.connectorUrl, + command: `hermes t3 connect --url ${shellQuote(input.connectorUrl)} --token ${shellQuote(token)}`, + // The long-lived credential is structurally absent here: it only + // ever exists on the server→plugin `connection.accepted` frame. + oneTimeToken: token, + } satisfies HermesGatewayEnrollmentResult; + }), + ); + + const getInstanceStatus = (instanceId: ProviderInstanceId) => + requireRecord(instanceId, "get-status").pipe( + Effect.flatMap((record) => statusOf(instanceId, record)), + ); + + const listInstances = readSettings("list-instances").pipe( + Effect.flatMap((currentSettings) => + Effect.forEach( + Object.entries(currentSettings.providerInstances).filter( + ([, config]) => config.driver === HERMES_DRIVER_KIND, + ), + ([rawId, config]) => { + const instanceId = rawId as ProviderInstanceId; + const record = recordFrom(instanceId, config); + // Configured-but-never-enrolled instances have no gateway status to + // report, so they are omitted rather than listed as offline. + return record && isEnrolled(record) + ? statusOf(instanceId, record) + : Effect.succeed(undefined); + }, + { concurrency: "unbounded" }, + ).pipe(Effect.map((values) => values.filter((value) => value !== undefined))), + ), + ); + + /** + * Rename is a pure display-name edit. Instance id is identity; the label is + * free text like every other provider, so there is no uniqueness scan. + */ + const renameInstance = (input: HermesGatewayRenameInstanceInput) => + withInstanceLock( + input.instanceId, + Effect.gen(function* () { + const nickname = normalizeNickname(input.nickname); + yield* requireRecord(input.instanceId, "rename-instance").pipe( + Effect.mapError((error) => + managementError("rename-instance", error.code, error.message, input.instanceId), + ), + ); + + const updated = yield* settings + .updateSettingsWith((latest) => { + const latestConfigured = latest.providerInstances[input.instanceId]; + if (!latestConfigured || latestConfigured.driver !== HERMES_DRIVER_KIND) return {}; + return { + providerInstances: { + ...latest.providerInstances, + [input.instanceId]: withHermesConfig(latestConfigured, { nickname }), + }, + }; + }) + .pipe( + Effect.mapError(() => + managementError( + "rename-instance", + "persistence-failed", + "Failed to persist the Hermes instance display name.", + input.instanceId, + ), + ), + ); + + // The update callback is a pure function of `latest`, so the committed + // settings are the single source of truth for whether it applied — + // no side-channel flag needed. + const record = recordFrom(input.instanceId, updated.providerInstances[input.instanceId]); + if (!record) { + return yield* managementError( + "rename-instance", + "instance-not-found", + `Provider instance '${input.instanceId}' is no longer configured with the Hermes driver.`, + input.instanceId, + ); + } + + yield* publishStatus(input.instanceId, record); + return (yield* statusOf( + input.instanceId, + record, + )) satisfies HermesGatewayRenameInstanceResult; + }), + ); + + const revokeInstance = (instanceId: ProviderInstanceId) => + withInstanceLock( + instanceId, + Effect.gen(function* () { + const existing = yield* requireRecord(instanceId, "revoke-instance").pipe( + Effect.mapError((error) => + managementError("revoke-instance", error.code, error.message, instanceId), + ), + ); + + yield* settings + .updateSettingsWith((latest) => { + const latestConfigured = latest.providerInstances[instanceId]; + if (!latestConfigured || latestConfigured.driver !== HERMES_DRIVER_KIND) return {}; + return { + providerInstances: { + ...latest.providerInstances, + [instanceId]: withHermesConfig(latestConfigured, { revoked: true }), + }, + }; + }) + .pipe( + Effect.mapError(() => + managementError( + "revoke-instance", + "persistence-failed", + "Failed to persist the Hermes gateway revocation.", + instanceId, + ), + ), + ); + + const record: InstanceRecord = { ...existing, revoked: true }; + yield* enrollmentStore.forget(instanceId); + + const cleared = yield* connections.clearConnection(instanceId); + if (cleared) { + yield* teardownConnection( + instanceId, + cleared, + 4003, + "The Hermes gateway credential was revoked.", + ); + } + yield* publishStatus(instanceId, record); + + // Credential removal is last and is allowed to fail loudly: revocation + // is already durable in settings, so a reconnect is refused either way. + yield* secretStore + .remove(credentialSecretName(instanceId)) + .pipe( + Effect.mapError(() => + managementError( + "revoke-instance", + "persistence-failed", + "Failed to remove the Hermes gateway credential.", + instanceId, + ), + ), + ); + + return (yield* statusOf(instanceId, record)) satisfies HermesGatewayRevokeInstanceResult; + }), + ); + + const removeInstance = (instanceId: ProviderInstanceId) => + withInstanceLock( + instanceId, + Effect.gen(function* () { + const record = yield* readRecord(instanceId, "remove-instance"); + if (!record) { + return yield* managementError( + "remove-instance", + "instance-not-found", + `Hermes gateway instance '${instanceId}' is not configured or enrolled.`, + instanceId, + ); + } + // An enrolled instance must be revoked first so its credential is + // invalidated before the config that describes it disappears. + if (isEnrolled(record) && !record.revoked) { + return yield* managementError( + "remove-instance", + "instance-not-revoked", + "Revoke the Hermes gateway instance before removing it.", + instanceId, + ); + } + + const updated = yield* settings + .updateSettingsWith((current) => { + const configured = current.providerInstances[instanceId]; + if (!configured || configured.driver !== HERMES_DRIVER_KIND) return {}; + const currentConfig = + configured.config && typeof configured.config === "object" + ? (configured.config as Record) + : {}; + const { + connectorUrl: _connectorUrl, + homeThreadId: _homeThreadId, + revoked: _revoked, + ...remainingConfig + } = currentConfig; + return { + providerInstances: { + ...current.providerInstances, + // Removing the optional companion must not remove the Hermes + // ACP provider. Preserve its ordinary ACP settings while + // clearing enrollment and the companion-owned Home mapping. + // The old thread and its history remain in T3, but a later + // enrollment gets a fresh authoritative Home designation. + [instanceId]: { ...configured, config: remainingConfig }, + }, + }; + }) + .pipe( + Effect.mapError(() => + managementError( + "remove-instance", + "persistence-failed", + "Failed to remove the Hermes companion enrollment from server settings.", + instanceId, + ), + ), + ); + const remaining = readHermesConfig(updated.providerInstances[instanceId]); + const remainingConfig = updated.providerInstances[instanceId]?.config; + const remainingBlob = + remainingConfig && typeof remainingConfig === "object" + ? (remainingConfig as Record) + : {}; + if ( + !remaining || + remaining.connectorUrl !== undefined || + remaining.revoked || + remainingBlob.homeThreadId !== undefined + ) { + return yield* managementError( + "remove-instance", + "persistence-failed", + `Hermes companion enrollment '${instanceId}' could not be removed.`, + instanceId, + ); + } + + yield* enrollmentStore.forget(instanceId); + const cleared = yield* connections.clearConnection(instanceId); + if (cleared) { + yield* teardownConnection( + instanceId, + cleared, + 4003, + "The Hermes gateway instance was removed.", + ); + } + yield* connections.forget(instanceId); + + // Clearing companion config is the authoritative act; a failure to + // delete the now-unreferenced credential is logged, not surfaced. + yield* secretStore.remove(credentialSecretName(instanceId)).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to clean up a removed Hermes companion credential", { + instanceId, + error, + }), + ), + ); + return { instanceId } satisfies HermesGatewayRemoveInstanceResult; + }), + ); + + // ── Connection lifecycle ────────────────────────────────────────── + + /** + * Ping loop for one connection. Forked into the connection's scope, so it is + * interrupted precisely when that connection is retired or replaced. + * + * A missed pong is not immediately fatal — a busy plugin can be slow — but + * `PING_MAX_MISSED` consecutive misses mean the socket is half-open, and we + * stop pretending otherwise. + */ + const pingLoop = ( + registration: HermesGatewayConnectionRegistration, + transport: HermesGatewayTransport, + ) => + Effect.gen(function* () { + if (registration.generation === null) return; + const generation = registration.generation; + const missed = yield* Ref.make(0); + + const probe = Effect.gen(function* () { + const requestId = `hermes-ping-${registration.instanceId}-${generation}-${yield* Clock.currentTimeMillis}`; + const outcome = yield* correlator + .request({ + owner: requestOwner(registration.instanceId, generation), + requestId, + method: "ping", + timeout: PING_TIMEOUT, + send: transport.send({ + type: "ping", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: requestId as HermesGatewayConnectionHello["requestId"], + sentAt: DateTime.formatIso(yield* DateTime.now), + }), + }) + .pipe(Effect.result); + + if (outcome._tag === "Success") { + yield* Ref.set(missed, 0); + // Healthy: wait a full interval before probing again. + return yield* Effect.sleep(PING_INTERVAL); + } + + const missedCount = yield* Ref.updateAndGet(missed, (value) => value + 1); + // Once a ping is missed, re-probe immediately rather than idling for + // another interval: the point is to confirm or clear the suspicion + // fast enough that in-flight requests get the real reason instead of + // a generic timeout. + if (missedCount < PING_MAX_MISSED) return; + + yield* Effect.logWarning("Hermes gateway connection failed liveness checks", { + instanceId: registration.instanceId, + missedCount, + }); + // Retire before closing: `disconnect` is generation-fenced, so if a + // replacement already landed this is a no-op and we leave it alone. + const retired = yield* connections.disconnect(registration); + if (retired) { + yield* publishCurrentStatus(registration.instanceId, "get-status"); + yield* teardownConnection( + registration.instanceId, + retired, + 4008, + "The Hermes gateway connection stopped responding.", + ); + } + // This connection is gone either way — retired here, or already + // replaced. Stop probing rather than interrogating a dead socket and + // logging another miss for a connection nobody is using. + return yield* Effect.interrupt; + }); + + // First probe runs after one interval — long enough that a plugin which + // just completed the handshake is not immediately re-interrogated. The + // pacing of subsequent probes lives inside `probe` itself, because a + // healthy connection and a suspected-dead one warrant different delays. + yield* Effect.sleep(PING_INTERVAL); + yield* probe.pipe(Effect.forever, Effect.ignoreCause({ log: true })); + }); + + /** + * Handshake authentication: prove the caller holds a live credential or a + * live enrollment token, and resolve the instance it is speaking for. + * + * Deliberately a pure read. Nothing here mutates broker state, which is what + * makes it safe to run twice — once before the instance lock is taken, so an + * unauthenticated caller never joins the lock queue, and again inside it, so + * the facts the mutation section acts on were re-read after every competing + * enrollment or revocation had to finish. + */ + const authenticateHello = (hello: HermesGatewayConnectionHello) => + Effect.gen(function* () { + let instanceId: ProviderInstanceId; + let enrollment: PendingEnrollment | undefined; + + if (hello.authentication.type === "enrollment-token") { + const authentication = hello.authentication; + const pending = yield* enrollmentStore.peek(authentication.token); + if (!pending) { + return yield* Effect.fail( + rejection( + hello.requestId, + "enrollment-expired", + "The enrollment token is invalid, expired, or already used.", + ), + ); + } + instanceId = pending.input.instanceId; + enrollment = pending; + } else { + const authentication = hello.authentication; + instanceId = authentication.instanceId; + const stored = yield* secretStore + .get(credentialSecretName(instanceId)) + .pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to read the gateway credential.", + ), + ), + ); + if (Option.isNone(stored) || !credentialsEqual(stored.value, authentication.credential)) { + return yield* Effect.fail( + rejection( + hello.requestId, + "invalid-authentication", + "The Hermes gateway credential is invalid.", + ), + ); + } + } + + const record = yield* readRecord(instanceId, "get-status").pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to validate the Hermes gateway provider instance.", + ), + ), + ); + if (!record) { + return yield* Effect.fail( + rejection( + hello.requestId, + "invalid-authentication", + "The Hermes gateway provider instance is no longer configured.", + ), + ); + } + if (record.revoked) { + return yield* Effect.fail( + rejection( + hello.requestId, + "instance-revoked", + "This Hermes gateway instance has been revoked.", + ), + ); + } + + return { instanceId, enrollment, record } as const; + }); + + const registerConnection = ( + hello: HermesGatewayConnectionHello, + transport: HermesGatewayTransport, + ) => + Effect.gen(function* () { + const strictCapabilities = isStrictCapabilities(hello.capabilities) + ? hello.capabilities + : undefined; + + // ── Phase 1: authenticate, unlocked. Nothing below this line mutates + // connection state, and nothing above it is allowed to. An earlier bug + // applied "incompatible version" state before checking the credential, + // letting an unauthenticated caller knock a healthy instance offline; + // the test "authenticates before applying incompatible connection state" + // guards this ordering. Running it before the lock also keeps a caller + // with a junk credential from queueing behind an in-flight enrollment. + const preliminary = yield* authenticateHello(hello); + + // ── Phase 2: the caller is authenticated. State changes may begin — + // under the instance's enrollment lock, so `createEnrollment`, + // `revokeInstance`, and `removeInstance` cannot interleave with token + // redemption, credential persistence, or acceptance. + // + // Everything inside is bounded and takes no other lock: settings reads + // are a `Ref.get`, and `teardownConnection` only fails correlated + // requests and closes the *displaced* socket — never the one currently + // handshaking, which is not in the registry yet. So the WS accept path + // cannot deadlock against a displaced connection's teardown. + return yield* withInstanceLock( + preliminary.instanceId, + Effect.gen(function* () { + // Re-read every fact behind the lock. The pre-lock pass was only a + // filter: between it and here a `createEnrollment` may have removed + // the credential and superseded the token, or a `revokeInstance` may + // have marked the instance revoked and cleared its connection. Both + // are now visible, and both reject. + const { + instanceId, + enrollment: authenticatedEnrollment, + record, + } = yield* authenticateHello(hello); + if (instanceId !== preliminary.instanceId) { + // The lock we hold is the wrong one, so this cannot proceed + // safely. Only reachable if a token were re-pointed at another + // instance, which minting never does. + return yield* Effect.fail( + rejection( + hello.requestId, + "invalid-authentication", + "The Hermes gateway enrollment changed during the handshake.", + ), + ); + } + + if ( + hello.protocolVersion !== HERMES_GATEWAY_PROTOCOL_VERSION || + strictCapabilities === undefined + ) { + const displaced = yield* connections.markUpgradeRequired({ + instanceId, + upgradeRequired: { + pluginVersion: hello.pluginVersion, + hermesVersion: hello.hermesVersion, + protocolVersion: hello.protocolVersion, + model: hello.model ?? null, + }, + }); + if (displaced) { + yield* teardownConnection( + instanceId, + displaced, + 4004, + "The Hermes gateway plugin requires a protocol upgrade.", + ); + } + yield* publishStatus(instanceId, record); + // Names the fix, not just the mismatch: the rejection text is what + // a v3 (pre-media) plugin's operator sees in its logs. A + // right-version hello can still land here when its capabilities + // don't satisfy the v4 contract (e.g. `attachments: false`), so + // say which it was. + return yield* Effect.fail( + rejection( + hello.requestId, + "version-incompatible", + hello.protocolVersion !== HERMES_GATEWAY_PROTOCOL_VERSION + ? `Expected protocol version ${HERMES_GATEWAY_PROTOCOL_VERSION}, received ${hello.protocolVersion}. Upgrade the T3 Code gateway plugin on the Hermes host to reconnect.` + : `The advertised capabilities do not satisfy the version ${HERMES_GATEWAY_PROTOCOL_VERSION} contract. Upgrade the T3 Code gateway plugin on the Hermes host to reconnect.`, + ), + ); + } + + let credential: HermesGatewayCredential | undefined; + if (hello.authentication.type === "enrollment-token" && authenticatedEnrollment) { + const authentication = hello.authentication; + const consumed = yield* enrollmentStore.consume( + authentication.token, + authenticatedEnrollment, + ); + if (!consumed) { + return yield* Effect.fail( + rejection( + hello.requestId, + "enrollment-expired", + "The enrollment token is invalid, expired, or already used.", + ), + ); + } + credential = HermesGatewayCredential.make( + Encoding.encodeBase64Url( + yield* crypto + .randomBytes(CREDENTIAL_BYTES) + .pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to generate the Hermes gateway credential.", + ), + ), + ), + ), + ); + yield* secretStore + .set(credentialSecretName(instanceId), textEncoder.encode(credential)) + .pipe( + Effect.mapError(() => + rejection( + hello.requestId, + "internal-error", + "Failed to persist the Hermes gateway credential.", + ), + ), + ); + } + const authorizationCredential = + credential ?? + (hello.authentication.type === "instance-credential" + ? hello.authentication.credential + : undefined); + if (authorizationCredential === undefined) { + return yield* Effect.fail( + rejection( + hello.requestId, + "internal-error", + "The Hermes gateway connection has no authenticated credential.", + ), + ); + } + + // ── Delivery connections: authenticated, but never the primary. + // + // An out-of-process cron run dials in only to hand over a + // `home.deliver` and leave. Registering it would displace the + // instance's live gateway socket ("replaced by a newer connection") + // and bump the generation, knocking the real plugin offline for the + // duration of a cron job. So it is accepted and then deliberately + // left out of the connection registry: no generation, no liveness + // ping, no status publish, nothing to disconnect. The existing + // primary is untouched. + if (hello.role === "delivery") { + return { + instanceId, + authorizationCredential, + generation: null, + role: "delivery", + accepted: { + type: "connection.accepted", + requestId: hello.requestId, + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + instanceId, + nickname: record.nickname, + ...(credential ? { credential } : {}), + }, + } as const satisfies HermesGatewayConnectionRegistration; + } + + const observed: HermesObservedConnection = { + pluginVersion: hello.pluginVersion, + hermesVersion: hello.hermesVersion, + capabilities: strictCapabilities, + model: hello.model ?? null, + connectedAt: DateTime.formatIso(yield* DateTime.now), + activeSessionCount: 0, + }; + const accepted = yield* connections.accept({ instanceId, transport, observed }); + if (accepted.displaced) { + yield* teardownConnection( + instanceId, + accepted.displaced, + 4001, + "The Hermes gateway connection was replaced by a newer connection.", + ); + } + + const registration = { + instanceId, + authorizationCredential, + generation: accepted.generation, + role: "gateway", + accepted: { + type: "connection.accepted", + requestId: hello.requestId, + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + instanceId, + nickname: record.nickname, + ...(credential ? { credential } : {}), + }, + } as const satisfies HermesGatewayConnectionRegistration; + + // Liveness probing belongs to this connection's scope, so it is + // interrupted the moment the connection is retired. + const liveness = yield* connections.liveness(instanceId); + if (liveness?.connection?.generation === accepted.generation) { + yield* pingLoop(registration, transport).pipe(Effect.forkIn(liveness.connection.scope)); + } + + yield* publishStatus(instanceId, record); + return registration; + }), + ); + }); + + const authorizationError = (registration: HermesGatewayConnectionRegistration, detail: string) => + new ProviderAdapterRequestError({ + provider: HERMES_DRIVER_KIND, + method: "connection.authorize", + detail: `Hermes gateway instance '${registration.instanceId}' ${detail}`, + }); + + const withAuthorizedConnection: HermesGatewayBrokerShape["withAuthorizedConnection"] = ( + registration, + effect, + ) => + withInstanceLock( + registration.instanceId, + Effect.gen(function* () { + if (registration.role === "gateway") { + const active = yield* connections.connection(registration.instanceId); + if (active?.generation !== registration.generation) { + return yield* authorizationError(registration, "connection is no longer active."); + } + } else { + // Delivery sockets are intentionally absent from the primary + // registry, so they cannot be closed by `clearConnection`. Re-check + // their credential under the same lifecycle lock used by revoke and + // re-enrollment; a socket authenticated before either operation + // cannot submit another delivery after that operation returns. + const record = yield* readRecord(registration.instanceId, "get-status").pipe( + Effect.mapError(() => + authorizationError(registration, "authorization could not be read."), + ), + ); + const stored = yield* secretStore + .get(credentialSecretName(registration.instanceId)) + .pipe( + Effect.mapError(() => + authorizationError(registration, "credential could not be verified."), + ), + ); + if ( + !record || + record.revoked || + Option.isNone(stored) || + !credentialsEqual(stored.value, registration.authorizationCredential) + ) { + return yield* authorizationError(registration, "connection is no longer authorized."); + } + } + }), + ).pipe(Effect.andThen(effect)); + + const receive = (registration: HermesGatewayConnectionRegistration, message: PluginMessage) => + Effect.gen(function* () { + // Delivery operations deliberately retain the lifecycle lock through + // their durable commit so revocation cannot return while an old + // credential is still writing. Primary response frames take this + // generation-fenced fast path instead: a large media fsync must never + // hold up pong processing and make a healthy socket look half-open. + if (registration.role === "delivery" || registration.generation === null) return; + const active = yield* connections.connection(registration.instanceId); + if (active?.generation !== registration.generation) return; + + if (message.type === "connection.status") { + const applied = yield* connections.recordSessionCount( + registration, + message.activeSessionCount, + ); + if (!applied) return; + yield* publishCurrentStatus(registration.instanceId, "get-status"); + } + + if ("requestId" in message && message.requestId) { + yield* correlator.complete( + requestOwner(registration.instanceId, registration.generation), + message.requestId, + message, + ); + } + yield* PubSub.publish(events, { instanceId: registration.instanceId, message }); + }); + + const disconnect = (registration: HermesGatewayConnectionRegistration) => + Effect.gen(function* () { + // Delivery sockets close as a matter of course — that is their whole + // lifecycle. They were never in the registry, so retiring one must not + // touch the instance's liveness or fail the live connection's requests. + if (registration.role === "delivery") return; + const retired = yield* connections.disconnect(registration); + if (!retired) return; + yield* publishCurrentStatus(registration.instanceId, "get-status"); + yield* failPendingRequests( + registration.instanceId, + retired.generation, + "The Hermes gateway connection disconnected.", + ); + }); + + // ── Messaging ───────────────────────────────────────────────────── + + const send = (instanceId: ProviderInstanceId, message: HermesGatewayT3ToPluginMessage) => + connections.transport(instanceId).pipe( + Effect.flatMap((transport) => + transport + ? transport.send(message) + : Effect.fail( + new ProviderAdapterRequestError({ + provider: HERMES_DRIVER_KIND, + method: message.type, + detail: `Hermes gateway instance '${instanceId}' is offline.`, + }), + ), + ), + ); + + const request = (instanceId: ProviderInstanceId, message: HermesGatewayT3ToPluginMessage) => { + if (!("requestId" in message) || !message.requestId) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: HERMES_DRIVER_KIND, + method: message.type, + detail: "A correlated Hermes gateway request requires a request id.", + }), + ); + } + const requestId = message.requestId; + return Effect.gen(function* () { + const connection = yield* connections.connection(instanceId); + if (!connection) { + return yield* new ProviderAdapterRequestError({ + provider: HERMES_DRIVER_KIND, + method: message.type, + detail: `Hermes gateway instance '${instanceId}' is offline.`, + }); + } + return yield* correlator.request({ + owner: requestOwner(instanceId, connection.generation), + requestId, + method: message.type, + // Re-check after the correlator registers the waiter. If replacement + // won before this point, fail immediately; if it wins after this + // check, teardown fails this exact generation's pending requests. + send: connections.connection(instanceId).pipe( + Effect.flatMap((current) => + current?.generation === connection.generation + ? connection.transport.send(message) + : Effect.fail( + new ProviderAdapterRequestError({ + provider: HERMES_DRIVER_KIND, + method: message.type, + detail: `Hermes gateway instance '${instanceId}' reconnected before the request was sent.`, + }), + ), + ), + ), + }); + }); + }; + + // ── Boot: migrate legacy metadata, then start sweepers ──────────── + + const LEGACY_METADATA_PREFIX = "hermes-gateway-metadata-"; + + /** + * Recover instance ids from legacy metadata filenames in the secrets + * directory. `ServerSecretStore` has no listing API, and an orphaned blob is + * by definition one that settings no longer references — so without this + * scan the exact files we most need to clean up are unreachable. + * + * Entirely best-effort: no config, no filesystem, or an unreadable directory + * all degrade to "found nothing" rather than failing boot. + */ + const discoverLegacyMetadataInstanceIds = Effect.gen(function* () { + const config = yield* Effect.serviceOption(ServerConfig.ServerConfig); + const fs = yield* Effect.serviceOption(FileSystem.FileSystem); + if (Option.isNone(config) || Option.isNone(fs)) return []; + + const entries = yield* fs.value + .readDirectory(config.value.secretsDir) + .pipe(Effect.orElseSucceed(() => [] as Array)); + + return entries.flatMap((entry) => { + const base = entry.endsWith(".bin") ? entry.slice(0, -".bin".length) : entry; + if (!base.startsWith(LEGACY_METADATA_PREFIX)) return []; + const encoded = base.slice(LEGACY_METADATA_PREFIX.length); + const decodedId = Buffer.from(encoded, "base64url").toString("utf8"); + // Round-trip guard: only accept names this scheme could have produced. + if (Buffer.from(decodedId, "utf8").toString("base64url") !== encoded) return []; + return isProviderInstanceId(decodedId) ? [decodedId] : []; + }); + }).pipe(Effect.orElseSucceed(() => [] as Array)); + + /** + * Fold pre-existing secret-store metadata blobs into settings and delete + * them. Ordering is deliberate: the settings write must succeed before the + * secret file is deleted, so a crash in between re-runs the migration rather + * than losing the enrollment facts. + */ + const migrateLegacyMetadata = Effect.gen(function* () { + const currentSettings = yield* settings.getSettings; + const defaultInstanceId = defaultInstanceIdForDriver(HERMES_DRIVER_KIND); + const candidates = new Set([ + defaultInstanceId, + ...Object.entries(currentSettings.providerInstances) + .filter(([, config]) => config.driver === HERMES_DRIVER_KIND) + .map(([rawId]) => rawId as ProviderInstanceId), + // The real orphans are precisely the ones settings no longer mentions, + // so scanning the secrets directory is the only way to find them. The + // store has no listing API; reading the directory directly is + // best-effort and never fatal. + ...(yield* discoverLegacyMetadataInstanceIds), + ]); + + for (const instanceId of candidates) { + const secretName = legacyMetadataSecretName(instanceId); + const stored = yield* secretStore.get(secretName); + if (Option.isNone(stored)) continue; + + const decoded = yield* decodeLegacyMetadata(new TextDecoder().decode(stored.value)).pipe( + Effect.result, + ); + if (decoded._tag === "Failure") { + yield* Effect.logWarning("Discarding unreadable legacy Hermes gateway metadata", { + instanceId, + }); + yield* secretStore.remove(secretName).pipe(Effect.ignore); + continue; + } + const metadata: LegacyInstanceMetadata = decoded.success; + + const configured = currentSettings.providerInstances[instanceId]; + const isHermesInstance = configured?.driver === HERMES_DRIVER_KIND; + + // A tombstone, or a blob for an instance nobody configures any more, has + // nothing to fold into. Drop the file and the orphaned credential — this + // is the leak the old code never cleaned up, because the tombstone name + // was only ever passed to get/set and never to remove. + if (metadata.removed || !isHermesInstance) { + yield* secretStore.remove(secretName).pipe(Effect.ignore); + yield* secretStore.remove(credentialSecretName(instanceId)).pipe(Effect.ignore); + continue; + } + + const committed = yield* settings + .updateSettingsWith((latest) => { + const latestConfigured = latest.providerInstances[instanceId]; + if (!latestConfigured || latestConfigured.driver !== HERMES_DRIVER_KIND) return {}; + const existing = readHermesConfig(latestConfigured); + return { + providerInstances: { + ...latest.providerInstances, + [instanceId]: withHermesConfig(latestConfigured, { + // Settings already win where they carry a value; the blob only + // fills gaps. + nickname: latestConfigured.displayName ?? metadata.nickname, + connectorUrl: existing?.connectorUrl ?? metadata.connectorUrl, + revoked: existing?.revoked === true ? true : metadata.revoked, + }), + }, + }; + }) + .pipe(Effect.result); + + if (committed._tag === "Failure") { + yield* Effect.logWarning("Deferring Hermes gateway metadata migration", { instanceId }); + continue; + } + + const migrated = recordFrom(instanceId, committed.success.providerInstances[instanceId]); + if (!migrated) { + yield* Effect.logWarning("Deferring Hermes gateway metadata migration", { instanceId }); + continue; + } + + // Only now, with the durable equivalent committed, is the blob + // redundant. The credential is kept: this instance is still configured + // and its plugin must be able to reconnect. + yield* secretStore.remove(secretName).pipe(Effect.ignore); + if (migrated.revoked) { + yield* secretStore.remove(credentialSecretName(instanceId)).pipe(Effect.ignore); + } + } + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to migrate legacy Hermes gateway metadata", { cause }), + ), + ); + + yield* migrateLegacyMetadata; + + // Bounded memory: expired tokens and abandoned request entries are reaped + // on a timer rather than only lazily at redemption. + yield* enrollmentStore.sweep.pipe( + Effect.andThen(correlator.sweep), + Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); + + return { + createEnrollment, + getInstanceStatus, + listInstances, + renameInstance, + revokeInstance, + removeInstance, + registerConnection, + withAuthorizedConnection, + receive, + disconnect, + request, + send, + isConnected: connections.isConnected, + stream: Stream.fromPubSub(events), + streamStatuses: Stream.fromPubSub(statusEvents), + } satisfies HermesGatewayBrokerShape; +}); + +export const HermesGatewayBrokerLive = Layer.effect(HermesGatewayBroker, makeHermesGatewayBroker); + +/** Exported so tests can seed and assert on migration inputs by exact name. */ +export const hermesGatewayLegacyMetadataSecretName = legacyMetadataSecretName; +export const hermesGatewayCredentialSecretName = credentialSecretName; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0b88191b41d6..93183fa1e686 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -329,6 +329,15 @@ function makeMutableServerSettingsService( yield* PubSub.publish(changes, next); return next; }), + updateSettingsWith: (update) => + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = applyServerSettingsPatch(current, update(current)); + encodeServerSettings(next); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }), get streamChanges() { return Stream.fromPubSub(changes); }, diff --git a/apps/server/src/provider/Layers/RequestCorrelator.test.ts b/apps/server/src/provider/Layers/RequestCorrelator.test.ts new file mode 100644 index 000000000000..ee01f117c962 --- /dev/null +++ b/apps/server/src/provider/Layers/RequestCorrelator.test.ts @@ -0,0 +1,284 @@ +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; + +import { ProviderAdapterRequestError } from "../Errors.ts"; +import { makeRequestCorrelator } from "./RequestCorrelator.ts"; + +const TIMEOUT = Duration.seconds(30); + +const makeCorrelator = () => + makeRequestCorrelator({ + provider: "test", + timeout: TIMEOUT, + maxAge: Duration.seconds(90), + }); + +const owner = "owner-a"; + +it.effect("resolves a correlated response and releases the pending entry", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const pending = yield* correlator + .request({ owner, requestId: "r1", method: "test.method", send: Effect.void }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.equal(yield* correlator.pendingCount, 1); + + assert.isTrue(yield* correlator.complete(owner, "r1", { requestId: "r1" })); + assert.deepEqual(yield* Fiber.join(pending), { requestId: "r1" }); + assert.equal(yield* correlator.pendingCount, 0); + }), +); + +it.effect("refuses a response from a different owner without consuming the waiter", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const pending = yield* correlator + .request({ owner, requestId: "r-fenced", method: "test.method", send: Effect.void }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + assert.isFalse( + yield* correlator.complete("stale-owner", "r-fenced", { requestId: "r-fenced" }), + ); + assert.equal(yield* correlator.pendingCount, 1); + assert.isUndefined(pending.pollUnsafe()); + + assert.isTrue(yield* correlator.complete(owner, "r-fenced", { requestId: "r-fenced" })); + assert.deepEqual(yield* Fiber.join(pending), { requestId: "r-fenced" }); + }), +); + +it.effect("rejects a duplicate request id without stranding the original waiter", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const first = yield* correlator + .request({ owner, requestId: "r-duplicate", method: "first.method", send: Effect.void }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + const duplicate = yield* Effect.flip( + correlator.request({ + owner: "owner-b", + requestId: "r-duplicate", + method: "second.method", + send: Effect.void, + }), + ); + assert.include(duplicate.detail, "already pending"); + assert.equal(yield* correlator.pendingCount, 1); + + yield* correlator.complete(owner, "r-duplicate", { requestId: "r-duplicate" }); + assert.deepEqual(yield* Fiber.join(first), { requestId: "r-duplicate" }); + assert.equal(yield* correlator.pendingCount, 0); + assert.isFalse( + yield* correlator.complete(owner, "r-duplicate", { requestId: "r-duplicate" }), + "a duplicate or late response must not revive a completed request", + ); + }), +); + +it.effect("releases the pending entry when the send fails", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const failure = yield* Effect.flip( + correlator.request({ + owner, + requestId: "r-send-fail", + method: "test.method", + send: Effect.fail( + new ProviderAdapterRequestError({ + provider: "test", + method: "test.method", + detail: "the transport refused the write", + }), + ), + }), + ); + assert.include(failure.detail, "refused"); + assert.equal(yield* correlator.pendingCount, 0); + }), +); + +// The send used to sit outside `restore`, so a transport write that never +// returned could not be interrupted: `Fiber.interrupt` would hang forever +// waiting for the uninterruptible region to finish. The write is now inside the +// restored region, so an interrupt lands — and the entry is still released. +it.effect("interrupts a request whose transport write never returns", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const writeStarted = yield* Deferred.make(); + const neverCompletes = yield* Deferred.make(); + + const pending = yield* correlator + .request({ + owner, + requestId: "r-stalled-write", + method: "test.method", + send: Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(neverCompletes)), + ), + }) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(writeStarted); + assert.equal(yield* correlator.pendingCount, 1); + + // Would never return before the fix. + yield* Fiber.interrupt(pending); + assert.equal( + yield* correlator.pendingCount, + 0, + "an interrupted request must not leave a pending entry behind", + ); + }), +); + +// `Effect.timeout` only wrapped `Deferred.await`, so the clock did not start +// until the write returned: a request over a wedged socket outlived its own +// timeout for as long as the socket stayed wedged. The timeout now covers the +// send too. +it.effect("times out a request whose transport write stalls", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const writeStarted = yield* Deferred.make(); + const neverCompletes = yield* Deferred.make(); + + const pending = yield* correlator + .request({ + owner, + requestId: "r-stalled-timeout", + method: "test.method", + send: Deferred.succeed(writeStarted, undefined).pipe( + Effect.andThen(Deferred.await(neverCompletes)), + ), + }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(writeStarted); + yield* TestClock.adjust(Duration.seconds(31)); + + const outcome = yield* Fiber.join(pending); + assert.equal(outcome._tag, "Failure"); + if (outcome._tag === "Failure") assert.include(outcome.failure.detail, "timed out"); + assert.equal(yield* correlator.pendingCount, 0); + }), +); + +it.effect("sweeps an abandoned request after its maximum age", () => + Effect.gen(function* () { + const correlator = yield* makeRequestCorrelator({ + provider: "test", + timeout: Duration.minutes(5), + maxAge: Duration.seconds(90), + }); + const pending = yield* correlator + .request({ + owner, + requestId: "r-abandoned", + method: "test.method", + send: Effect.void, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.equal(yield* correlator.pendingCount, 1); + + yield* TestClock.adjust(Duration.seconds(91)); + yield* correlator.sweep; + + const failure = yield* Effect.flip(Fiber.join(pending)); + assert.include(failure.detail, "abandoned"); + assert.equal(yield* correlator.pendingCount, 0); + }), +); + +// A response can land the instant the write completes. Registration happens +// uninterruptibly before the send, so `complete` always finds an entry — +// moving the send inside `restore` must not have opened that window. +it.effect("completes a response that arrives from inside the transport write", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const pending = yield* correlator + .request({ + owner, + requestId: "r-immediate", + method: "test.method", + // The transport answers synchronously, before `send` even returns. + send: correlator.complete(owner, "r-immediate", { requestId: "r-immediate" }).pipe( + Effect.flatMap((completed) => + completed + ? Effect.void + : Effect.fail( + new ProviderAdapterRequestError({ + provider: "test", + method: "test.method", + detail: "the response found no pending entry", + }), + ), + ), + ), + }) + .pipe(Effect.forkChild({ startImmediately: true })); + + assert.deepEqual(yield* Fiber.join(pending), { requestId: "r-immediate" }); + assert.equal(yield* correlator.pendingCount, 0); + }), +); + +it.effect("fails every request routed over a dead owner", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const first = yield* correlator + .request({ owner, requestId: "r-own-1", method: "test.method", send: Effect.void }) + .pipe(Effect.forkChild({ startImmediately: true })); + const second = yield* correlator + .request({ owner, requestId: "r-own-2", method: "test.method", send: Effect.void }) + .pipe(Effect.forkChild({ startImmediately: true })); + const other = yield* correlator + .request({ + owner: "owner-b", + requestId: "r-own-3", + method: "test.method", + send: Effect.void, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + + yield* correlator.failOwner(owner, "the connection dropped"); + assert.include((yield* Effect.flip(Fiber.join(first))).detail, "dropped"); + assert.include((yield* Effect.flip(Fiber.join(second))).detail, "dropped"); + assert.isUndefined(other.pollUnsafe(), "another owner's request must be untouched"); + + yield* correlator.complete("owner-b", "r-own-3", { requestId: "r-own-3" }); + assert.deepEqual(yield* Fiber.join(other), { requestId: "r-own-3" }); + }), +); + +it.effect("sweeps an abandoned request after its maximum age", () => + Effect.gen(function* () { + const correlator = yield* makeCorrelator(); + const pending = yield* correlator + .request({ + owner, + requestId: "r-abandoned", + method: "test.method", + send: Effect.void, + timeout: Duration.minutes(10), + }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + assert.equal(yield* correlator.pendingCount, 1); + + yield* TestClock.adjust(Duration.seconds(91)); + yield* correlator.sweep; + + const outcome = yield* Fiber.join(pending); + assert.equal(outcome._tag, "Failure"); + if (outcome._tag === "Failure") assert.include(outcome.failure.detail, "abandoned"); + assert.equal(yield* correlator.pendingCount, 0); + }), +); diff --git a/apps/server/src/provider/Layers/RequestCorrelator.ts b/apps/server/src/provider/Layers/RequestCorrelator.ts new file mode 100644 index 000000000000..816eb20f3fac --- /dev/null +++ b/apps/server/src/provider/Layers/RequestCorrelator.ts @@ -0,0 +1,198 @@ +/** + * Live {@link RequestCorrelator} implementation. + * + * Generic over the owner key (the route a request travels over) and the + * response payload, so this is reusable by any provider with correlated + * request/response framing — see the module docs on the service for why the + * interrupt-safety and sweeping guarantees live here rather than at call sites. + * + * @module Layers/RequestCorrelator + */ +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +import { ProviderAdapterRequestError } from "../Errors.ts"; +import type { RequestCorrelator, RequestCorrelatorSend } from "../Services/RequestCorrelator.ts"; + +interface PendingRequest { + readonly owner: Owner; + readonly method: string; + readonly registeredAtMillis: number; + readonly deferred: Deferred.Deferred; +} + +export interface MakeRequestCorrelatorOptions { + /** Provider name stamped onto `ProviderAdapterRequestError`. */ + readonly provider: string; + /** Default response timeout when a request does not override it. */ + readonly timeout: Duration.Duration; + /** + * Entries older than this are reaped by `sweep`. Comfortably larger than + * `timeout` so the sweeper only ever catches entries whose own timeout + * pipeline failed to run — a normal slow request is never stolen from + * underneath its awaiting fiber. + */ + readonly maxAge: Duration.Duration; +} + +export const makeRequestCorrelator = ( + options: MakeRequestCorrelatorOptions, +): Effect.Effect> => + Effect.gen(function* () { + const pending = yield* Ref.make(new Map>()); + + const requestError = (method: string, detail: string) => + new ProviderAdapterRequestError({ provider: options.provider, method, detail }); + + /** Remove one entry by id. Safe to call when it is already gone. */ + const release = ( + requestId: string, + deferred: Deferred.Deferred, + ) => + Ref.update(pending, (current) => { + // `complete` removes before waking the waiter. A caller may legitimately + // reuse that id after completion but before this waiter's `ensuring` + // runs; only remove the exact generation registered by this request. + if (current.get(requestId)?.deferred !== deferred) return current; + const next = new Map(current); + next.delete(requestId); + return next; + }); + + const request = (input: RequestCorrelatorSend) => + Effect.gen(function* () { + const deferred = yield* Deferred.make(); + const registeredAtMillis = yield* Clock.currentTimeMillis; + + // Registration must not be interruptible independently of the cleanup + // that removes the entry. `uninterruptibleMask` closes that window: the + // registration is applied uninterruptibly — so a response arriving the + // instant the request is written always finds an entry to complete — + // and `ensuring` guarantees release regardless of how the rest exits. + // + // Everything after registration — the send *and* the await — lives + // inside `restore`, with the timeout inside it too. Both placements + // matter: + // + // - Send outside `restore`: a transport write that never returns is + // unkillable, and the response timeout does not start counting + // until the write returns, so a request over a wedged socket hangs + // for as long as the socket does. + // - Timeout outside `restore`: `Effect.timeout` races internally, and + // that race inherits the surrounding uninterruptible region — so an + // interrupt cannot land on the awaiting fiber even though the body + // it wraps was restored. + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const registered = yield* Ref.modify(pending, (current) => { + // Replacing an existing deferred strands its original waiter; + // worse, that waiter's eventual cleanup can then delete the new + // request. Request ids are protocol identities, so collision is + // an explicit failure rather than last-writer-wins state. + if (current.has(input.requestId)) return [false, current] as const; + return [ + true, + new Map(current).set(input.requestId, { + owner: input.owner, + method: input.method, + registeredAtMillis, + deferred, + }), + ] as const; + }); + if (!registered) { + return yield* requestError( + input.method, + `${options.provider} request id '${input.requestId}' is already pending.`, + ); + } + + return yield* restore( + input.send.pipe( + Effect.andThen(Deferred.await(deferred)), + Effect.timeout(input.timeout ?? options.timeout), + ), + ).pipe( + Effect.mapError((error) => + error._tag === "TimeoutError" + ? requestError(input.method, `${options.provider} request timed out.`) + : error, + ), + Effect.ensuring(release(input.requestId, deferred)), + ); + }), + ); + }); + + const complete = (owner: Owner, requestId: string, response: Response) => + Ref.modify(pending, (current) => { + const found = current.get(requestId); + // A request id identifies a request only within the connection that + // owns it. A stale/replaced connection must not be able to satisfy a + // newer generation's waiter by replaying the same id. + if (!found || found.owner !== owner) return [undefined, current] as const; + const next = new Map(current); + next.delete(requestId); + return [found, next] as const; + }).pipe( + Effect.flatMap((found) => + found === undefined + ? Effect.succeed(false) + : Deferred.succeed(found.deferred, response).pipe(Effect.as(true)), + ), + ); + + /** + * Extract every entry matching `select`, then fail their deferreds. The + * extraction is a single `Ref.modify` so two concurrent failures cannot + * both claim the same entry. + */ + const failMatching = ( + select: (entry: PendingRequest) => boolean, + detail: (entry: PendingRequest) => string, + ) => + Ref.modify(pending, (current) => { + const claimed: Array> = []; + const next = new Map(current); + for (const [requestId, entry] of current) { + if (!select(entry)) continue; + claimed.push(entry); + next.delete(requestId); + } + return [claimed, next] as const; + }).pipe( + Effect.flatMap((claimed) => + Effect.forEach( + claimed, + (entry) => Deferred.fail(entry.deferred, requestError(entry.method, detail(entry))), + { discard: true }, + ), + ), + ); + + const failOwner = (owner: Owner, detail: string) => + failMatching( + (entry) => entry.owner === owner, + () => detail, + ); + + const sweep = Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => + failMatching( + (entry) => now - entry.registeredAtMillis >= Duration.toMillis(options.maxAge), + () => `${options.provider} request was abandoned before it completed.`, + ), + ), + ); + + return { + request, + complete, + failOwner, + sweep, + pendingCount: Ref.get(pending).pipe(Effect.map((current) => current.size)), + } satisfies RequestCorrelator; + }); diff --git a/apps/server/src/provider/Layers/StandardAcpAdapter.attachments.test.ts b/apps/server/src/provider/Layers/StandardAcpAdapter.attachments.test.ts new file mode 100644 index 000000000000..1129088e1724 --- /dev/null +++ b/apps/server/src/provider/Layers/StandardAcpAdapter.attachments.test.ts @@ -0,0 +1,48 @@ +import type { ChatAttachment } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { toAcpAttachmentContentBlock } from "./StandardAcpAdapter.ts"; + +describe("StandardAcpAdapter attachment content", () => { + it("keeps images as inline ACP image blocks", () => { + const attachment: ChatAttachment = { + type: "image", + id: "thread-1-image-1", + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 3, + }; + + expect( + toAcpAttachmentContentBlock({ + attachment, + attachmentPath: "/tmp/diagram.png", + bytes: Uint8Array.from([1, 2, 3]), + }), + ).toEqual({ type: "image", data: "AQID", mimeType: "image/png" }); + }); + + it("maps non-images to MIME-typed ACP resource links", () => { + const attachment: ChatAttachment = { + type: "file", + id: "thread-1-file-1", + name: "release notes.pdf", + mimeType: "application/pdf", + sizeBytes: 4, + }; + + expect( + toAcpAttachmentContentBlock({ + attachment, + attachmentPath: "/tmp/release notes.pdf", + bytes: Uint8Array.from([1, 2, 3, 4]), + }), + ).toEqual({ + type: "resource_link", + uri: "file:///tmp/release%20notes.pdf", + name: "release notes.pdf", + mimeType: "application/pdf", + size: 4, + }); + }); +}); diff --git a/apps/server/src/provider/Layers/StandardAcpAdapter.ts b/apps/server/src/provider/Layers/StandardAcpAdapter.ts index 767c08e8972a..186114c4a995 100644 --- a/apps/server/src/provider/Layers/StandardAcpAdapter.ts +++ b/apps/server/src/provider/Layers/StandardAcpAdapter.ts @@ -1,5 +1,6 @@ import { ApprovalRequestId, + type ChatAttachment, EventId, type ProviderApprovalDecision, type ProviderRuntimeEvent, @@ -28,6 +29,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as NodeURL from "node:url"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -270,6 +272,27 @@ export function standardAcpPromptSettlementBelongsToContext(input: { ); } +/** Map T3's MIME-typed attachment union to ACP's corresponding content block. */ +export function toAcpAttachmentContentBlock(input: { + readonly attachment: ChatAttachment; + readonly attachmentPath: string; + readonly bytes: Uint8Array; +}): EffectAcpSchema.ContentBlock { + return input.attachment.type === "image" + ? { + type: "image", + data: Buffer.from(input.bytes).toString("base64"), + mimeType: input.attachment.mimeType, + } + : { + type: "resource_link", + uri: NodeURL.pathToFileURL(input.attachmentPath).href, + name: input.attachment.name, + mimeType: input.attachment.mimeType, + size: input.bytes.byteLength, + }; +} + export function makeStandardAcpAdapter( config: StandardAcpAdapterConfig, options?: StandardAcpAdapterLiveOptions, @@ -1025,7 +1048,7 @@ export function makeStandardAcpAdapter Effect.gen(function* () { @@ -1051,16 +1074,12 @@ export function makeStandardAcpAdapter = [ ...(text ? [{ type: "text" as const, text }] : []), - ...imagePromptParts, + ...attachmentPromptParts, ]; if (promptParts.length === 0) { diff --git a/apps/server/src/provider/Services/HermesConnectionRegistry.ts b/apps/server/src/provider/Services/HermesConnectionRegistry.ts new file mode 100644 index 000000000000..3f5adb1406b1 --- /dev/null +++ b/apps/server/src/provider/Services/HermesConnectionRegistry.ts @@ -0,0 +1,172 @@ +/** + * HermesConnectionRegistry — owner of **volatile** Hermes gateway liveness. + * + * Everything in here is deliberately memory-only and is never persisted: + * the live connection, its transport, `lastSeen`, `activeSessionCount`, the + * reported model, and the `upgrade-required` observation. + * + * Why liveness must not be persisted + * ---------------------------------- + * `ProviderInstanceRegistryLive.reconcile` structurally compares each + * `ProviderInstanceConfig` envelope and closes the instance scope on **any** + * change. Closing that scope runs `HermesAdapter`'s finalizer, which calls + * `stopAll()` and sends `session.stop` to Hermes for every live thread. So + * writing something like `connectedAt` into the instance config on every + * connect would tear down every Hermes session on every reconnect. + * + * The broker (and therefore this registry) is a top-level layer that survives + * reconcile, which makes it the correct owner of liveness. The accepted + * consequence is that after a T3 server restart the UI reports "never + * connected" until the plugin dials back in. That is intended. + * + * Generation fencing + * ------------------ + * Every accepted connection gets a monotonically increasing generation. All + * post-acceptance mutations are fenced on it, and the comparison happens + * *inside* the `Ref` update rather than as a separate read, so a replacement + * landing concurrently cannot be clobbered by a stale writer (TOCTOU). + * + * @module HermesConnectionRegistry + */ +import type { + HermesGatewayCapabilities, + HermesGatewayInstanceStatus, + ProviderInstanceId, +} from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +import type { + HermesGatewayConnectionRegistration, + HermesGatewayTransport, +} from "./HermesGatewayBroker.ts"; + +/** Observed facts about a plugin connection, independent of its transport. */ +export interface HermesObservedConnection { + readonly pluginVersion: string; + readonly hermesVersion: string; + readonly capabilities: HermesGatewayCapabilities; + /** Model reported at handshake. Null when the plugin predates the field. */ + readonly model: string | null; + readonly connectedAt: string; + readonly activeSessionCount: number; +} + +export interface HermesActiveConnection extends HermesObservedConnection { + readonly generation: number; + readonly transport: HermesGatewayTransport; + /** + * Scope tied to this connection's lifetime. Per-connection fibers (the ping + * loop) are forked into it, so they die exactly when the connection does. + */ + readonly scope: Scope.Closeable; +} + +export interface HermesUpgradeRequired { + readonly pluginVersion: string; + readonly hermesVersion: string; + readonly protocolVersion: number; + readonly model: string | null; +} + +/** Volatile per-instance liveness. Absent entry means "nothing observed yet". */ +export interface HermesInstanceLiveness { + readonly connection?: HermesActiveConnection | undefined; + readonly lastSeen?: HermesObservedConnection | undefined; + readonly upgradeRequired?: HermesUpgradeRequired | undefined; +} + +export interface HermesAcceptedConnection { + readonly generation: number; + /** + * The connection this one displaced, if any. The caller is responsible for + * closing it — the registry never touches a transport itself, so callers can + * order the close against failing pending requests. + */ + readonly displaced: HermesActiveConnection | undefined; +} + +export interface HermesConnectionRegistry { + /** Current liveness for one instance, or `undefined` if nothing observed. */ + readonly liveness: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** Accept a connection, assigning it the next generation. */ + readonly accept: (input: { + readonly instanceId: ProviderInstanceId; + readonly transport: HermesGatewayTransport; + readonly observed: HermesObservedConnection; + }) => Effect.Effect; + + /** + * Record an incompatible plugin. Clears any live connection for the instance + * and returns it so the caller can close it. + */ + readonly markUpgradeRequired: (input: { + readonly instanceId: ProviderInstanceId; + readonly upgradeRequired: HermesUpgradeRequired; + }) => Effect.Effect; + + /** + * Update the active session count. Generation-fenced; returns `false` when + * the registration is stale, in which case nothing was written. + */ + readonly recordSessionCount: ( + registration: HermesGatewayConnectionRegistration, + activeSessionCount: number, + ) => Effect.Effect; + + /** + * Retire a connection, demoting it to `lastSeen`. Generation-fenced; + * returns the retired connection, or `undefined` when the registration is + * stale (already replaced) and nothing was changed. + */ + readonly disconnect: ( + registration: HermesGatewayConnectionRegistration, + ) => Effect.Effect; + + /** + * Clear the live connection for an instance regardless of generation, e.g. + * on revoke. Returns it so the caller can close the transport. + */ + readonly clearConnection: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** Drop all liveness for an instance (on remove). */ + readonly forget: (instanceId: ProviderInstanceId) => Effect.Effect; + + /** Whether a live connection exists. */ + readonly isConnected: (instanceId: ProviderInstanceId) => Effect.Effect; + + /** Current active connection, including its generation, if connected. */ + readonly connection: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + + /** Live transport for an instance, if connected. */ + readonly transport: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; +} + +/** + * Project the status fields owned by liveness. The durable half (nickname, + * connectorUrl, revoked) is layered on by the broker, which reads it from + * settings. + */ +export type HermesLivenessStatusFields = Pick< + HermesGatewayInstanceStatus, + | "lastConnectedAt" + | "pluginVersion" + | "hermesVersion" + | "model" + | "activeSessionCount" + | "protocolVersion" + | "capabilities" + | "connectionGeneration" +> & { + readonly connected: boolean; + readonly upgradeRequired: boolean; +}; diff --git a/apps/server/src/provider/Services/HermesEnrollmentStore.ts b/apps/server/src/provider/Services/HermesEnrollmentStore.ts new file mode 100644 index 000000000000..ebcbdd134ee9 --- /dev/null +++ b/apps/server/src/provider/Services/HermesEnrollmentStore.ts @@ -0,0 +1,68 @@ +/** + * HermesEnrollmentStore — the mint/consume/expire lifecycle of one-time + * enrollment tokens. + * + * Separated from the broker so the token rules live in one place: + * + * - A token is redeemable exactly once. Consumption is compare-and-swap: + * the entry is deleted only if it is still byte-identical to the one the + * caller authenticated against, and expiry is re-checked afterwards, so + * two concurrent redemptions cannot both win. + * - Minting a token for an instance invalidates that instance's previous + * tokens, so only the newest unconsumed token is ever redeemable. + * - Tokens expire from memory on a sweep, not only lazily at redemption. + * Redemption-time expiry alone let any operator-scoped client grow the + * map without bound by calling createEnrollment in a loop. + * + * @module HermesEnrollmentStore + */ +import type { + HermesGatewayCreateEnrollmentInput, + HermesGatewayEnrollmentToken, + ProviderInstanceId, +} from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; + +export interface PendingEnrollment { + readonly input: HermesGatewayCreateEnrollmentInput; + readonly expiresAtMillis: number; +} + +export interface HermesEnrollmentStore { + /** + * Mint a token for `input`, invalidating any prior unconsumed token for the + * same instance. Returns the token and its absolute expiry. + */ + readonly mint: ( + input: HermesGatewayCreateEnrollmentInput, + ) => Effect.Effect< + { readonly token: HermesGatewayEnrollmentToken; readonly expiresAtMillis: number }, + never + >; + + /** + * Look up a token without consuming it. Returns `undefined` when the token + * is unknown or already expired — the caller authenticates against this + * value and later passes it back to `consume`. + */ + readonly peek: (token: string) => Effect.Effect; + + /** + * Compare-and-swap consumption. Deletes the entry only if it is still + * identical to `expected`, then re-checks expiry. Returns the consumed + * enrollment, or `undefined` if it was raced away or expired in between. + */ + readonly consume: ( + token: string, + expected: PendingEnrollment, + ) => Effect.Effect; + + /** Drop every unconsumed token belonging to `instanceId`. */ + readonly forget: (instanceId: ProviderInstanceId) => Effect.Effect; + + /** Drop every expired token. Run periodically; bounds memory. */ + readonly sweep: Effect.Effect; + + /** Number of unconsumed tokens. Exposed for tests and diagnostics. */ + readonly size: Effect.Effect; +} diff --git a/apps/server/src/provider/Services/HermesGatewayBroker.ts b/apps/server/src/provider/Services/HermesGatewayBroker.ts new file mode 100644 index 000000000000..981b98cb0e5a --- /dev/null +++ b/apps/server/src/provider/Services/HermesGatewayBroker.ts @@ -0,0 +1,141 @@ +import type { + HermesGatewayConnectionHello, + HermesGatewayConnectionRole, + HermesGatewayCredential, + HermesGatewayCreateEnrollmentInput, + HermesGatewayEnrollmentResult, + HermesGatewayInstanceStatus, + HermesGatewayPluginToT3Message, + HermesGatewayRemoveInstanceResult, + HermesGatewayRenameInstanceInput, + HermesGatewayRenameInstanceResult, + HermesGatewayRevokeInstanceResult, + HermesGatewayT3ToPluginMessage, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import type { HermesGatewayManagementError } from "@t3tools/contracts"; +import type { ProviderAdapterRequestError } from "../Errors.ts"; + +export interface HermesGatewayTransport { + readonly send: ( + message: HermesGatewayT3ToPluginMessage, + ) => Effect.Effect; + readonly close: (code: number, reason: string) => Effect.Effect; +} + +export interface HermesGatewayConnectionRegistration { + readonly instanceId: ProviderInstanceId; + /** Credential authenticated (or minted) for this socket. Never serialized. */ + readonly authorizationCredential: HermesGatewayCredential; + /** + * Fencing token for the instance's primary connection, or `null` for a + * delivery connection. + * + * A delivery connection is deliberately outside the primary generation + * scheme: it cannot displace the live gateway. Its credential is still + * revalidated under the lifecycle lock for every durable operation. + */ + readonly generation: number | null; + /** + * What this socket registered as. `delivery` sockets may only hand over + * proactive home/media deliveries; they carry no session, take no ping loop, + * and their disconnect must not disturb the instance's liveness state. + */ + readonly role: HermesGatewayConnectionRole; + readonly accepted: Extract< + HermesGatewayT3ToPluginMessage, + { readonly type: "connection.accepted" } + >; +} + +export interface HermesGatewayEnvelope { + readonly instanceId: ProviderInstanceId; + readonly message: Exclude; +} + +export interface HermesGatewayBrokerShape { + readonly createEnrollment: ( + input: HermesGatewayCreateEnrollmentInput, + ) => Effect.Effect; + readonly getInstanceStatus: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + readonly listInstances: Effect.Effect< + ReadonlyArray, + HermesGatewayManagementError + >; + readonly renameInstance: ( + input: HermesGatewayRenameInstanceInput, + ) => Effect.Effect; + readonly revokeInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + readonly removeInstance: ( + instanceId: ProviderInstanceId, + ) => Effect.Effect; + readonly registerConnection: ( + hello: HermesGatewayConnectionHello, + transport: HermesGatewayTransport, + ) => Effect.Effect< + HermesGatewayConnectionRegistration, + Extract + >; + /** + * Run one post-handshake operation while holding the instance lifecycle + * lock. Revocation/re-enrollment therefore linearizes against both primary + * and short-lived delivery sockets instead of only closing the registered + * primary connection. + */ + readonly withAuthorizedConnection: ( + registration: HermesGatewayConnectionRegistration, + effect: Effect.Effect, + ) => Effect.Effect; + readonly receive: ( + registration: HermesGatewayConnectionRegistration, + message: Exclude, + ) => Effect.Effect; + readonly disconnect: (registration: HermesGatewayConnectionRegistration) => Effect.Effect; + readonly request: ( + instanceId: ProviderInstanceId, + message: HermesGatewayT3ToPluginMessage, + ) => Effect.Effect< + Exclude, + ProviderAdapterRequestError + >; + readonly send: ( + instanceId: ProviderInstanceId, + message: HermesGatewayT3ToPluginMessage, + ) => Effect.Effect; + readonly isConnected: (instanceId: ProviderInstanceId) => Effect.Effect; + readonly stream: Stream.Stream; + readonly streamStatuses: Stream.Stream; +} + +const unavailable = () => Effect.die(new Error("HermesGatewayBroker live layer is not installed")); + +export const HermesGatewayBroker = Context.Reference( + "t3/provider/Services/HermesGatewayBroker", + { + defaultValue: () => ({ + createEnrollment: unavailable, + getInstanceStatus: unavailable, + listInstances: unavailable(), + renameInstance: unavailable, + revokeInstance: unavailable, + removeInstance: unavailable, + registerConnection: unavailable, + withAuthorizedConnection: (_registration, _effect) => unavailable(), + receive: unavailable, + disconnect: unavailable, + request: unavailable, + send: unavailable, + isConnected: () => Effect.succeed(false), + stream: Stream.empty, + streamStatuses: Stream.empty, + }), + }, +); diff --git a/apps/server/src/provider/Services/RequestCorrelator.ts b/apps/server/src/provider/Services/RequestCorrelator.ts new file mode 100644 index 000000000000..b5d00facf8d7 --- /dev/null +++ b/apps/server/src/provider/Services/RequestCorrelator.ts @@ -0,0 +1,33 @@ +/** + * Generic request/response correlation for providers with request IDs. + * + * The correlator owns interrupt-safe cleanup, timeout handling, stale-entry + * sweeping, and failing every pending request for a disconnected owner. + */ +import type * as Duration from "effect/Duration"; +import type * as Effect from "effect/Effect"; + +import type { ProviderAdapterRequestError } from "../Errors.ts"; + +export interface RequestCorrelatorSend { + readonly owner: Owner; + readonly requestId: string; + readonly method: string; + readonly send: Effect.Effect; + readonly timeout?: Duration.Duration | undefined; +} + +export interface RequestCorrelator { + readonly request: ( + input: RequestCorrelatorSend, + ) => Effect.Effect; + /** Complete only when the response arrived over the request's exact owner. */ + readonly complete: ( + owner: Owner, + requestId: string, + response: Response, + ) => Effect.Effect; + readonly failOwner: (owner: Owner, detail: string) => Effect.Effect; + readonly sweep: Effect.Effect; + readonly pendingCount: Effect.Effect; +} diff --git a/apps/server/src/provider/hermesGatewayHttp.test.ts b/apps/server/src/provider/hermesGatewayHttp.test.ts new file mode 100644 index 000000000000..74f3a5fb6ee6 --- /dev/null +++ b/apps/server/src/provider/hermesGatewayHttp.test.ts @@ -0,0 +1,562 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + HERMES_GATEWAY_PROTOCOL_VERSION, + HERMES_MEDIA_MAX_BYTES, + HermesGatewayCredential, + HermesGatewayDeliveryId, + HermesGatewayRequestId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type HermesGatewayMediaDeliver, + type HermesGatewayT3ToPluginMessage, + type OrchestrationCommand, +} from "@t3tools/contracts"; +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 Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { HermesGatewayConnectionRegistration } from "./Services/HermesGatewayBroker.ts"; +import { makeHermesDeliveryHandlers } from "./hermesGatewayHttp.ts"; + +const INSTANCE_ID = ProviderInstanceId.make("hermes-media-test"); +const HOME_THREAD_ID = ThreadId.make("thread-home-media"); +const SESSION_THREAD_ID = ThreadId.make("thread-live-turn"); +const AGENT_PROJECT_ID = ProjectId.make("project-hermes-media"); +const CREATED_AT = "2026-07-27T09:00:00.000Z"; + +const registration: HermesGatewayConnectionRegistration = { + instanceId: INSTANCE_ID, + authorizationCredential: HermesGatewayCredential.make("test-credential"), + generation: 1, + role: "gateway", + accepted: { + type: "connection.accepted", + requestId: HermesGatewayRequestId.make("accept-1"), + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + instanceId: INSTANCE_ID, + nickname: "Hermes Media", + }, +}; + +const mediaFrame = ( + overrides: Partial = {}, +): HermesGatewayMediaDeliver => + ({ + type: "media.deliver", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: HermesGatewayDeliveryId.make("media-delivery-1"), + threadId: HOME_THREAD_ID, + kind: "cron", + label: "Cron: daily-digest", + name: "chart.png", + mimeType: "image/png", + sizeBytes: 8, + caption: "Today's chart", + data: Buffer.from("PNGBYTES").toString("base64"), + createdAt: CREATED_AT, + ...overrides, + }) as HermesGatewayMediaDeliver; + +const makeHarness = (options?: { + readonly trackedThreadId?: ThreadId; + readonly failDispatch?: boolean; + /** Archive state of the designated home thread, as the projection sees it. */ + readonly homeThreadArchivedAt?: string; +}) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-hermes-media-attachments-", + }); + const dispatched = yield* Ref.make>([]); + const receivedCommandIds = yield* Ref.make(new Set()); + const sent: Array = []; + const transport = { + send: (frame: HermesGatewayT3ToPluginMessage) => + Effect.sync(() => sent.push(frame)).pipe(Effect.asVoid), + }; + + const engineLayer = Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: (command) => + Ref.modify(receivedCommandIds, (seen) => { + if (seen.has(command.commandId)) return [false, seen] as const; + return [true, new Set([...seen, command.commandId])] as const; + }).pipe( + Effect.tap((isFirst) => + isFirst ? Ref.update(dispatched, (commands) => [...commands, command]) : Effect.void, + ), + Effect.andThen( + options?.failDispatch + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: "thread.notification.deliver", + detail: "Simulated dispatch failure.", + }), + ) + : Effect.succeed({ sequence: 1 }), + ), + ), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }); + + const queryLayer = Layer.mock(ProjectionSnapshotQuery)({ + // The home thread is designated in settings and exists, so home-thread + // resolution takes the fast path without dispatching a thread.create. + getThreadArchiveStateById: (threadId) => + Effect.succeed( + threadId === HOME_THREAD_ID + ? Option.some({ + projectId: AGENT_PROJECT_ID, + archivedAt: options?.homeThreadArchivedAt ?? null, + }) + : Option.none(), + ), + getThreadShellById: (threadId) => + Effect.succeed( + options?.trackedThreadId === threadId + ? Option.some({ + id: threadId, + projectId: AGENT_PROJECT_ID, + modelSelection: { instanceId: INSTANCE_ID, model: "hermes" }, + session: { status: "ready" }, + archivedAt: null, + } as never) + : Option.none(), + ), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + Option.some({ + id: AGENT_PROJECT_ID, + title: "Hermes Media", + workspaceRoot: "/tmp/t3-hermes-media/agents/hermes-media-test", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + deletedAt: null, + }), + ), + }); + + const settingsLayer = ServerSettings.layerTest({ + providerInstances: { + [INSTANCE_ID]: { + driver: "hermes", + displayName: "Hermes Media", + config: { homeThreadId: HOME_THREAD_ID }, + }, + }, + } as never); + + // Only `attachmentsDir` is read off the config by the handlers. + const configLayer = Layer.succeed(ServerConfig, { + baseDir: "/tmp/t3-hermes-media", + attachmentsDir, + } as unknown as ServerConfig["Service"]); + + // Provided at invocation too: the handlers resolve the home thread at + // delivery time, which pulls services from the runtime context. + const servicesLayer = Layer.mergeAll(engineLayer, queryLayer, settingsLayer, configLayer).pipe( + Layer.provideMerge(NodeServices.layer), + ); + + const handlers = yield* makeHermesDeliveryHandlers().pipe(Effect.provide(servicesLayer)); + const deliverMedia = (...args: Parameters) => + handlers.deliverMedia(...args).pipe(Effect.provide(servicesLayer), Effect.orDie); + const deliverHomeNotification = ( + ...args: Parameters + ) => + handlers.deliverHomeNotification(...args).pipe(Effect.provide(servicesLayer), Effect.orDie); + const createHandoffThread = (...args: Parameters) => + handlers.createHandoffThread(...args).pipe(Effect.provide(servicesLayer), Effect.orDie); + + return { + deliverMedia, + deliverHomeNotification, + createHandoffThread, + dispatched, + sent, + transport, + attachmentsDir, + } as const; + }); + +const dispatchedDeliveries = (commands: ReadonlyArray) => + commands.filter( + (command): command is Extract => + command.type === "thread.notification.deliver", + ); + +it.effect("creates handoff threads idempotently under the instance's agent project", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const request = { + type: "handoff.create" as const, + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make("handoff-request-1"), + parentThreadId: HOME_THREAD_ID, + name: "Hermes — release prep", + }; + + yield* harness.createHandoffThread(registration, request, harness.transport); + yield* harness.createHandoffThread(registration, request, harness.transport); + + const creates = (yield* Ref.get(harness.dispatched)).filter( + (command) => command.type === "thread.create", + ); + assert.equal(creates.length, 1); + const created = creates[0]!; + assert.equal(created.projectId, AGENT_PROJECT_ID); + assert.equal(created.title, "Hermes — release prep"); + assert.equal(created.modelSelection.instanceId, INSTANCE_ID); + assert.match(created.threadId, /^hermes-handoff-/); + + assert.equal(harness.sent.length, 2); + assert.deepEqual(harness.sent[0], { + type: "handoff.created", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: request.requestId, + threadId: created.threadId, + }); + assert.deepEqual(harness.sent[1], harness.sent[0]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refuses a forged handoff parent without creating a thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.createHandoffThread( + registration, + { + type: "handoff.create", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make("handoff-forged-parent"), + parentThreadId: ThreadId.make("some-user-thread"), + name: "Wrong parent", + }, + harness.transport, + ); + + assert.equal( + (yield* Ref.get(harness.dispatched)).filter((command) => command.type === "thread.create") + .length, + 0, + ); + assert.equal(harness.sent.length, 1); + assert.equal(harness.sent[0]?.type, "protocol.error"); + if (harness.sent[0]?.type === "protocol.error") { + assert.equal(harness.sent[0].requestId, "handoff-forged-parent"); + assert.equal(harness.sent[0].code, "invalid-message"); + assert.equal(harness.sent[0].recoverable, false); + assert.include(harness.sent[0].message, "Home thread"); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reports unexpected handoff creation failures as recoverable", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ failDispatch: true }); + yield* harness.createHandoffThread( + registration, + { + type: "handoff.create", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: HermesGatewayRequestId.make("handoff-dispatch-failure"), + parentThreadId: HOME_THREAD_ID, + name: "Retry later", + }, + harness.transport, + ); + + assert.equal(harness.sent.length, 1); + assert.equal(harness.sent[0]?.type, "protocol.error"); + if (harness.sent[0]?.type === "protocol.error") { + assert.equal(harness.sent[0].code, "internal-error"); + assert.equal(harness.sent[0].recoverable, true); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("allows handoff delivery only into a thread owned by the instance agent project", () => + Effect.gen(function* () { + const handoffThreadId = ThreadId.make("hermes-handoff-owned"); + const harness = yield* makeHarness({ trackedThreadId: handoffThreadId }); + yield* harness.deliverHomeNotification( + registration, + { + type: "home.deliver", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: HermesGatewayDeliveryId.make("handoff-delivery-1"), + threadId: handoffThreadId, + kind: "handoff", + label: "Handoff", + text: "The CLI session is ready here.", + createdAt: CREATED_AT, + }, + harness.transport, + ); + + const delivery = dispatchedDeliveries(yield* Ref.get(harness.dispatched))[0]!; + assert.equal(delivery.threadId, handoffThreadId); + assert.equal(delivery.kind, "handoff"); + assert.equal(harness.sent[0]?.type, "home.deliver.ack"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("writes turnless media to the home thread with provenance and acks after dispatch", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + // The frame's threadId is deliberately NOT the home thread: turnless + // media must land in the re-resolved home thread regardless. + yield* harness.deliverMedia( + registration, + mediaFrame({ threadId: ThreadId.make("thread-attacker-named") }), + harness.transport, + ); + + const deliveries = dispatchedDeliveries(yield* Ref.get(harness.dispatched)); + assert.equal(deliveries.length, 1); + const delivery = deliveries[0]!; + assert.equal(delivery.threadId, HOME_THREAD_ID); + assert.equal(delivery.kind, "cron"); + assert.equal(delivery.label, "Cron: daily-digest"); + assert.equal(delivery.text, "Today's chart"); + assert.isUndefined(delivery.turnId); + assert.equal(delivery.attachments?.length, 1); + const attachment = delivery.attachments![0]!; + // image/* rides the image variant so the web's inline grid renders it. + assert.equal(attachment.type, "image"); + assert.equal(attachment.mimeType, "image/png"); + assert.equal(attachment.sizeBytes, 8); + + // The bytes are durably on disk under the dispatched attachment id. + const fileSystem = yield* FileSystem.FileSystem; + const entries = yield* fileSystem.readDirectory(harness.attachmentsDir); + const written = entries.find((entry) => entry.startsWith(attachment.id)); + assert.isDefined(written, "the media bytes must be written to the attachments dir"); + const bytes = yield* fileSystem.readFile(`${harness.attachmentsDir}/${written}`); + assert.equal(Buffer.from(bytes).toString("utf8"), "PNGBYTES"); + + assert.deepEqual(harness.sent, [ + { + type: "media.deliver.ack", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: mediaFrame().deliveryId, + }, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("retries after an interrupted atomic media write", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const completed = yield* makeHarness(); + yield* completed.deliverMedia(registration, mediaFrame(), completed.transport); + const finalName = (yield* fileSystem.readDirectory(completed.attachmentsDir)).find((entry) => + entry.endsWith(".png"), + ); + assert.isDefined(finalName); + + const retry = yield* makeHarness(); + const interruptedDirectory = `${retry.attachmentsDir}/${finalName}.interrupted`; + yield* fileSystem.makeDirectory(interruptedDirectory); + yield* fileSystem.writeFileString(`${interruptedDirectory}/contents.tmp`, "PARTIAL"); + + yield* retry.deliverMedia(registration, mediaFrame(), retry.transport); + + const persisted = yield* fileSystem.readFile(`${retry.attachmentsDir}/${finalName}`); + assert.equal(Buffer.from(persisted).toString("utf8"), "PNGBYTES"); + assert.equal(dispatchedDeliveries(yield* Ref.get(retry.dispatched)).length, 1); + assert.equal(retry.sent[0]?.type, "media.deliver.ack"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("acks a duplicate deliveryId without dispatching a second message", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.deliverMedia(registration, mediaFrame(), harness.transport); + yield* harness.deliverMedia(registration, mediaFrame(), harness.transport); + + // Both attempts reach dispatch with the same deterministic command id; + // the engine's durable command receipt returns the first sequence without + // appending a second message. + assert.equal(dispatchedDeliveries(yield* Ref.get(harness.dispatched)).length, 1); + assert.equal(harness.sent.length, 2); + assert.equal(harness.sent[0]?.type, "media.deliver.ack"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps delivery identity stable when a retry resolves to another thread", () => + Effect.gen(function* () { + const handoffThreadId = ThreadId.make("hermes-handoff-redesignated"); + const harness = yield* makeHarness({ trackedThreadId: handoffThreadId }); + const deliveryId = HermesGatewayDeliveryId.make("delivery-across-redesignation"); + + yield* harness.deliverHomeNotification( + registration, + { + type: "home.deliver", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId, + threadId: HOME_THREAD_ID, + kind: "cron", + label: "Cron", + text: "Committed before the ack was lost.", + createdAt: CREATED_AT, + }, + harness.transport, + ); + yield* harness.deliverHomeNotification( + registration, + { + type: "home.deliver", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId, + threadId: handoffThreadId, + kind: "handoff", + label: "Retry", + text: "Must not commit again.", + createdAt: CREATED_AT, + }, + harness.transport, + ); + + const deliveries = dispatchedDeliveries(yield* Ref.get(harness.dispatched)); + assert.equal(deliveries.length, 1); + assert.equal(deliveries[0]?.threadId, HOME_THREAD_ID); + assert.equal(harness.sent.length, 2); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("never overwrites committed media bytes on a conflicting retry", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.deliverMedia(registration, mediaFrame(), harness.transport); + yield* harness.deliverMedia( + registration, + mediaFrame({ data: Buffer.from("DIFFERENT").toString("base64"), sizeBytes: 9 }), + harness.transport, + ); + + const delivery = dispatchedDeliveries(yield* Ref.get(harness.dispatched))[0]!; + const attachment = delivery.attachments![0]!; + const entries = yield* (yield* FileSystem.FileSystem).readDirectory(harness.attachmentsDir); + const persistedPath = `${harness.attachmentsDir}/${entries.find((entry) => entry.startsWith(attachment.id))}`; + const persisted = yield* (yield* FileSystem.FileSystem).readFile(persistedPath); + assert.equal(Buffer.from(persisted).toString("utf8"), "PNGBYTES"); + assert.equal(harness.sent.length, 1, "the conflicting retry must remain unacked"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("dedupes a delivery into an ARCHIVED home thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + homeThreadArchivedAt: CREATED_AT, + }); + yield* harness.deliverMedia(registration, mediaFrame(), harness.transport); + yield* harness.deliverMedia(registration, mediaFrame(), harness.transport); + + assert.equal(dispatchedDeliveries(yield* Ref.get(harness.dispatched)).length, 1); + assert.equal(harness.sent[0]?.type, "media.deliver.ack"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("routes turn-scoped media into the tracked thread and carries the turnId", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ trackedThreadId: SESSION_THREAD_ID }); + const turnId = TurnId.make("hermes-turn-live"); + yield* harness.deliverMedia( + registration, + mediaFrame({ + threadId: SESSION_THREAD_ID, + turnId, + deliveryId: HermesGatewayDeliveryId.make("media-delivery-turn"), + mimeType: "video/mp4", + name: "clip.mp4", + }), + harness.transport, + ); + + const delivery = dispatchedDeliveries(yield* Ref.get(harness.dispatched))[0]!; + assert.equal(delivery.threadId, SESSION_THREAD_ID); + assert.equal(delivery.turnId, turnId); + // Non-image media takes the generic file variant. + assert.equal(delivery.attachments?.[0]?.type, "file"); + assert.equal(harness.sent[0]?.type, "media.deliver.ack"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refuses turn-scoped media for a thread the adapter does not track", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ trackedThreadId: SESSION_THREAD_ID }); + yield* harness.deliverMedia( + registration, + mediaFrame({ + threadId: ThreadId.make("thread-not-ours"), + turnId: TurnId.make("turn-x"), + }), + harness.transport, + ); + + // Refused outright: nothing written, and no ack so the plugin retries + // (against a session that may exist by then). + assert.equal(dispatchedDeliveries(yield* Ref.get(harness.dispatched)).length, 0); + assert.equal(harness.sent.length, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refuses an oversized decoded payload without writing or acking", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const oversized = Buffer.alloc(HERMES_MEDIA_MAX_BYTES + 4, 1); + yield* harness.deliverMedia( + registration, + mediaFrame({ + data: oversized.toString("base64"), + sizeBytes: oversized.byteLength, + }), + harness.transport, + ); + + assert.equal(dispatchedDeliveries(yield* Ref.get(harness.dispatched)).length, 0); + assert.equal(harness.sent.length, 0); + assert.deepEqual( + yield* (yield* FileSystem.FileSystem).readDirectory(harness.attachmentsDir), + [], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("refuses a payload whose declared size grossly disagrees with its bytes", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.deliverMedia(registration, mediaFrame({ sizeBytes: 5_000 }), harness.transport); + + assert.equal(dispatchedDeliveries(yield* Ref.get(harness.dispatched)).length, 0); + assert.equal(harness.sent.length, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not ack media when the dispatch fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ failDispatch: true }); + yield* harness.deliverMedia(registration, mediaFrame(), harness.transport); + + // The plugin keeps the delivery queued and retries on the next connect. + assert.equal(harness.sent.length, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/hermesGatewayHttp.ts b/apps/server/src/provider/hermesGatewayHttp.ts new file mode 100644 index 000000000000..c5766a2e4bd2 --- /dev/null +++ b/apps/server/src/provider/hermesGatewayHttp.ts @@ -0,0 +1,663 @@ +import * as NodeCrypto from "node:crypto"; +import { + CommandId, + DEFAULT_HERMES_MODEL, + DEFAULT_PROVIDER_INTERACTION_MODE, + HERMES_GATEWAY_PROTOCOL_VERSION, + HERMES_MEDIA_MAX_BYTES, + HermesGatewayConnectionHello, + HermesGatewayPluginToT3Message, + HermesGatewayT3ToPluginMessage, + MessageId, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type ChatAttachment, + type ThreadId, + ThreadId as ThreadIdSchema, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { getOrCreateAgentProject } from "../orchestration/agentProjects.ts"; +import { getOrCreateHomeThread } from "../orchestration/homeThreads.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + HermesGatewayBroker, + type HermesGatewayConnectionRegistration, +} from "./Services/HermesGatewayBroker.ts"; +import { ProviderAdapterRequestError } from "./Errors.ts"; + +export const HERMES_GATEWAY_WEBSOCKET_PATH = "/api/hermes-gateway/ws"; + +const decodePluginFrame = Schema.decodeUnknownEffect( + Schema.fromJsonString(HermesGatewayPluginToT3Message), +); +const encodeServerFrame = Schema.encodeSync(Schema.fromJsonString(HermesGatewayT3ToPluginMessage)); +const isConnectionHello = Schema.is(HermesGatewayConnectionHello); + +interface HermesDeliveryTransport { + readonly send: ( + frame: HermesGatewayT3ToPluginMessage, + ) => Effect.Effect; +} + +function deliveryUuid(input: { + readonly instanceId: string; + readonly threadId: string; + readonly deliveryId: string; + readonly purpose: string; +}): string { + const hex = NodeCrypto.createHash("sha256") + .update(`${input.purpose}\0${input.instanceId}\0${input.threadId}\0${input.deliveryId}`) + .digest("hex") + .slice(0, 32); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function deliveryIds(input: { readonly instanceId: string; readonly deliveryId: string }) { + // The identity belongs to the source instance and delivery, not to the + // destination selected at retry time. Home can be re-designated after a + // commit whose ack was lost; retaining the same command/message ids lets + // the durable receipt acknowledge that retry instead of appending twice. + const id = deliveryUuid({ ...input, threadId: "", purpose: "message" }); + return { + commandId: CommandId.make(`hermes-delivery-${id}`), + messageId: MessageId.make(`hermes-delivery-${id}`), + }; +} + +const writeMediaAtomically = Effect.fn("writeHermesMediaAtomically")(function* ( + filePath: string, + bytes: Uint8Array, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetDirectory = path.dirname(filePath); + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(filePath)}.`, + }); + const tempPath = path.join(tempDirectory, "contents.tmp"); + + yield* fileSystem.writeFile(tempPath, bytes); + yield* fileSystem.rename(tempPath, filePath); +}); + +/** + * Build the durable-write-then-ack handlers for plugin-initiated deliveries. + * + * A factory rather than route-inlined closures so the delivery contract — + * dedupe, pessimistic ack, thread resolution — is testable without standing + * up a WebSocket route around it. + */ +export const makeHermesDeliveryHandlers = Effect.fn("makeHermesDeliveryHandlers")(function* () { + const engine = yield* OrchestrationEngineService; + const projection = yield* ProjectionSnapshotQuery; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + const resolveDeliveryThread = (input: { + readonly registration: HermesGatewayConnectionRegistration; + readonly requestedThreadId: ThreadId; + readonly kind: "cron" | "message" | "lifecycle" | "handoff" | "other"; + }) => + Effect.gen(function* () { + const homeThreadId = yield* getOrCreateHomeThread({ + instanceId: input.registration.instanceId, + title: input.registration.accepted.nickname, + }); + if (input.kind !== "handoff" || input.requestedThreadId === homeThreadId) { + return homeThreadId; + } + + // `/handoff` is the one proactive path allowed to name a non-Home + // destination. It must be a thread in this instance's synthetic agent + // project — the same boundary `createHandoffThread` writes into. This + // prevents an authenticated but confused plugin from addressing an + // arbitrary user project. + const project = yield* getOrCreateAgentProject({ + instanceId: input.registration.instanceId, + title: input.registration.accepted.nickname, + }); + const active = yield* projection.getThreadShellById(input.requestedThreadId); + const archived = + Option.isNone(active) && projection.getThreadArchiveStateById !== undefined + ? yield* projection.getThreadArchiveStateById(input.requestedThreadId) + : Option.none(); + const thread = Option.isSome(active) + ? active.value + : projection.getThreadArchiveStateById !== undefined + ? Option.getOrUndefined(archived) + : (yield* projection.getArchivedShellSnapshot()).threads.find( + (candidate) => candidate.id === input.requestedThreadId, + ); + if (thread?.projectId !== project.id) { + return yield* new ProviderAdapterRequestError({ + provider: "hermes", + method: "handoff.deliver", + detail: `Handoff destination '${input.requestedThreadId}' is not owned by this Hermes instance.`, + }); + } + if (thread.archivedAt !== null) { + yield* engine + .dispatch({ + type: "thread.unarchive", + commandId: CommandId.make( + `hermes-handoff-unarchive-${deliveryUuid({ + instanceId: input.registration.instanceId, + threadId: input.requestedThreadId, + deliveryId: thread.archivedAt, + purpose: "unarchive", + })}`, + ), + threadId: input.requestedThreadId, + }) + .pipe(Effect.ignore); + } + return input.requestedThreadId; + }); + + /** + * Write one proactive delivery into the instance's home thread and ack it. + * + * The ack is sent **only after the dispatch succeeds**. The plugin purges + * its queued copy on the ack and on nothing else, so acking optimistically + * would silently drop deliveries whenever a write failed. An unacked + * delivery is retried on the next connect and deduped there by + * `deliveryId`, which makes the pessimistic order the safe one. + * + * Accepted from either connection role. The route runs this handler through + * the broker's authorization fence: a primary must still own its generation, + * while a short-lived delivery socket must still hold the current credential. + * That preserves out-of-process cron without letting a socket outlive revoke + * or re-enrollment. + */ + const deliverHomeNotification = ( + registration: HermesGatewayConnectionRegistration, + message: Extract, + transport: HermesDeliveryTransport, + ) => + Effect.gen(function* () { + const deliveryThreadId = yield* resolveDeliveryThread({ + registration, + requestedThreadId: message.threadId, + kind: message.kind, + }); + + const ids = deliveryIds({ + instanceId: registration.instanceId, + deliveryId: message.deliveryId, + }); + // Command receipts make this idempotent across retries and restarts. + yield* engine.dispatch({ + type: "thread.notification.deliver", + ...ids, + threadId: deliveryThreadId, + expectedProviderInstanceId: registration.instanceId, + deliveryId: message.deliveryId, + kind: message.kind, + label: message.label, + text: message.text, + createdAt: message.createdAt, + }); + + yield* transport.send({ + type: "home.deliver.ack", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: message.deliveryId, + }); + }).pipe( + Effect.catchCause((cause) => + // Deliberately no ack: the plugin keeps the delivery queued and + // retries it on the next connect. + Effect.logWarning("Hermes home delivery failed", { + instanceId: registration.instanceId, + deliveryId: message.deliveryId, + cause: Cause.pretty(cause), + }), + ), + ); + + /** + * Write one media delivery — bytes to the attachment store, then a + * notification-shaped message row — and ack it. + * + * The same pessimistic contract as `deliverHomeNotification`: the ack is + * sent only after the dispatch succeeds, an unacked delivery is retried + * and deduped on `deliveryId`, and both connection roles are accepted. + * + * Thread resolution is scope-dependent: + * - `turnId` present — media produced during a live turn. The frame's + * threadId is honored only when its projected shell belongs to this + * instance and still has a live ACP session; anything else is refused, so + * a confused plugin cannot spray files into arbitrary threads. Reading the + * provider-neutral projection also keeps the gateway route independent of + * provider runtime internals. + * - turnless — proactive media. The threadId is advisory exactly as it is + * for `home.deliver`: the server re-resolves the instance's home thread + * and writes only there. + */ + const deliverMedia = ( + registration: HermesGatewayConnectionRegistration, + message: Extract, + transport: HermesDeliveryTransport, + ) => + Effect.gen(function* () { + const bytes = Buffer.from(message.data, "base64"); + // The schema bounds the encoded string; re-check the decoded bytes so + // a frame whose base64 hides more than the ceiling (or whose declared + // size lies) is refused before anything is written. `sizeBytes` only + // needs to be honest, not exact — base64 length is ambiguous by up to + // 2 bytes of padding. + if (bytes.byteLength === 0 || bytes.byteLength > HERMES_MEDIA_MAX_BYTES) { + return yield* Effect.fail( + new ProviderAdapterRequestError({ + provider: "hermes", + method: "media.deliver", + detail: `Media payload is empty or exceeds ${HERMES_MEDIA_MAX_BYTES} bytes after decoding.`, + }), + ); + } + if (Math.abs(bytes.byteLength - message.sizeBytes) > 2) { + return yield* Effect.fail( + new ProviderAdapterRequestError({ + provider: "hermes", + method: "media.deliver", + detail: `Media payload decoded to ${bytes.byteLength} bytes but declared ${message.sizeBytes}.`, + }), + ); + } + + const threadId = yield* Effect.gen(function* () { + if (message.turnId === undefined) { + return yield* resolveDeliveryThread({ + registration, + requestedThreadId: message.threadId, + kind: message.kind, + }); + } + // Turn-scoped: the named thread must belong to this instance and have + // a live ACP session. The companion does not start the turn; it can + // only attach media to one ACP already owns. + const shell = yield* projection.getThreadShellById(message.threadId); + const tracked = Option.isSome(shell) + ? shell.value.modelSelection.instanceId === registration.instanceId && + shell.value.session !== null && + shell.value.session.status !== "stopped" + : false; + if (!tracked) { + return yield* Effect.fail( + new ProviderAdapterRequestError({ + provider: "hermes", + method: "media.deliver", + detail: `Turn-scoped media names thread '${message.threadId}', which this instance has no session for.`, + }), + ); + } + return message.threadId; + }); + + const ids = deliveryIds({ + instanceId: registration.instanceId, + deliveryId: message.deliveryId, + }); + { + const mimeType = message.mimeType.trim().toLowerCase(); + const attachmentId = createAttachmentId( + threadId, + deliveryUuid({ + instanceId: registration.instanceId, + threadId: "", + deliveryId: message.deliveryId, + purpose: "attachment", + }), + ); + if (!attachmentId) { + return yield* Effect.fail( + new ProviderAdapterRequestError({ + provider: "hermes", + method: "media.deliver", + detail: "Failed to create a safe attachment id.", + }), + ); + } + // Images take the image variant so they ride the existing inline + // grid; the image schema caps sizeBytes at 10MB, so a larger image + // degrades to the generic file card rather than being refused. + const attachment: ChatAttachment = + mimeType.startsWith("image/") && bytes.byteLength <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + ? { + type: "image", + id: attachmentId, + name: message.name, + mimeType, + sizeBytes: bytes.byteLength, + } + : { + type: "file", + id: attachmentId, + name: message.name, + mimeType, + sizeBytes: bytes.byteLength, + }; + + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* Effect.fail( + new ProviderAdapterRequestError({ + provider: "hermes", + method: "media.deliver", + detail: `Failed to resolve a persisted path for '${message.name}'.`, + }), + ); + } + // Bytes before row, deliberately: a row pointing at a missing file + // renders broken forever, while an orphaned file from a failed + // dispatch is harmless. Publish through a sibling temporary file so + // an interrupted write cannot leave a partial final file that poisons + // every retry of this deterministic delivery id. + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + if (yield* fileSystem.exists(attachmentPath)) { + const persisted = yield* fileSystem.readFile(attachmentPath); + if (!Buffer.from(persisted).equals(bytes)) { + return yield* Effect.fail( + new ProviderAdapterRequestError({ + provider: "hermes", + method: "media.deliver", + detail: `Delivery '${message.deliveryId}' was retried with different media bytes.`, + }), + ); + } + } else { + yield* Effect.scoped(writeMediaAtomically(attachmentPath, bytes)); + } + + yield* engine.dispatch({ + type: "thread.notification.deliver", + ...ids, + threadId, + expectedProviderInstanceId: registration.instanceId, + deliveryId: message.deliveryId, + kind: message.kind, + label: message.label, + // The caption is the row's text; empty is fine — the schema + // allows it and the web renders media-only rows without a body. + text: message.caption ?? "", + attachments: [attachment], + ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), + createdAt: message.createdAt, + }); + } + + yield* transport.send({ + type: "media.deliver.ack", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: message.deliveryId, + }); + }).pipe( + Effect.catchCause((cause) => + // Deliberately no ack: the plugin keeps the delivery queued and + // retries it on the next connect. + Effect.logWarning("Hermes media delivery failed", { + instanceId: registration.instanceId, + deliveryId: message.deliveryId, + cause: Cause.pretty(cause), + }), + ), + ); + + const createHandoffThread = ( + registration: HermesGatewayConnectionRegistration, + message: Extract, + transport: HermesDeliveryTransport, + ) => + Effect.gen(function* () { + if (registration.role !== "gateway") { + return yield* new ProviderAdapterRequestError({ + provider: "hermes", + method: "handoff.create", + detail: "A delivery-only connection cannot create handoff threads.", + }); + } + const homeThreadId = yield* getOrCreateHomeThread({ + instanceId: registration.instanceId, + title: registration.accepted.nickname, + }); + if (message.parentThreadId !== homeThreadId) { + return yield* new ProviderAdapterRequestError({ + provider: "hermes", + method: "handoff.create", + detail: "A Hermes handoff must start from the instance's Home thread.", + }); + } + const project = yield* getOrCreateAgentProject({ + instanceId: registration.instanceId, + title: registration.accepted.nickname, + }); + const rawId = deliveryUuid({ + instanceId: registration.instanceId, + threadId: homeThreadId, + deliveryId: message.requestId, + purpose: "handoff-thread", + }); + const threadId = ThreadIdSchema.make(`hermes-handoff-${rawId}`); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`hermes-handoff-create-${rawId}`), + threadId, + projectId: project.id, + title: message.name, + modelSelection: { + instanceId: registration.instanceId, + model: DEFAULT_HERMES_MODEL, + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* transport.send({ + type: "handoff.created", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + threadId, + }); + }).pipe( + Effect.catchTag("ProviderAdapterRequestError", (error) => + error.method === "handoff.create" + ? transport.send({ + type: "protocol.error", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + code: "invalid-message", + message: error.detail, + recoverable: false, + }) + : Effect.fail(error), + ), + Effect.catchCause((cause) => + transport + .send({ + type: "protocol.error", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: message.requestId, + code: "internal-error", + message: "T3 could not create the Hermes handoff thread.", + recoverable: true, + }) + .pipe( + Effect.tap(() => + Effect.logWarning("Hermes handoff thread creation failed", { + instanceId: registration.instanceId, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); + + return { deliverHomeNotification, deliverMedia, createHandoffThread } as const; +}); + +export const hermesGatewayWebSocketRouteLayer = Layer.unwrap( + Effect.gen(function* () { + const broker = yield* HermesGatewayBroker; + const { deliverHomeNotification, deliverMedia, createHandoffThread } = + yield* makeHermesDeliveryHandlers(); + + return HttpRouter.add( + "GET", + HERMES_GATEWAY_WEBSOCKET_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const socket = yield* Effect.orDie(request.upgrade); + const write = yield* socket.writer; + const registration = yield* Ref.make>( + Option.none(), + ); + const transport = { + send: (message: HermesGatewayT3ToPluginMessage) => + write(encodeServerFrame(message)).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: "hermes", + method: message.type, + detail: "Failed to write a Hermes gateway WebSocket frame.", + cause, + }), + ), + ), + close: (code: number, reason: string) => + write(new Socket.CloseEvent(code, reason)).pipe(Effect.ignore), + }; + + yield* socket + .runString((frame) => + Effect.gen(function* () { + const message = yield* decodePluginFrame(frame); + const current = yield* Ref.get(registration); + + if (Option.isNone(current)) { + if (!isConnectionHello(message)) { + yield* transport.close(4002, "First message must be connection.hello"); + return; + } + const registered = yield* broker + .registerConnection(message, transport) + .pipe( + Effect.tapError((rejected) => + transport + .send(rejected) + .pipe(Effect.andThen(transport.close(4003, rejected.message))), + ), + ); + yield* Ref.set(registration, Option.some(registered)); + + // Resolve the home thread here rather than inside + // `registerConnection`, whose error channel is exactly + // `connection.rejected`: a thread-creation failure is not a + // reason to refuse an authenticated plugin. On failure the + // handshake completes without `homeThreadId`, the plugin keeps + // whatever designation it already had, and the next connect + // converges — which is the whole point of converge-on-read. + const homeThreadId = yield* getOrCreateHomeThread({ + instanceId: registered.instanceId, + title: registered.accepted.nickname, + }).pipe( + Effect.map(Option.some), + Effect.catchCause((cause) => + Effect.logWarning("home thread resolution failed", { + instanceId: registered.instanceId, + cause: Cause.pretty(cause), + }).pipe(Effect.as(Option.none())), + ), + ); + + yield* transport.send({ + ...registered.accepted, + ...(Option.isSome(homeThreadId) ? { homeThreadId: homeThreadId.value } : {}), + }); + return; + } + + if (isConnectionHello(message)) { + yield* transport.close(4002, "connection.hello may only be sent once"); + return; + } + + // Deliveries are handled on the socket that carried them rather + // than through the broker's event stream, because the ack has to + // go back to *this* connection — which for an out-of-process cron + // run is a short-lived delivery socket that no stream subscriber + // can address. + if (message.type === "home.deliver") { + yield* broker.withAuthorizedConnection( + current.value, + deliverHomeNotification(current.value, message, transport), + ); + return; + } + + if (message.type === "media.deliver") { + yield* broker.withAuthorizedConnection( + current.value, + deliverMedia(current.value, message, transport), + ); + return; + } + + if (message.type === "handoff.create") { + yield* broker.withAuthorizedConnection( + current.value, + createHandoffThread(current.value, message, transport), + ); + return; + } + + yield* broker.receive(current.value, message); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Rejected Hermes gateway WebSocket frame", { cause }).pipe( + Effect.andThen(transport.close(4002, "Invalid Hermes gateway message")), + ), + ), + ), + ) + .pipe( + Effect.catch((cause) => + Effect.logDebug("Hermes gateway WebSocket disconnected", { cause }), + ), + Effect.ensuring( + Ref.get(registration).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: broker.disconnect, + }), + ), + ), + ), + ); + + return HttpServerResponse.empty(); + }), + ); + }), +); diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 5bfd3e14cfd7..b1d8704609c0 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -266,6 +266,7 @@ describe("makeManagedServerProvider", () => { ready: Effect.void, getSettings: Ref.get(serverSettingsRef), updateSettings: () => Effect.die(new Error("unused in this test")), + updateSettingsWith: () => Effect.die(new Error("unused in this test")), streamChanges: Stream.empty, subscribeChanges: PubSub.subscribe(serverSettingsChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 42fc93624668..7f7abdbc7744 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -32,6 +32,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; +import { HermesGatewayBrokerLive } from "./provider/Layers/HermesGatewayBroker.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; @@ -114,6 +115,7 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import { hermesGatewayWebSocketRouteLayer } from "./provider/hermesGatewayHttp.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; @@ -143,6 +145,10 @@ const PtyAdapterLive = Layer.unwrap( const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)); +const HermesGatewayBrokerLayerLive = HermesGatewayBrokerLive.pipe( + Layer.provideMerge(ServerSettingsLayerLive), +); + const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( Layer.provide(ResourceMonitorBinary.layer), ); @@ -369,7 +375,9 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services - Layer.provideMerge(ServerSettingsLayerLive), + // The broker re-exports the same settings layer it consumes so all runtime + // services share one settings cache and write semaphore. + Layer.provideMerge(HermesGatewayBrokerLayerLive), Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), @@ -455,6 +463,7 @@ export const makeRoutesLayer = Layer.mergeAll( otlpTracesProxyRouteLayer, assetRouteLayer, staticAndDevRouteLayer, + hermesGatewayWebSocketRouteLayer, websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 2798faf6f006..106ee4510d35 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -129,6 +129,15 @@ export class ServerSettingsService extends Context.Service< patch: ServerSettingsPatch, ) => Effect.Effect; + /** + * Compute and persist a patch while holding the settings write lock. + * Use this for read-modify-write operations on whole-map fields such as + * `providerInstances`, so concurrent settings edits are preserved. + */ + readonly updateSettingsWith: ( + update: (current: ServerSettings) => ServerSettingsPatch, + ) => Effect.Effect; + /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; @@ -159,18 +168,26 @@ const makeTest = (overrides: DeepPartial = {}) => : {}), }); const currentSettingsRef = yield* Ref.make(initialSettings); + const writeSemaphore = yield* Semaphore.make(1); - return { - start: Effect.void, - ready: Effect.void, - getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)), - updateSettings: (patch) => + const updateSettingsWith = (update: (current: ServerSettings) => ServerSettingsPatch) => + writeSemaphore.withPermits(1)( Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), + Effect.map((currentSettings) => + applyServerSettingsPatch(currentSettings, update(currentSettings)), + ), Effect.flatMap(normalizeServerSettings), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), Effect.map(resolveTextGenerationProvider), ), + ); + + return { + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)), + updateSettings: (patch) => updateSettingsWith(() => patch), + updateSettingsWith, streamChanges: Stream.empty, subscribeChanges: Effect.succeed(Stream.empty), } satisfies ServerSettingsService["Service"]; @@ -568,6 +585,23 @@ const make = Effect.gen(function* () { yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); }); + const updateSettingsWith = (update: (current: ServerSettings) => ServerSettingsPatch) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* getSettingsFromCache; + const nextPersisted = yield* persistProviderEnvironmentSecrets( + current, + applyServerSettingsPatch(current, update(current)), + ); + const next = yield* normalizeServerSettings(nextPersisted); + yield* writeSettingsAtomically(next); + yield* Cache.set(settingsCache, cacheKey, next); + yield* emitChange(next); + const materialized = yield* materializeProviderEnvironmentSecrets(next); + return resolveTextGenerationProvider(materialized); + }), + ); + return { start, ready: Deferred.await(startedDeferred), @@ -575,22 +609,8 @@ const make = Effect.gen(function* () { Effect.flatMap(materializeProviderEnvironmentSecrets), Effect.map(resolveTextGenerationProvider), ), - updateSettings: (patch) => - writeSemaphore.withPermits(1)( - Effect.gen(function* () { - const current = yield* getSettingsFromCache; - const nextPersisted = yield* persistProviderEnvironmentSecrets( - current, - applyServerSettingsPatch(current, patch), - ); - const next = yield* normalizeServerSettings(nextPersisted); - yield* writeSettingsAtomically(next); - yield* Cache.set(settingsCache, cacheKey, next); - yield* emitChange(next); - const materialized = yield* materializeProviderEnvironmentSecrets(next); - return resolveTextGenerationProvider(materialized); - }), - ), + updateSettings: (patch) => updateSettingsWith(() => patch), + updateSettingsWith, get streamChanges() { return materializeChanges(Stream.fromPubSub(changesPubSub)); }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cc45b9057638..b4a44b73aacd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -79,6 +79,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import { HermesGatewayBroker } from "./provider/Services/HermesGatewayBroker.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -371,6 +372,7 @@ const makeWsRpcLayer = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const hermesGatewayBroker = yield* HermesGatewayBroker; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; @@ -1545,6 +1547,42 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.hermesGatewayCreateEnrollment]: (input) => + observeRpcEffect( + WS_METHODS.hermesGatewayCreateEnrollment, + hermesGatewayBroker.createEnrollment(input), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.hermesGatewayGetInstanceStatus]: ({ instanceId }) => + observeRpcEffect( + WS_METHODS.hermesGatewayGetInstanceStatus, + hermesGatewayBroker.getInstanceStatus(instanceId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.hermesGatewayListInstances]: (_input) => + observeRpcEffect( + WS_METHODS.hermesGatewayListInstances, + hermesGatewayBroker.listInstances, + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.hermesGatewayRenameInstance]: (input) => + observeRpcEffect( + WS_METHODS.hermesGatewayRenameInstance, + hermesGatewayBroker.renameInstance(input), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.hermesGatewayRevokeInstance]: ({ instanceId }) => + observeRpcEffect( + WS_METHODS.hermesGatewayRevokeInstance, + hermesGatewayBroker.revokeInstance(instanceId), + { "rpc.aggregate": "provider" }, + ), + [WS_METHODS.hermesGatewayRemoveInstance]: ({ instanceId }) => + observeRpcEffect( + WS_METHODS.hermesGatewayRemoveInstance, + hermesGatewayBroker.removeInstance(instanceId), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bbea3d72112f..3f9b769cda61 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + type AssetResource, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, @@ -2389,22 +2390,29 @@ function ChatViewContent(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; - const serverAttachmentIds = useMemo(() => { - const attachmentIds = new Set(); + const serverAttachmentResources = useMemo(() => { + const resources = new Map>(); for (const message of serverMessages ?? []) { for (const attachment of message.attachments ?? []) { - attachmentIds.add(attachment.id); + if (resources.has(attachment.id)) continue; + resources.set( + attachment.id, + attachment.type === "file" + ? { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: attachment.mimeType, + } + : { _tag: "attachment", attachmentId: attachment.id }, + ); } } - return [...attachmentIds]; + return [...resources.values()]; }, [serverMessages]); - const serverAttachmentResources = useMemo( - () => - serverAttachmentIds.map((attachmentId) => ({ - _tag: "attachment" as const, - attachmentId, - })), - [serverAttachmentIds], + const serverAttachmentIds = useMemo( + () => serverAttachmentResources.map((resource) => resource.attachmentId), + [serverAttachmentResources], ); const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); const serverAttachmentUrlById = useMemo( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 617ee0b80d1c..910a35f70224 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -443,6 +443,46 @@ describe("MessagesTimeline", () => { expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); }); + it("renders proactive assistant image and file attachments", () => { + const entry = buildAssistantTimelineEntry("Cron output"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('alt="chart.png"'); + expect(markup).toContain('href="https://assets.example/report.bin"'); + expect(markup).toContain('download="report.pdf"'); + expect(markup).toContain("application/pdf"); + }); + it("renders collapse controls for long user messages", () => { const markup = renderToStaticMarkup( }) { const ctx = use(TimelineRowCtx); - const userImages = row.message.attachments ?? []; + const attachments = row.message.attachments ?? []; + const userImages = attachments.filter((attachment) => attachment.type === "image"); + const userFiles = attachments.filter((attachment) => attachment.type === "file"); const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const previewAnnotations: ParsedPreviewAnnotation[] = []; @@ -1011,6 +1014,7 @@ function UserTimelineRow({ row }: { row: Extract )} + {previewAnnotations.map((annotation, index) => ( + [number]>; +}) { + const ctx = use(TimelineRowCtx); + const images = props.attachments.filter((attachment) => attachment.type === "image"); + const files = props.attachments.filter((attachment) => attachment.type === "file"); + return ( + <> + {images.length > 0 ? ( +
+ {images.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
{image.name}
+ )} +
+ ))} +
+ ) : null} + + + ); +} + +function MessageFileAttachments(props: { + attachments: ReadonlyArray< + Extract[number], { type: "file" }> + >; +}) { + if (props.attachments.length === 0) return null; + return ( +
+ {props.attachments.map((file) => + file.previewUrl ? ( + + + {file.name} + {file.mimeType} + + ) : ( +
+ + {file.name} +
+ ), + )} +
+ ); +} + function AssistantCopyButton({ row }: { row: Extract }) { const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, diff --git a/apps/web/src/components/settings/HermesCompanionSection.tsx b/apps/web/src/components/settings/HermesCompanionSection.tsx new file mode 100644 index 000000000000..83e988444779 --- /dev/null +++ b/apps/web/src/components/settings/HermesCompanionSection.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { CopyIcon, RefreshCwIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + EnvironmentId, + HermesGatewayEnrollmentResult, + HermesGatewayInstanceStatus, + ProviderInstanceId, +} from "@t3tools/contracts"; + +import { useAtomCommand } from "../../state/use-atom-command"; +import { serverEnvironment } from "../../state/server"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { requestConfirmDialog } from "../../confirmDialog"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; + +const STATUS_LABELS: Record = { + offline: "Disconnected", + connecting: "Connecting", + connected: "Connected", + "upgrade-required": "Plugin upgrade required", + revoked: "Revoked", +}; + +function defaultConnectorUrl(): string { + return typeof window === "undefined" + ? "" + : new URL("/api/hermes-gateway/ws", window.location.origin).toString(); +} + +function messageFromUnknownError(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim() + ) { + return error.message; + } + return "The Hermes companion request failed."; +} + +function isInstanceNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "instance-not-found" + ); +} + +function statusGuidance(status: HermesGatewayInstanceStatus["status"] | null): string { + switch (status) { + case "connected": + return "The companion is connected. Ordinary chats still run through hermes-acp."; + case "connecting": + return "The companion is completing its authenticated handshake."; + case "upgrade-required": + return "Re-run the plugin install script on the Hermes host, then restart hermes gateway."; + case "revoked": + return "Create a new one-time enrollment to reconnect this Hermes host."; + case "offline": + return "Run the enrollment command on the Hermes host, then restart hermes gateway."; + case null: + return "Install the shipped plugin on the Hermes host, then create a one-time enrollment."; + } +} + +export function HermesCompanionSection(props: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly nickname: string; +}) { + const [status, setStatus] = useState(null); + const [enrollment, setEnrollment] = useState(null); + const [connectorUrl, setConnectorUrl] = useState(defaultConnectorUrl); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const connectorUrlHasLocalEdits = useRef(false); + const refreshGeneration = useRef(0); + const getStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, { + reportFailure: false, + }); + const createEnrollment = useAtomCommand(serverEnvironment.hermesGatewayCreateEnrollment); + const revoke = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance); + const remove = useAtomCommand(serverEnvironment.hermesGatewayRemoveInstance); + const { copyToClipboard } = useCopyToClipboard({ + onCopy: () => toastManager.add({ type: "success", title: "Enrollment command copied" }), + }); + + const refresh = useCallback( + async (quiet = false) => { + const generation = ++refreshGeneration.current; + if (!quiet) setPending(true); + const result = await getStatus({ + environmentId: props.environmentId, + input: { instanceId: props.instanceId }, + }); + // A management operation or a newer poll superseded this read. Applying + // its stale not-enrolled result would hide a just-created enrollment (or + // erase the actionable error from a failed operation). + if (generation !== refreshGeneration.current) return; + if (result._tag === "Success") { + setStatus(result.value); + if (!connectorUrlHasLocalEdits.current) setConnectorUrl(result.value.connectorUrl); + if (!quiet) setError(null); + } else { + const failure = squashAtomCommandFailure(result); + if (isInstanceNotFoundError(failure)) { + // A missing gateway record is the normal, never-enrolled state. + setStatus(null); + if (!quiet) setError(null); + } else if (!quiet) { + setError(messageFromUnknownError(failure)); + } + } + if (!quiet) setPending(false); + }, + [getStatus, props.environmentId, props.instanceId], + ); + + useEffect(() => { + void refresh(); + const interval = window.setInterval(() => void refresh(true), 5_000); + return () => window.clearInterval(interval); + }, [refresh]); + + const enroll = async () => { + if (!connectorUrl.trim()) return; + refreshGeneration.current += 1; + setPending(true); + setError(null); + const result = await createEnrollment({ + environmentId: props.environmentId, + input: { + instanceId: props.instanceId, + nickname: props.nickname.trim() || "Hermes companion", + connectorUrl: connectorUrl.trim(), + }, + }); + if (result._tag === "Success") { + setEnrollment(result.value); + connectorUrlHasLocalEdits.current = false; + setConnectorUrl(result.value.connectorUrl); + toastManager.add({ type: "success", title: "One-time enrollment created" }); + await refresh(true); + } else { + setError(messageFromUnknownError(squashAtomCommandFailure(result))); + } + setPending(false); + }; + + const revokeOrRemove = async (action: "revoke" | "remove") => { + const prompt = + action === "revoke" + ? "Revoke this companion's access? It will need to be enrolled again." + : "Remove this companion enrollment record? Your Hermes provider remains configured."; + if (!(await requestConfirmDialog(prompt, { variant: "destructive" }))) return; + refreshGeneration.current += 1; + setPending(true); + setError(null); + if (action === "revoke") { + const result = await revoke({ + environmentId: props.environmentId, + input: { instanceId: props.instanceId }, + }); + if (result._tag === "Success") { + refreshGeneration.current += 1; + setEnrollment(null); + setStatus(result.value); + } else { + setError(messageFromUnknownError(squashAtomCommandFailure(result))); + } + } else { + const result = await remove({ + environmentId: props.environmentId, + input: { instanceId: props.instanceId }, + }); + if (result._tag === "Success") { + refreshGeneration.current += 1; + setEnrollment(null); + setStatus(null); + } else { + setError(messageFromUnknownError(squashAtomCommandFailure(result))); + } + } + setPending(false); + }; + + return ( +
+
+

Hermes companion (optional)

+

+ Ordinary chats continue to use hermes-acp. The companion is only for proactive Home, cron, + handoff, and media delivery. +

+

+ On the Hermes host, first run this repository's{" "} + integrations/hermes-t3-gateway/install.sh. +

+
+ +
+ + {status ? STATUS_LABELS[status.status] : "Not enrolled"} + {status ? ( + <> + {status.protocolVersion ? ` · protocol v${status.protocolVersion}` : ""} + {status.activeSessionCount ? ` · ${status.activeSessionCount} active session(s)` : ""} + {status.pluginVersion ? ` · plugin ${status.pluginVersion}` : ""} + + ) : null} + + +
+

{statusGuidance(status?.status ?? null)}

+ {error ?

{error}

: null} + + {!status || status.status === "revoked" ? ( +
+ + { + connectorUrlHasLocalEdits.current = true; + setConnectorUrl(event.target.value); + }} + /> + +
+ ) : null} + + {enrollment ? ( +
+

+ Run this once in the Hermes environment before{" "} + {new Date(enrollment.expiresAt).toLocaleString()}. +

+ + {enrollment.command} + + +

+ The command contains a one-time enrollment token. Keep it private. +

+
+ ) : null} + + {status ? ( +
+ {status.status !== "revoked" ? ( + + ) : null} + +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 11e108e7ca7c..df446cdb2b08 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -14,10 +14,12 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { useState, type ReactNode } from "react"; import { + HERMES_DRIVER_KIND, isProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type ProviderInstanceId, + type EnvironmentId, type ProviderDriverKind, type ServerProvider, type ServerProviderModel, @@ -42,6 +44,7 @@ import { ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; +import { HermesCompanionSection } from "./HermesCompanionSection"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { getProviderVersionAdvisoryPresentation, @@ -319,6 +322,7 @@ function ProviderEnvironmentSection(props: { } interface ProviderInstanceCardProps { + readonly environmentId: EnvironmentId; readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; readonly driverOption: DriverOption | undefined; @@ -376,6 +380,7 @@ interface ProviderInstanceCardProps { * flows through the envelope. */ export function ProviderInstanceCard({ + environmentId, instanceId, instance, driverOption, @@ -773,6 +778,14 @@ export function ProviderInstanceCard({ /> ) : null} + {instance.driver === HERMES_DRIVER_KIND ? ( + + ) : null} + {driverOption !== undefined ? ( { readonly attachments?: ReadonlyArray | undefined; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ae6840cd16ef..a2b54c9a6129 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -227,8 +227,9 @@ export default defineConfig(() => { ? { // One entry per shared prefix; the server's dev catch-all 404s the // same list, so the two sides cannot drift. `/ws` is the app's own - // socket — Vite's HMR socket is matched separately and exactly - // (path "/" plus a vite-hmr subprotocol), so the two upgrade + // socket and `/api` includes companion sockets such as the Hermes + // gateway — Vite's HMR socket is matched separately and exactly + // (path "/" plus a vite-hmr subprotocol), so these upgrade // handlers don't collide. proxy: Object.fromEntries( DEV_PROXIED_PATH_PREFIXES.map((prefix) => [ @@ -236,7 +237,7 @@ export default defineConfig(() => { { target: devProxyTarget, changeOrigin: true, - ...(prefix === "/ws" ? { ws: true } : {}), + ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}), }, ]), ), diff --git a/integrations/hermes-t3-gateway/COMPATIBILITY.md b/integrations/hermes-t3-gateway/COMPATIBILITY.md new file mode 100644 index 000000000000..aed76ae7d3df --- /dev/null +++ b/integrations/hermes-t3-gateway/COMPATIBILITY.md @@ -0,0 +1,515 @@ +# Hermes event compatibility inventory + +The plugin deliberately uses only public Hermes plugin and platform-adapter +surfaces. The compatibility shims were audited at Hermes Agent upstream commit +`62e07223` (v0.19.0). The supported handoff callback shape was separately +audited at official revision `d109785b`; v0.19.0 remains supported through the +documented Home fallback. + +T3 interactive turns are intentionally outside the companion integration: +`hermes-acp` owns them. The v4 validators and BasePlatformAdapter callbacks +remain for API/wire compatibility, but companion `turn.start` and `turn.steer` +dispatch fails recoverably before invoking Hermes. + +Scope note: this file inventories **upstream Hermes** surfaces only. T3-side +machinery the plugin talks to over the wire — `withHermesConfig`, the broker's +generation fencing, `getOrCreateHomeThread` — is not a Python concern and is +documented on the T3 side; only the wire contract those produce appears here. + +Audited surfaces, all present at that commit: + +| Surface | Location at 62e07223 | +| ---------------------------------------- | ------------------------------------ | +| `save_env_value` / `get_env_path` | `hermes_cli/config.py:8137` / `:688` | +| `load_config_readonly` | `hermes_cli/config.py:7415` | +| `skills_list` (registered tool) | `tools/skills_tool.py:785` | +| `skill_view` (registered tool) | `tools/skills_tool.py:961` | +| `build_session_key` | `gateway/session.py:1029` | +| `resolve_gateway_approval` | `tools/approval.py:2073` | +| `resolve_gateway_clarify` | `tools/clarify_gateway.py:160` | +| `register_platform` (`**entry_kwargs`) | `hermes_cli/plugins.py:931` | +| `_mark_notify_metadata` (`notify` flag) | `gateway/platforms/base.py:89` | +| Tool-hook `session_id` (= run id) | `agent/tool_executor.py:188` | +| Run-id generation | `gateway/session.py:2388` | +| `HERMES_SESSION_KEY` binding | `gateway/run.py:17367` | +| `get_session_env` accessor | `gateway/session_context.py:303` | +| Tool-thread context propagation | `agent/tool_executor.py:715` | +| Final-delivery `notify` stamp | `gateway/platforms/base.py:5220` | +| Streaming final `notify` stamp | `gateway/stream_consumer.py:328` | +| `REQUIRES_EDIT_FINALIZE` declaration | `gateway/platforms/base.py:3128` | +| Progress-loop `finalize` injection | `gateway/run.py:20777` | +| Segment-break `finalize` (flag-agnostic) | `gateway/stream_consumer.py:938` | +| Live tool-chrome delivery path | `gateway/run.py:20485` | +| `tool_progress` display resolution | `gateway/display_config.py:187` | +| `format_tool_event` (override hook) | `gateway/platforms/base.py:2740` | +| Tool-chrome dispatch (`None` == eat) | `gateway/stream_dispatch.py:108` | +| `/steer` active-run handler | `gateway/run.py:11280` | +| Home-channel notice text | `gateway/run.py:13780` | +| Active-command inline dispatch | `gateway/platforms/base.py:4926` | +| User-plugin path `$HERMES_HOME/plugins/` | `hermes_cli/plugins.py:10`, `:1350` | + +Home-channel surfaces, added for protocol v3: + +| Surface | Location at 62e07223 | +| ------------------------------------------ | ---------------------------------- | +| `cron_deliver_env_var` registration flag | `gateway/platform_registry.py:143` | +| `standalone_sender_fn` registration flag | `gateway/platform_registry.py:159` | +| Standalone-sender invocation | `tools/send_message_tool.py:741` | +| `_home_target_env_var` fallback convention | `gateway/run.py:1541` | +| `_resolve_home_env_var` (plugin lookup) | `cron/scheduler.py:1025` | +| `env_enablement_fn` `home_channel` promote | `gateway/config.py:2648` | +| `HomeChannel` dataclass | `gateway/config.py:421` | +| `get_home_channel` | `gateway/config.py:1022` | +| `get_hermes_home` (queue/state base) | `hermes_constants.py:106` | +| `get_hermes_home` re-export | `hermes_cli/config.py:686` | +| Cron run-id shape (`cron_*`) | `cron/scheduler.py:3017`, `:3484` | +| Cron session-var clearing | `cron/scheduler.py:3066-3091` | +| Cron `job_id` in routed metadata | `cron/scheduler.py:1782` | +| Cron metadata reaching `adapter.send` | `gateway/delivery.py:606` | +| Cron wrap header (`Cronjob Response: …`) | `cron/scheduler.py:1513` | +| Gateway online notice | `gateway/run.py:17277` | +| Gateway restart notice | `gateway/run.py:17236` | +| Gateway shutdown/restarting notice | `gateway/run.py:6599` | +| `/handoff` synthetic source identity | `gateway/run.py:8854` | +| `HERMES_SESSION_USER_ID` binding | `gateway/run.py:17372` | +| Session-context lifetime around a turn | `gateway/run.py:12972` → `:14626` | + +Hermes v0.19.0 had no public handoff-thread callback. Current official Hermes +main (audited at `d109785b`) exposes +`BasePlatformAdapter.create_handoff_thread(parent_chat_id, name) -> Optional[str>` +and invokes it from the handoff watcher before the synthetic transfer turn. The +T3 adapter implements that exact public method: it sends correlated +`handoff.create`, returns the T3-created thread id, and reads the public +`metadata["thread_id"]` Hermes supplies on the resulting send. Disconnect, +timeout, protocol rejection, duplicate, and late-response paths all clean up the +pending request. `None` remains the official fallback, so Hermes v0.19.0 and +older T3 servers continue delivering the handoff summary to Home. No private +handoff imports or monkey patches are used. + +This inventory describes gateway wire protocol v4. Protocol v2 added active-turn +recovery in `session.ready` and authoritative `content.snapshot` replacement; v3 +added `role` on `connection.hello`, `homeThreadId` on `connection.accepted`, and +the `home.deliver` / `home.deliver.ack` pair; v4 adds media — optional inline +`attachments` on `turn.start` / `turn.steer`, the `media.deliver` / +`media.deliver.ack` pair, and the `attachments` capability flipping to the +literal `true`. `handoff.create` / `handoff.created` are an additive v4 exchange: +an older peer never initiates it, while a new plugin receiving an +`unsupported-message` response returns Hermes' documented Home fallback. Other +version mismatches are rejected during the handshake — the version policy stays +fail-closed. + +## Mapped in the initial scope + +| Hermes surface | T3 gateway event | +| ----------------------------------------------------- | ------------------------------------------------- | +| Cumulative `send` / `edit_message` output | `content.delta` / `content.snapshot` | +| Final stream edit | `item.completed`, `turn.completed` | +| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` | +| Live adapter status text | `status_text` activity item | +| `load_config_readonly()["model"]["default"]` | Optional `model` on `connection.hello` | +| `send_exec_approval` | `request.opened` / `request.resolved` | +| `send_clarify` | `user-input.requested` / `user-input.resolved` | +| `/steer` gateway command | `turn.steer` | +| Adapter interrupt event | `turn.interrupt` | +| `load_config_readonly()["agent"]["reasoning_effort"]` | Optional `reasoningEffort` on `describe.response` | +| `skills_list()` metadata | `skills` on `describe.response` | +| `skill_view(name, preprocess=False)` | `markdown` on `skill.body.response` | +| Cron `deliver=t3`, `send_message t3`, lifecycle | `home.deliver` / `home.deliver.ack` | +| `create_handoff_thread(parent_chat_id, name)` | `handoff.create` / `handoff.created` | + +## Known limitations + +- The platform adapter receives cumulative rendered text, not the underlying + token stream category. The current adapter maps it to `assistant_text`; Hermes reasoning, + plan, and command-output stream categories are not publicly exposed here. +- Prefix-extending cumulative edits emit `content.delta`; edits that revise or + clear already-emitted text emit an authoritative `content.snapshot`. +- A handoff's thread creation and synthetic summary delivery use the companion, + because those are gateway semantics. A user reply typed in T3 remains an ACP + turn and does not continue the gateway's transferred CLI session. This is the + deliberate interactive-data-plane boundary, not an implicit fallback. +- Hermes' exact first-chat T3 home-channel notice is suppressed at the adapter + output boundary. The plugin does not assign a home channel or redirect + proactive delivery; other Hermes platform notices pass through unchanged. + This match is **exact string equality**, which is fragile: Hermes builds the + notice inline from an f-string (`gateway/run.py:13780`) rather than exporting + a constant, so any wording change upstream silently stops the suppression and + the notice reaches the transcript. Re-verified byte-for-byte at 62e07223 by + reconstructing the f-string with `platform_name="t3"` (`Platform("t3").value` + → `"t3"`, `.title()` → `"T3"`) and the non-Slack `/sethome` branch; it still + matches. A regression test pins the literal. +- Hermes' documented tool hook surface exposes a `task_id`, tool name, + arguments, string result, and duration. Verified at 62e07223: the runtime + additionally supplies `session_id`, `tool_call_id`, `turn_id`, + `api_request_id`, and `middleware_trace` on both hooks + (`hermes_cli/plugins.py:2146` for `pre_tool_call`, `model_tools.py:1050` for + `post_tool_call`), and `post_tool_call` also supplies `status`, `error_type`, + and `error_message`. The adapter consumes `session_id`, `tool_call_id`, and + `status` when present and falls back to the documented IDs for older + versions. It projects only canonical, whitelisted fields (command/cwd, file + path, search query, image path, or MCP server/operation); arbitrary arguments + and raw results never cross the wire. +- `post_tool_call` passes `result` as `Any`, not a guaranteed `str` — the + adapter never forwards it, so the looser type is inert here. +- **The tool hooks' `session_id` is not this plugin's session id.** Hermes + passes `agent.session_id` (`agent/tool_executor.py:188`, `:305`, `:341`), + which the gateway sets from `SessionEntry.session_id` — a timestamped run id + like `20260725_143012_ab12cd34` (`gateway/session.py:2388`, + `agent/agent_init.py:1446-1453`). This plugin's session ids come from + `build_session_key` (`gateway/session.py:1029`) and are shaped + `agent:main:t3:dm:`. The two namespaces never intersect, so keying + the thread lookup on the hook's value alone matched nothing and silently + dropped every tool activity item. This is the same class of defect as the + `finalize` bug — keying behaviour off a Hermes-supplied value whose meaning + was assumed rather than verified. `_turn_for_tool_hook` now resolves in three + steps: the raw `session_id` as a routing key (free, and correct if upstream + ever passes the gateway key here), then `HERMES_SESSION_KEY` from Hermes' + session context (`gateway/run.py:17367` → + `gateway/session_context.py:200`, read via `get_session_env` at `:303`), + which IS the `build_session_key` value and is propagated into the tool worker + threads by `propagate_context_to_thread` (`agent/tool_executor.py:715`), then + the sole active turn when exactly one exists. With two or more concurrent + turns and no routing key it emits nothing rather than misattributing activity + to the wrong thread. Every step is best-effort and cannot raise: tool + activity is decorative and must never break a turn. + + Regression shape if upstream changes: if `HERMES_SESSION_KEY` stops being + bound or stops propagating into tool threads, a **multi-thread** Hermes loses + tool activity rows (single-thread still works via the sole-turn fallback). + Turn lifecycle is unaffected either way — tool items are decorative. + +- Approval resolution is session-FIFO in Hermes. T3 request IDs identify the UI + prompt, then resolve the oldest matching Hermes approval for that session. +- The public `clarify` hook is a single question. The wire protocol supports an + array so richer structured input can be added without a protocol break. +- Hermes session completion has no dedicated platform-adapter callback. The + plugin uses `notify=True` metadata on `send` as the authoritative completion + boundary (`_mark_notify_metadata`, `gateway/platforms/base.py:89`). It + explicitly does **not** use `finalize=True` on `edit_message`, which upstream + sets on every mid-turn tool-progress edit and every stream segment break — + see "Turn completion is keyed off `notify`, never `finalize`" below. +- Active `/steer` dispatch returns a textual Hermes control acknowledgement + through the normal platform `send(..., notify=True)` path + (`gateway/platforms/base.py:4926`). The plugin captures that response in the + originating steering request's async context and suppresses it from the + transcript. Because a steer targets a _running_ turn, the capture is + correlated by the steering `requestId` — which the base adapter passes back + as `reply_to` via `_reply_anchor_for_event` — and not by `chat_id`. Genuine + assistant output emitted on the same thread during the steer window carries a + different correlation id and reaches the transcript untouched. +- The plugin acknowledges T3 only when the audited Hermes success response + begins with `⏩ Steer queued`. That prefix is likewise matched against an + inline f-string (`gateway/run.py:11280`) rather than an exported constant, so + it carries the same drift risk as the home-channel notice. Confirmed present + at 62e07223. Unknown future response shapes fail closed with `protocol.error` + rather than completing the turn. +- Hermes' configured default model is read once per handshake from the + documented read-only accessor `load_config_readonly()["model"]["default"]`. + That accessor returns the shared process-wide config cache and its docstring + forbids mutation, so the plugin copies out only a trimmed string. Any failure + — missing key, import error, older Hermes — omits the optional `model` field + from `connection.hello` rather than sending null or empty. +- Hermes' configured reasoning effort is read from + `load_config_readonly()["agent"]["reasoning_effort"]` on every + `describe.request`, with the same discipline as the model read above: a + trimmed string copy, no mutation of the shared cache, and any failure omits + the optional `reasoningEffort` field rather than sending null or empty. Note + this is the _global_ effort. Hermes also supports + `agent.reasoning_overrides` (per-model) and `delegation.reasoning_effort` + (subagents); neither is resolved here, so a user with a per-model override + active sees the global value on the Agent page. +- Skills are enumerated through the registered `skills_list()` tool surface + (`tools/skills_tool.py:785`), not the private `_find_all_skills()` scanner + behind it. Consequences of that choice, all verified at 62e07223: + - `skills_list()` already applies Hermes' disabled-skill, platform, and + environment filters, so **disabled skills are absent from the list rather + than reported with `enabled: false`**. The wire field is always `true`. + Reporting disabled skills would require `_find_all_skills(skip_disabled=True)` + plus `hermes_cli.skills_config.get_disabled_skills()` — a private scanner + and a config-mutating module — so T3 shows what this Hermes would actually + load, not the full on-disk inventory. + - The surface publishes only `name`, `description`, and `category`. There is + **no path or install-source field**: `category` is the nearest published + analogue and is sent as `source`. The real on-disk path is available only + from `skill_view()` per skill, so it is not eagerly fetched. + - The list reflects `~/.hermes/skills/` plus configured `skills.external_dirs`, + and is served from a 30s in-process cache keyed on a directory-mtime and + disabled-set signature. A skill added seconds before a `describe.request` + may be one refresh late. + - MCP servers are not reported at all. Hermes has no public enumeration + surface for them at this commit, and the T3 contract omits the field in v1. +- Skill bodies are read with `skill_view(name, preprocess=False)`. Preprocessing + is disabled deliberately: T3 renders the skill for a human to read, so the + literal authored markdown is wanted rather than Hermes' template and + inline-shell rendering of it — the latter executes shell fragments embedded in + the skill, which must not happen merely because a user expanded a row. Bodies + are truncated at 512 KiB. Any failure — unknown name, ambiguous name across + `external_dirs`, unreadable file, older Hermes — replies with `markdown: null` + rather than an error, so the UI renders "no body available" instead of a + protocol failure. The plugin calls `skill_view` directly rather than the + registered `_skill_view_with_bump` handler, so a T3 body fetch does **not** + bump that skill's view/use counters (`tools/skill_usage.py`) — browsing an + agent's skills in T3 must not look like the agent loading one, since + `last_used_at` is what Hermes' curator keys its stale-skill timer off. +- Neither describe frame can fail the connection over a _Hermes_ problem. + Every Hermes-sourced read degrades — omitted optional field, empty skill + list, or null markdown — so a `describe.request` against an older or + partially-broken Hermes yields a thinner reply, never a `protocol.error`. + The one exception is a malformed request: `skill.body.request` with no + `skillName` cannot be answered, because the response echoes the name back + and the wire type is non-empty. That takes the ordinary correlated + `protocol.error` path. +- Attachments are part of protocol v4; the capability is fixed to `true` + (T3's schema pins the literal, so a plugin that cannot handle them is a v3 + plugin and is rejected at the version gate). Inbound, `turn.start` / + `turn.steer` may carry inline base64 files (≤25MiB each): turn-start files + are written to private temp files and ride `MessageEvent.media_urls` / + `media_types` into Hermes' own enrichment pipeline; steer files are + appended to the injected `/steer` text as path notes, because Hermes' + steer handler injects only text between tool iterations + (`gateway/run.py:11254`). Outbound, the adapter overrides + `send_image_file` / `send_video` / `send_voice` / `send_document` to emit + `media.deliver` frames (raw bytes ≤25MiB, base64 on the wire) with the same + durable queue-then-ack lifecycle as `home.deliver`; the + `standalone_sender_fn` sends `media_files` the same way, and + `force_document` remains signature parity only — T3 derives rendering from + `mimeType`, so there is no document/photo distinction to force. +- **Kind/label classification is heuristic.** `adapter.send()` carries no + structured "this is a cron delivery" marker on every path, so the plugin + reads what does exist (see "Home-channel delivery" below). A + misclassification costs a wrong badge — and, for `lifecycle`, a delivery that + raises its hand when it should have landed quietly — never a lost delivery. + If upstream ever exposes delivery provenance in metadata, adopt it and + replace the heuristics here. + +## Turn completion is keyed off `notify`, never `finalize` + +**A previous revision of this document blamed `format_tool_event` for the +early-turn-truncation bug. That diagnosis was wrong.** It is corrected here; +the real cause and the real completion signal are documented below. + +### The signal that ends a turn: `notify=True` on `send` + +`_mark_notify_metadata` (`gateway/platforms/base.py:89`) stamps `notify: True` +onto the metadata of a send, and the gateway applies it **only** for genuine +user-visible replies: + +- the final response delivery (`gateway/platforms/base.py:5220`, consumed at + `:5261`, `:5330`, `:5376`, `:5418`, `:5433`-`:5469`), +- slash-command acknowledgements (`:4827`, `:4934`, `:4987`), +- and, in the streaming path, `StreamConsumer._metadata_for_send(final=True)` + (`gateway/stream_consumer.py:328-329`). + +`send(..., metadata={"notify": True})` is therefore the plugin's completion +boundary, and `_complete_turn` is reached from nowhere else on the output path. + +### The signal that does NOT end a turn: `finalize=True` on `edit_message` + +`finalize` reads like "last edit of the response", and the base class documents +it that way (`gateway/platforms/base.py:3176-3183`). It is **not** a turn +boundary. Two upstream paths set it mid-turn: + +1. **The tool-progress loop.** When an adapter declares + `REQUIRES_EDIT_FINALIZE`, `_edit_progress_message` passes `finalize=True` on + **every** progress-bubble edit (`gateway/run.py:20777-20780`) — once per tool + event, for the whole turn. Nothing about that edit is final. +2. **The stream consumer's segment breaks.** `_send_or_edit` is called with + `finalize=(got_done or got_segment_break)` + (`gateway/stream_consumer.py:938-940`), so every mid-turn tool/segment + boundary finalizes the current content message. This path is + **independent of `REQUIRES_EDIT_FINALIZE`** — setting the flag to `False` + does not suppress it. + +This plugin previously declared `REQUIRES_EDIT_FINALIZE = True` and treated +`finalize=True` in `edit_message` as "turn finished", calling `_complete_turn`. +Consequently the **first tool call ended the T3 turn while Hermes was still +working**: the transcript kept the progress chrome ("📚 Reading skill +hermes-agent 🔍 Searching the web for …") as the assistant's entire answer, and +every subsequent send failed with `Send failed: no active T3 turn — trying +plain-text fallback` in the gateway log. The real answer never arrived. + +The fix is twofold, and both halves are needed because of path (2) above: + +- `REQUIRES_EDIT_FINALIZE = False` — declaring it only arms path (1). T3 closes + an item on `item.completed`, which this plugin emits itself; it has no + rich-card streaming state that needs an explicit close. +- `edit_message` ignores `finalize` outright (`del metadata, finalize`) and + never calls `_complete_turn` — this is what defends against path (2). + +`test_tool_progress_bubble_edits_never_complete_the_turn` pins both legs: +it replays the gateway's `_edit_progress_message` closure verbatim and a +segment-break finalize, asserts the turn survives every one, then asserts a +single `notify=True` send completes it exactly once. + +**Regression shape if upstream changes.** If a future Hermes makes `finalize` +genuinely mean "turn over" and removes the mid-turn uses, this plugin will +simply never see a completion via that route — harmless, since `notify` still +fires. The dangerous direction is the inverse: if `_mark_notify_metadata` stops +being applied to the final delivery (or the streaming path stops calling +`_metadata_for_send(final=True)`), turns would **never complete** — T3 threads +would hang in the running state with the full answer streamed but no +`turn.completed`. That is the opposite failure mode from the original bug and +would show up as spinners that never resolve, not truncated answers. + +## Home-channel delivery: the gate is provenance, not turn absence + +Hermes-initiated output — cron results, `send_message` with a bare `t3` target, +gateway lifecycle notices, `/handoff t3` — has no T3-issued turn to stream into. +It is emitted as `home.deliver` against the instance's durable home thread. + +### The deadlock this design exists to avoid + +The naive rule — "no active turn for this thread → deliver" — is wrong, and +wrong in the same keyed-off-the-wrong-signal way as the `finalize` bug above. +When the home thread itself has a live user turn, a cron delivery targeting it +would fall into the active-turn path, stream as that turn's assistant content, +and — because final cron deliveries arrive notify-stamped via +`_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete the user's +live turn with the cron output as its answer**. + +The discriminator is `HERMES_SESSION_KEY`. The gateway binds it onto the turn's +context for the whole handler (`gateway/run.py:12972` → `:14626`, read via +`get_session_env`), and every send a turn produces — streamed or final — happens +inside that scope, so a genuine turn reply resolves to this plugin's +`build_session_key` id for its thread. Cron runs under its own +`cron__` session with the gateway routing keys explicitly +cleared (`cron/scheduler.py:3066-3091`), and lifecycle broadcasts run in no +session at all. + +`_is_proactive_delivery` therefore decides in this order: + +1. Session key matches an active turn on this thread → **turn content**, never + a delivery. Checked first, so a turn reply can never be rerouted. +2. Not the home thread → never a delivery. "Message any thread unprompted" + stays out of scope and the existing `"no active T3 turn"` error is returned + verbatim. +3. Home thread, no active turn → delivery. There is nothing it could belong to. +4. Home thread **with** a non-matching active turn → delivery only when + provenance is positively established. This is the conservative half: an + unattributable send in that window stays with the turn (at worst misplaced + inside the same thread) rather than being torn out of a turn it may belong + to. So an unclassifiable send can never steal a live answer, and a + recognisable cron/lifecycle/handoff delivery never completes one. + +Ordering inside `send()` is load-bearing: `_capture_steer_control_response` +stays first, because steer acknowledgements arrive with `notify=True` and must +never be read as deliveries. + +`edit_message` has **no** proactive branch and keeps returning `"no active T3 +turn"` outside a turn. A delivery is an atomic document, not a streaming +surface. If an upstream path ever streams a home delivery, revisit with a +`home.deliver`-supersedes-by-`deliveryId` scheme rather than edit frames. + +### What classification keys off + +All best-effort, in precedence order, all degrading to +`("message", "Hermes", uncertain)`: + +- `metadata["job_id"]` — the only structured signal. The cron scheduler stamps + it into the routed metadata (`cron/scheduler.py:1782`) and + `DeliveryRouter._deliver_to_platform` passes the dict through to + `adapter.send` unchanged (`gateway/delivery.py:606`). +- The cron wrap header `Cronjob Response: ` (`cron/scheduler.py:1513`), + present whenever `cron.wrap_response` is on (the default), which also + supplies the human job name for the badge. +- Lifecycle literals: `gateway/run.py:17277`, `:17236`, `:6599`. These are + inline f-strings upstream, not exported constants, so they carry the same + drift risk as the `/sethome` notice and the `⏩ Steer queued` prefix. +- `HERMES_SESSION_USER_ID == "system:handoff"`, the synthetic source identity + `/handoff` dispatches under (`gateway/run.py:8854`, bound at `:17372`). + +### Registration contracts + +- **`cron_deliver_env_var="T3_HOME_CHANNEL"`.** The name is not free-form. + `_home_target_env_var` (`gateway/run.py:1541`) consults built-ins, then the + plugin registry via `_resolve_home_env_var` (`cron/scheduler.py:1025`), then + falls back to `f"{PLATFORM.upper()}_HOME_CHANNEL"` — exactly this string for + platform `t3`. Matching the fallback means `send_message`'s error hints and + cron's env-only resolution agree with what the plugin writes, with no + upstream override-table entry. Without the flag, `deliver=t3` is silently + dropped by cron. +- **`env_enablement_fn` seeds `home_channel`.** That key is magic: core pops it + out of the returned dict and promotes it to a real `HomeChannel` dataclass + (`gateway/config.py:2648-2660`, reading only `chat_id` / `name` / + `thread_id`). The promotion is what makes `get_home_channel("t3")` + (`gateway/config.py:1022`) resolve, which is what makes `send_message`, + lifecycle broadcasts, and `/handoff` work — core hardcodes env promotion only + for built-ins. T3 threads are the addressing unit, so `chat_id` **is** the + thread id and `thread_id` stays unset. +- **`standalone_sender_fn`.** Out-of-process cron has no live adapter + (`tools/send_message_tool.py:741`). The plugin dials T3 itself over a + short-lived socket announcing `role: "delivery"`. That role is load-bearing: + T3's broker registers a `gateway` connection under generation fencing and + displaces its predecessor, so a cron dial-in announcing the default role + would kick the live gateway socket off its own instance mid-turn. + +### Designation is a synced cache, not local state + +`T3_HOME_CHANNEL` is written by the plugin, never by the user. T3's settings +blob is authoritative and republishes `homeThreadId` on every +`connection.accepted`; the plugin compares and persists via `save_env_value` +(the same profile-aware helper enrollment uses) and mirrors into `os.environ` +so a running gateway needs no restart. A hand-edited value is overwritten on +the next reconnect — documented in the README. A read-only or managed `.env` +degrades to the in-process mirror only: routing works for the life of the +process and re-reconciles on the next connect. + +### Queue and state location + +The plugin previously persisted nothing to disk. It now keeps one JSONL outbox +at `/gateway/t3_home_delivery_queue.jsonl`, using +`get_hermes_home()` (`hermes_constants.py:106`, re-exported at +`hermes_cli/config.py:686`) as the base — the same accessor and the same +`gateway/` subdirectory Hermes' own Discord adapter uses for per-profile +adapter state (`plugins/platforms/discord/adapter.py:52`, `:272`, `:1694`). +Resolving through that accessor rather than `~/.hermes` makes the queue +profile-scoped: a second profile cannot replay another profile's deliveries +into its own home thread. + +Correctness rests on one rule: an entry is removed **only** on the matching ack +(`home.deliver.ack` for text or `media.deliver.ack` for media). Everything else +— a socket that dropped mid-send, a server +that died before writing, a plugin restart — leaves the entry to be replayed, +which is safe because T3 dedupes on `deliveryId`. Acking before the durable +write on the server side would break this. The queue is capped at 300 entries +and 256MiB total, dropping oldest-first with a logged warning; retention beyond +those bounds is therefore not guaranteed. One flush replays +at most 50 entries or 100MiB so a reconnect does not stall live traffic. + +### Cron tool-hook misattribution + +`_turn_for_tool_hook`'s sole-active-turn fallback is now skipped for cron runs. +The hooks are process-global, so a cron job running tools while exactly one T3 +turn happens to be live would resolve through that fallback and paint the cron +job's tool calls into an unrelated live conversation. Cron runs are identifiable +by the `cron__` session id the scheduler mints +(`cron/scheduler.py:3017`, passed to the agent at `:3484`) — the exact value the +hooks receive. Upstream treats the same routing hazard as real, clearing the +process-global session env vars for it (`cron/scheduler.py:3066-3091`). A cron +job's activity belongs to the eventual `home.deliver`, never to a live turn. + +Prefix matching carries the usual drift risk: if upstream renames the shape, +this degrades to the previous behaviour (cron tool rows may again be +misattributed to a sole live turn) rather than breaking anything. + +## Tool-progress chrome: the `format_tool_event` override is not the defence + +The plugin overrides `format_tool_event` to return `None` +(`gateway/platforms/base.py:2740`), which `gateway/stream_dispatch.py:108` +documents as "adapter chose to eat this event". T3 already renders tool calls as +typed `item.started` / `item.completed` activity from the `pre_tool_call` / +`post_tool_call` hooks, so the text line is a strictly poorer duplicate. + +**At 62e07223 this hook is dead code on the live path.** Its only caller is +`GatewayEventDispatcher` (`gateway/stream_dispatch.py:40`, dispatch at `:108`), +and that class is referenced nowhere in the shipped gateway — only from +`tests/gateway/test_stream_events.py`. The path that actually runs is +`gateway/run.py:20485+`, which builds the same emoji lines itself and delivers +them via `adapter.send` / `adapter.edit_message`, with **no adapter hook to +suppress them**. Chrome visibility there is governed by the platform's +`tool_progress` display setting (`gateway/display_config.py:187`), not by this +override. + +The override is kept as documented-contract defence: it costs nothing and +becomes load-bearing again if upstream routes chrome through the dispatcher. But +it never protected the turn — ignoring `finalize` does. diff --git a/integrations/hermes-t3-gateway/README.md b/integrations/hermes-t3-gateway/README.md new file mode 100644 index 000000000000..cb59b31eb634 --- /dev/null +++ b/integrations/hermes-t3-gateway/README.md @@ -0,0 +1,208 @@ +# Hermes T3 Code Gateway + +Optional companion for connecting one already-running Hermes process to T3 +Code. It makes an outbound WebSocket connection; Hermes does not listen on a +public port. + +## ACP versus companion boundary + +Ordinary interactive conversations use T3's built-in **`hermes-acp`** provider. +This companion only handles enrollment and proactive Home delivery (cron, +`send_message`, lifecycle notices, media, and handoff). Current Hermes releases +call the plugin's public `BasePlatformAdapter.create_handoff_thread` callback; +T3 creates a dedicated thread and the companion delivers Hermes' synthetic +handoff response there. Gateway `turn.start` and `turn.steer` commands receive a +recoverable error and never invoke Hermes. The platform callbacks remain +implemented because public Hermes delivery APIs use the adapter; they are not +an alternative interactive runtime. + +The gateway wire protocol is v4. The T3 server and Hermes plugin must be updated +together; mismatched versions fail the connection handshake closed. + +The T3 server owns the companion's Home and handoff-delivery threads. +`hermes-acp` independently owns interactive thread and session identity. In +particular, a reply typed in T3 after a handoff is an ordinary ACP turn; it does +not travel back over the companion socket or mutate the CLI session Hermes +handed to its gateway. The handoff's companion-routed operation is thread +creation plus Hermes' synthetic transfer/summary delivery. + +## Install from this repository + +Run the install script. It symlinks this directory into the active Hermes +profile's user-plugin directory and enables the plugin: + +```bash +./integrations/hermes-t3-gateway/install.sh +``` + +The script is safe to re-run: an existing correct symlink is left in place, and +enabling an already-enabled plugin is a no-op. It installs into +`$HERMES_HOME/plugins/` when `HERMES_HOME` is set, and `~/.hermes/plugins/` +otherwise. It fails with instructions if `hermes` is not on `PATH`, and refuses +to replace a real directory already sitting at the target path. + +In T3 Code, add or open a Hermes provider instance, expand its **Hermes +companion** section, create a one-time enrollment, and copy the generated +command. It has this shape: + +```bash +hermes t3 connect \ + --url https://t3.example.com \ + --token +``` + +`--url` accepts an HTTP(S) browser origin or an explicit WS(S) URL. The command +normalizes it to `/api/hermes-gateway/ws`, enrolls over the first authenticated +`connection.hello` frame, and saves these values with Hermes' +profile-aware `save_env_value` helper: + +Use **HTTPS/WSS for every connection that leaves the local machine**. The +one-time enrollment token and long-lived instance credential authenticate the +companion and must not cross an untrusted network over cleartext HTTP/WS. +Plain HTTP/WS is intended only for loopback development or a separately secured +private tunnel. + +- `HERMES_T3_GATEWAY_URL` +- `HERMES_T3_GATEWAY_INSTANCE_ID` +- `HERMES_T3_GATEWAY_CREDENTIAL` +- `HERMES_T3_GATEWAY_NICKNAME` + +The long-lived credential is never printed. Run `hermes gateway restart` after +enrollment. `hermes t3 status` reports the local enrollment without revealing +the credential. + +The handshake also reports Hermes' configured default model so T3 can show a +truthful label in its picker. It is read-only — Hermes owns model selection — +and is omitted entirely if it cannot be read. + +## The Home thread + +Every enrolled instance gets one **Home** thread in T3, created automatically — +there is nothing to set up and nothing to choose. It receives all of Hermes' +proactive output: cron results (`deliver=t3`), the agent's `send_message` tool +with a bare `t3` target, gateway online/shutdown notices, and `/handoff t3`. +Use a `hermes-acp` thread to converse with Hermes; Home is a delivery inbox. + +On Hermes versions exposing the documented +`create_handoff_thread(parent_chat_id, name)` callback, `/handoff t3` asks T3 to +create a fresh thread under the same synthetic agent project and sends the +handoff summary there. T3 accepts the request only when `parent_chat_id` is the +instance's authoritative Home thread, and accepts subsequent handoff delivery +only for a thread owned by that instance's agent project. Duplicate creation +requests resolve to the same deterministic thread. If the connection drops, +the request times out, or an older Hermes/T3 peer lacks the additive callback, +the plugin returns the official `None` fallback and Hermes delivers to Home +instead; no handoff watcher is left waiting. + +**T3 owns the designation, and `T3_HOME_CHANNEL` is a synced cache of it.** The +plugin writes that variable itself: T3 republishes the home thread id on every +successful handshake, and the plugin compares and persists it with Hermes' +profile-aware `save_env_value` helper. A hand-edited `T3_HOME_CHANNEL` will +therefore be **overwritten on the next reconnect** — to move the Home thread, +change it in T3, not in `.env`. (`/sethome` is likewise inert for this platform: +the designation is fixed, so Hermes' "set a home channel" nudge is suppressed.) + +Deliveries use a bounded durable outbox. Each one is written to a JSONL +queue at `/gateway/t3_home_delivery_queue.jsonl` before it is sent +and removed only once T3 acknowledges its matching frame type +(`home.deliver.ack` for text, `media.deliver.ack` for media). Queued deliveries +survive restarts and flush on the next connect, and T3 deduplicates replays. +The queue drops oldest-first with a logged warning at its bounds, so retention +beyond those bounds is not guaranteed. The defaults are +300 entries and 256MiB total; one reconnect flushes at most 50 entries or +100MiB so backlog replay cannot starve liveness traffic. + +Cron works whether or not the gateway is co-resident. When `hermes cron` runs in +its own process there is no live adapter, so the plugin dials T3 over a +short-lived delivery connection — authenticated the same way, but never +registered as the instance's primary connection, so it cannot disturb a running +`hermes gateway`. If T3 is unreachable the delivery is queued and the cron job +still reports success. + +Attachments ride the same queue-then-ack durability as text: one `media.deliver` +frame per file, each carrying the file's bytes rather than its path, so a +delivery that flushes after an outage still works when the original temp file is +long gone. The raw ceiling is 25MiB per file. A file that cannot be read or that +exceeds the ceiling is reported in the result's `detail` and skipped rather than +queued — a frame T3 would reject forever must not sit in the outbox forever. +Every successful send result also reports `media_count`, `acked_count`, and +`delivery_ids`. + +## Companion scope + +- Reconnect with bounded backoff +- Version-incompatible and revoked credentials fail closed +- Proactive delivery into the Home thread: cron, `send_message`, lifecycle + notices — with a durable queue and out-of-process cron support +- `/handoff` thread creation through the official platform callback, with + correlated timeout/reconnect cleanup and deterministic server idempotency +- Outbound attachments: `MEDIA:` files from cron, `send_message`, and `/handoff` + are delivered as `media.deliver` frames + +MIME-typed attachments on ordinary interactive prompts belong to the separate +ACP transport, not this companion socket. + +Except for a server-created handoff destination, non-Home T3 threads remain +session-only: Hermes cannot message them unprompted, and an unsolicited send to +one still fails with `no active T3 turn`. + +Attachments are pinned to `true`. It is part of the v4 contract rather than a +negotiated option — T3's schema fixes the capability at that literal, so a plugin +that cannot handle attachments is by definition a v3 plugin and is rejected at +the version gate. The retained gateway turn validators materialize inbound +files privately for API compatibility, but T3 does not issue interactive turn +frames to the companion. Outbound companion files leave as `media.deliver` +frames. + +## Upstream core bugs this plugin works around + +Hermes core decides media support for `send_message` from a hard-coded list of +platform names rather than from a platform capability, so a plugin platform that +delivers media perfectly well is still treated as if it cannot. Two consequences, +both against **v0.19.0**: + +- **A false warning.** `tools/send_message_tool.py:1108` builds `"MEDIA +attachments were omitted for t3; ..."` whenever a send carries files and the + platform is off that list, and line 1154 appends it to _any_ successful result + without checking whether anything was actually dropped. Left alone, the tool + output tells the agent the files were lost immediately after T3 acknowledged + them — which is exactly how a live agent came to report a delivery failure for + files the user could already see. +- **A silent drop.** `tools/send_message_tool.py:711`, taken when the gateway is + co-resident with the caller, invokes `adapter.send(chat_id, content, metadata)` + and returns. `media_files` is never passed, and the `MEDIA:` directives were + already stripped out of `content` upstream at line 442, so the attachments are + simply gone — no error, no warning. Out-of-process sends escape this only + because they fall through to the plugin's standalone sender instead. + +`coreshim.py` compensates for both in-process at plugin load: co-resident `t3` +sends carrying media are rerouted through the plugin's own sender, media-only +sends are rescued from the related hard error at line 1101, and the false warning +is stripped by stable prefix. Everything else — every other platform, every +text-only send — reaches the original untouched. + +**Residual caveat.** The shim is deliberately fail-open: it feature-detects each +target function and, on any signature or shape mismatch, logs one warning and +leaves core alone rather than risking a crash on an upstream upgrade. When that +happens the two bugs return as described above. The accounting keys on every +send result (`media_count`, `acked_count`, and a note naming the delivered file +count) are the backstop — they sit in the same JSON as any stale warning and +contradict it directly. Grep the logs for `leaving it unpatched` to detect it. +The whole module is removable once upstream drives media handling from platform +capabilities instead of the hard-coded list. + +See [COMPATIBILITY.md](./COMPATIBILITY.md) for public Hermes extension-surface +limitations. + +## Tests + +The pure protocol and transport tests do not require a live Hermes or T3 server: + +```bash +python -m unittest discover \ + integrations/hermes-t3-gateway/tests \ + -p 'test_*.py' + +python -m ruff check integrations/hermes-t3-gateway +sh -n integrations/hermes-t3-gateway/install.sh +``` diff --git a/integrations/hermes-t3-gateway/__init__.py b/integrations/hermes-t3-gateway/__init__.py new file mode 100644 index 000000000000..c8a5dac7356b --- /dev/null +++ b/integrations/hermes-t3-gateway/__init__.py @@ -0,0 +1,105 @@ +"""T3 Code gateway plugin registration for Hermes Agent.""" +# ruff: noqa: N999 - Hermes loads hyphenated plugin directories dynamically. + +from __future__ import annotations + +from .adapter import ( + T3PlatformAdapter, + check_requirements, + env_enablement, + validate_config, +) +from .cli import register_cli, t3_command +from .coreshim import apply as apply_core_shim +from .home import HOME_CHANNEL_ENV, standalone_send + + +def _pre_tool_call( + tool_name: str, + args: dict, + task_id: str, + **kwargs, +) -> None: + session_id = str(kwargs.get("session_id") or task_id) + tool_call_id = str(kwargs.get("tool_call_id") or "") + T3PlatformAdapter.route_tool_started(tool_name, args, session_id, tool_call_id) + + +def _post_tool_call( + tool_name: str, + args: dict, + result: str, + task_id: str, + duration_ms: int | None = None, + **kwargs, +) -> None: + del args + session_id = str(kwargs.get("session_id") or task_id) + tool_call_id = str(kwargs.get("tool_call_id") or "") + status = str(kwargs.get("status") or "") + T3PlatformAdapter.route_tool_completed( + tool_name, result, session_id, duration_ms, tool_call_id, status + ) + + +def register(ctx) -> None: + ctx.register_platform( + name="t3", + label="T3 Code", + adapter_factory=lambda config: T3PlatformAdapter(config), + check_fn=check_requirements, + validate_config=validate_config, + required_env=[ + "HERMES_T3_GATEWAY_URL", + "HERMES_T3_GATEWAY_INSTANCE_ID", + "HERMES_T3_GATEWAY_CREDENTIAL", + ], + env_enablement_fn=env_enablement, + # Cron home-channel delivery. The name is not free-form: Hermes + # resolves a platform's cron home target through `_home_target_env_var` + # (`gateway/run.py:1541`), which falls back to + # f"{PLATFORM.upper()}_HOME_CHANNEL" for any platform without a + # built-in override entry — exactly this string for platform `t3`. So + # `send_message`'s error hints, `/sethome` messaging, and cron's + # env-only resolution all agree with the value the plugin writes, with + # no upstream override table entry. Without this, `deliver=t3` is + # silently dropped by cron. + cron_deliver_env_var=HOME_CHANNEL_ENV, + # Out-of-process cron delivery: when `hermes cron` runs in a separate + # process from `hermes gateway` there is no live adapter, and without + # this hook `deliver=t3` fails with "No live adapter for platform". + # Dials T3 over a short-lived `role: "delivery"` socket so it cannot + # displace the live gateway connection. + standalone_sender_fn=standalone_send, + max_message_length=120_000, + emoji="🔺", + pii_safe=True, + platform_hint=( + "You are chatting through T3 Code. Preserve normal Hermes behavior; " + "T3 renders streamed text, tool activity, approvals, and questions." + ), + ) + ctx.register_cli_command( + name="t3", + help="Pair and inspect the T3 Code gateway", + setup_fn=register_cli, + handler_fn=t3_command, + description=( + "Connect this Hermes process to a named T3 Code provider instance." + ), + ) + ctx.register_hook("pre_tool_call", _pre_tool_call) + ctx.register_hook("post_tool_call", _post_tool_call) + # Compensate two upstream `send_message` media defects in-process (see + # `coreshim.py` for the file:line analysis). Applied after the platform is + # registered because the Bug B wrapper routes through `standalone_send`, + # which resolves the same enrollment the entry above advertises. This runs + # in every process that loads plugins — `hermes gateway`, `hermes cron`, and + # the `hermes send` CLI, which reaches `register()` via + # `tools/send_message_tool.py:399` -> `gateway/config.py:2530` well before + # it routes a send. Never raises: on any mismatch it logs one warning and + # leaves core untouched. + apply_core_shim() + + +__all__ = ["register"] diff --git a/integrations/hermes-t3-gateway/adapter.py b/integrations/hermes-t3-gateway/adapter.py new file mode 100644 index 000000000000..db4db749857d --- /dev/null +++ b/integrations/hermes-t3-gateway/adapter.py @@ -0,0 +1,2011 @@ +"""Hermes platform adapter that treats each T3 thread as one Hermes session.""" + +from __future__ import annotations + +import asyncio +import contextvars +import json +import logging +import os +import re +import tempfile +import time +import uuid +import weakref +from collections.abc import Coroutine +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) +from gateway.session import build_session_key + +from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV +from .connection import T3GatewayConnection, dependency_available +from .home import ( + HOME_CHANNEL_ENV, + MAX_FLUSH_BYTES_PER_CONNECT, + MAX_FLUSH_PER_CONNECT, + HomeDeliveryQueue, + build_delivery, + build_media_delivery, + classify_delivery, + home_thread_id, + save_home_thread_id, +) +from .protocol import ( + PROTOCOL_VERSION, + canonical_tool_data, + canonical_tool_item_type, + describe_response, + frame, + iso_now, + item_id, + protocol_error, + skill_body, + skill_body_response, + turn_attachments, + validate_server_frame, +) + +logger = logging.getLogger(__name__) + +_T3_HOME_CHANNEL_NOTICE = ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, or ignore to skip." +) + +# T3's canonical item type for a free-form provider status line. Deliberately +# not `unknown`: that value is the "could not classify this" sentinel other +# adapters rely on being inert, so routing status text through it made stray +# activity rows appear in unrelated provider threads. T3 renders these rows +# preferring `detail` over `title`, so the live status string is sent as +# `detail`. +_STATUS_ITEM_TYPE = "status_text" + +# How long a just-completed turn stays an acceptable scope for its own media. +# +# The window exists because the base adapter's delivery pipeline sends a +# reply's final TEXT before the reply's media files, and that text is +# notify-marked — so it completes the T3 turn, and every file of the same +# reply then arrives against a thread with no active turn +# (`gateway/platforms/base.py:5326` text, then `:5373+`/`:5424+` media). +# +# Sized against what actually separates the two: the live repro measured 36ms, +# and the only deliberate spacing upstream inserts is `_get_human_delay()` +# (`gateway/platforms/base.py:5051`), whose widest configured mode is 2.5s per +# file. 30s covers a slow batch of large files with generous headroom while +# staying far below any plausible human follow-up: the window closes long +# before the user could read the answer and ask something new, and it is a +# *scope* window only — it never keeps a turn alive or re-completes one. +_RECENT_TURN_MEDIA_WINDOW_SECONDS = 30.0 + +# `create_handoff_thread` is called inline by Hermes' handoff watcher. Bound +# the correlated request below the server's own 30s request timeout so a lost +# response cannot park that watcher forever; returning None is the documented +# BasePlatformAdapter fallback to the configured parent chat. +_HANDOFF_CREATE_TIMEOUT_SECONDS = 20.0 + + +def _hermes_version() -> str: + try: + from hermes_cli import __version__ + + return str(__version__) + except Exception: # noqa: BLE001 - version discovery must not block loading + return "unknown" + + +# Characters allowed to survive from a client-supplied filename into a temp +# file name. Everything else is dropped: the name arrived over the wire and +# must never influence the directory the file lands in. +_ATTACHMENT_NAME_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+") + + +def _materialize_attachments( + attachments: list[dict[str, Any]], +) -> tuple[list[str], list[str]]: + """Write inbound turn attachments to private temp files. + + Returns `(paths, mime_types)` aligned by index — the exact shape + `MessageEvent.media_urls` / `media_types` expect. + + Each turn gets its own `mkdtemp` directory (mode 0700) and each file is + created with `mkstemp` (mode 0600), so nothing is readable by other users + even mid-write. The extension is preserved from the wire `name` — after + sanitizing, because that name is client-supplied — since Hermes routes + files by suffix in several places (`should_send_media_as_audio`, the + text-document allowlist). The files are deliberately not deleted here: + Hermes reads them asynchronously during the turn (vision, STT, terminal + tools), there is no turn-end hook on this surface, and the OS tmp reaper + is the documented cleanup — the same pre-existing no-GC stance as T3's + attachment store. + """ + if not attachments: + return [], [] + directory = tempfile.mkdtemp(prefix="hermes-t3-attachments-") + paths: list[str] = [] + mime_types: list[str] = [] + for attachment in attachments: + wire_name = Path(str(attachment["name"])).name # strip any path parts + stem = _ATTACHMENT_NAME_SAFE_RE.sub("_", Path(wire_name).stem)[:48] + suffix = _ATTACHMENT_NAME_SAFE_RE.sub("", Path(wire_name).suffix)[:16] + if suffix and not suffix.startswith("."): + suffix = f".{suffix}" + if suffix == ".": + suffix = "" + handle, path = tempfile.mkstemp( + prefix=f"{stem or 'attachment'}-", + suffix=suffix, + dir=directory, + ) + with os.fdopen(handle, "wb") as stream: + stream.write(attachment["data"]) + paths.append(path) + mime_types.append(str(attachment["mimeType"])) + return paths, mime_types + + +@dataclass +class _TurnState: + thread_id: str + session_id: str + turn_id: str + request_id: str + message_id: str = field(default_factory=lambda: str(uuid.uuid4())) + visible_text: str = "" + assistant_started: bool = False + tool_items: dict[str, str] = field(default_factory=dict) + generic_activity_id: str | None = None + generic_activity_detail: str | None = None + generic_activity_lock: asyncio.Lock = field( + default_factory=asyncio.Lock, + repr=False, + ) + # Monotonic clock reading taken when this turn completed; None while live. + # Read only by `_media_turn_scope` to bound how long the completed turn + # remains an acceptable scope for its own trailing media + # (`_RECENT_TURN_MEDIA_WINDOW_SECONDS`). Monotonic deliberately: a wall + # clock adjustment mid-turn must not widen or collapse the window. + completed_at: float | None = None + + +@dataclass +class _SteerControlResponse: + thread_id: str + request_id: str + messages: list[str] = field(default_factory=list) + + @property + def control_message_id(self) -> str: + """Synthetic id returned for captured control traffic. + + `edit_message` correlates against this so a later edit of the control + acknowledgement is captured too, while genuine assistant edits (which + carry the stream's own message id) pass straight through. + """ + return f"t3-steer-control-{self.request_id}" + + +_steer_control_response = contextvars.ContextVar[_SteerControlResponse | None]( + "hermes_t3_steer_control_response", + default=None, +) + + +class T3PlatformAdapter(BasePlatformAdapter): + """Companion delivery adapter; interactive turns are owned by hermes-acp.""" + + supports_code_blocks = True + supports_status_text = True + # Deliberately NOT set. It exists for rich-card surfaces that must be told + # when to leave the streaming state; T3 closes an item on `item.completed`, + # which this plugin emits itself. Declaring it only makes the gateway's + # progress loop pass `finalize=True` on every progress edit + # (`gateway/run.py:20777-20780`) — a signal we must ignore anyway. + REQUIRES_EDIT_FINALIZE = False + MAX_MESSAGE_LENGTH = 120_000 + _instances: weakref.WeakSet[T3PlatformAdapter] = weakref.WeakSet() + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform("t3")) + extra = config.extra or {} + self._url = str(extra.get("url") or os.environ.get(URL_ENV, "")).strip() + self._instance_id = str( + extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, "") + ).strip() + self._credential = str( + extra.get("credential") or os.environ.get(CREDENTIAL_ENV, "") + ).strip() + self._nickname = str( + extra.get("nickname") or os.environ.get(NICKNAME_ENV, "") or "Hermes" + ).strip() + self._connection: T3GatewayConnection | None = None + self._event_loop: asyncio.AbstractEventLoop | None = None + self._sessions: dict[str, str] = {} + self._active_session_threads: set[str] = set() + self._thread_by_session: dict[str, str] = {} + self._active_turns: dict[str, _TurnState] = {} + # The most recently COMPLETED turn per thread. The base adapter's + # delivery pipeline sends the final text (notify-marked, which + # completes the turn here) BEFORE it sends the reply's media files + # (`gateway/platforms/base.py:5326` then `:5383+`), so a turn reply's + # media routinely arrives moments after its turn closed. This record + # lets that media still be delivered turn-scoped instead of erroring + # with "no active T3 turn". + self._recent_turns: dict[str, _TurnState] = {} + self._approval_requests: dict[str, tuple[str, str]] = {} + self._user_input_requests: dict[str, tuple[str, str]] = {} + self._pending_handoff_creates: dict[str, asyncio.Future[str | None]] = {} + self._home_queue = HomeDeliveryQueue() + # Strong references to fire-and-forget tasks. asyncio only holds a weak + # reference to a running task, so without this the GC may collect one + # mid-flight and its exception surfaces as a bare warning. + self._scheduled_tasks: set[asyncio.Task[Any]] = set() + # Keep public BasePlatformAdapter callbacks for Hermes compatibility, + # but never let the companion socket start agent work. Interactive T3 + # conversations are exclusively the hermes-acp provider's concern. + self._gateway_interactive_turns_enabled = False + type(self)._instances.add(self) + + @property + def name(self) -> str: + return f"T3 Code ({self._nickname})" + + @property + def authorization_is_upstream(self) -> bool: + # The only source of inbound messages is T3's instance-authenticated + # socket. There is no separate Hermes-side user allowlist. + return True + + async def connect(self, *, is_reconnect: bool = False) -> bool: + del is_reconnect + if not (self._url and self._instance_id and self._credential): + self._set_fatal_error( + "t3_not_enrolled", + "Run `hermes t3 connect --url --token ` first.", + retryable=False, + ) + return False + self._event_loop = asyncio.get_running_loop() + self._connection = T3GatewayConnection( + url=self._url, + instance_id=self._instance_id, + credential=self._credential, + hermes_version=_hermes_version(), + on_message=self._handle_server_frame, + on_state=self._handle_connection_state, + on_accepted=self._handle_connection_accepted, + ) + try: + connected = await self._connection.connect() + except Exception as exc: # noqa: BLE001 - transport supplies typed rejection details + self._set_fatal_error("t3_connection_rejected", str(exc), retryable=False) + return False + if connected: + self._mark_connected() + await self._send_status() + return connected + + async def disconnect(self) -> None: + self._settle_pending_handoffs() + if self._connection is not None: + await self._connection.disconnect() + self._connection = None + self._mark_disconnected() + + async def send( + self, + chat_id: str, + content: str, + reply_to: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + # `reply_to` is the base adapter's reply anchor. For the inline + # slash-command path it is `_reply_anchor_for_event(event)`, which for + # this platform resolves to the dispatched MessageEvent's `message_id` + # — the steering requestId. That is the only correlation identifier + # `send` receives, so it is the capture discriminator here. + captured = self._capture_steer_control_response(chat_id, content, reply_to) + if captured is not None: + return captured + thread_id = str(chat_id) + turn = self._active_turns.get(thread_id) + if self._is_proactive_delivery(thread_id, turn, content, metadata): + return await self._deliver_to_home(thread_id, content, metadata) + if turn is None: + return SendResult(success=False, error="no active T3 turn") + try: + if content == _T3_HOME_CHANNEL_NOTICE: + if bool((metadata or {}).get("notify")): + await self._complete_turn(turn) + return SendResult(success=True, message_id=turn.message_id) + await self._emit_assistant_content(turn, content) + if bool((metadata or {}).get("notify")): + await self._complete_turn(turn) + return SendResult(success=True, message_id=turn.message_id) + except Exception as exc: # noqa: BLE001 - adapter send must return SendResult + return SendResult(success=False, error=str(exc)) + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + *, + finalize: bool = False, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + # `finalize` is deliberately ignored as a completion signal. + # + # It reads like "this is the final edit of the response", and that is + # what the base class documents it as — but the gateway's tool-progress + # loop sets it unconditionally on EVERY progress-bubble edit whenever + # the adapter declares `REQUIRES_EDIT_FINALIZE` + # (`gateway/run.py:20777-20780`). Treating it as "turn finished" ended + # the turn on the first tool call; every later send then failed with + # "no active T3 turn" and the real answer was dropped. + # + # `notify=True` on `send()` is the signal that actually means "the + # user-visible reply is delivered": the gateway applies it via + # `_mark_notify_metadata` (`gateway/platforms/base.py:89`) only on + # final replies, and the progress path never sets it (verified across + # every `adapter.send`/`edit_message` call in the progress loop). + del metadata, finalize + # `edit_message` never carries the reply anchor; its correlation + # identifier is the id of the message being edited. Only an edit of a + # message this adapter already reported as captured control traffic is + # control traffic itself. + captured = self._capture_steer_control_response(chat_id, content, message_id) + if captured is not None: + return captured + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return SendResult(success=False, error="no active T3 turn") + try: + if content == _T3_HOME_CHANNEL_NOTICE: + return SendResult(success=True, message_id=message_id) + await self._emit_assistant_content(turn, content) + return SendResult(success=True, message_id=message_id) + except Exception as exc: # noqa: BLE001 - adapter edit must return SendResult + return SendResult(success=False, error=str(exc)) + + async def get_chat_info(self, chat_id: str) -> dict[str, Any]: + return {"name": f"T3 thread {chat_id}", "type": "dm"} + + async def create_handoff_thread( + self, + parent_chat_id: str, + name: str, + ) -> str | None: + """Create a T3 destination through Hermes' public handoff callback. + + Current Hermes calls this with its configured T3 Home chat as the + parent, then places the returned id in ordinary ``thread_id`` send + metadata. T3 validates that parent against the enrolled instance's + authoritative Home designation and creates the child in that + instance's synthetic agent project. + + ``None`` is the official fallback contract. It is returned while + offline, on timeout, or when an older T3 server rejects the additive + frame, allowing Hermes to deliver the handoff notice to Home instead + of deadlocking its watcher. + """ + parent = str(parent_chat_id or "").strip() + if not parent: + logger.warning("T3 handoff has no parent Home thread") + return None + connection = self._connection + if connection is None or not connection.connected: + logger.warning("T3 handoff thread creation skipped while the gateway is offline") + return None + + correlation_id = str(uuid.uuid4()) + pending = asyncio.get_running_loop().create_future() + self._pending_handoff_creates[correlation_id] = pending + try: + await self._send_frame( + frame( + "handoff.create", + requestId=correlation_id, + parentThreadId=parent, + name=(str(name or "").strip() or "Hermes handoff")[:200], + ) + ) + return await asyncio.wait_for( + pending, + timeout=_HANDOFF_CREATE_TIMEOUT_SECONDS, + ) + except TimeoutError: + logger.warning("T3 did not answer handoff thread creation in time") + return None + except Exception as exc: # noqa: BLE001 - None is the public fallback contract + logger.warning("T3 handoff thread creation failed: %s", exc) + return None + finally: + current = self._pending_handoff_creates.get(correlation_id) + if current is pending: + self._pending_handoff_creates.pop(correlation_id, None) + + def _settle_pending_handoffs(self) -> None: + """Release all handoff waiters when their transport generation dies.""" + pending, self._pending_handoff_creates = self._pending_handoff_creates, {} + for waiter in pending.values(): + if not waiter.done(): + waiter.set_result(None) + + def _resolve_handoff_create(self, message: dict[str, Any]) -> None: + """Resolve one response; duplicate and late frames are inert.""" + request_id_value = str(message.get("requestId") or "").strip() + pending = self._pending_handoff_creates.pop(request_id_value, None) + if pending is None or pending.done(): + logger.debug("Ignoring duplicate or late T3 handoff response %s", request_id_value) + return + thread_id = str(message.get("threadId") or "").strip() + pending.set_result(thread_id or None) + + # ── proactive home delivery ──────────────────────────────────────── + + def _is_proactive_delivery( + self, + thread_id: str, + turn: _TurnState | None, + content: str, + metadata: dict[str, Any] | None, + ) -> bool: + """Decide whether this send is Hermes-initiated home delivery. + + **The gate is provenance, not turn absence.** The naive rule ("no + active turn for this thread → deliver") deadlocks against the + notify-completion contract the moment the home thread has a live turn: + a cron result or `send_message` targeting the home chat mid-conversation + would take the active-turn path, stream as that turn's assistant + content, and — because final cron deliveries arrive notify-stamped via + `_mark_notify_metadata` (`gateway/platforms/base.py:89`) — **complete + the user's live turn with the cron output as its answer.** That is the + same keyed-off-the-wrong-signal defect class as the `finalize` bug. + + The discriminator is `_gateway_session_key()`: the gateway binds + `HERMES_SESSION_KEY` onto the turn's context for the whole handler + (`gateway/run.py:12972` → `:14626`), and every send a turn produces — + streamed or final — happens inside that scope. A genuine turn reply + therefore resolves to this plugin's `build_session_key` id for its + thread. Cron runs in its own `cron_*` session with the gateway keys + explicitly cleared (`cron/scheduler.py:3066-3091`), and lifecycle + broadcasts run in no session at all, so neither resolves to the live + turn's key. + + The rules, in order: + + * A send whose session key matches an active turn on this thread is + that turn's own output. Never a delivery — checked first so a turn + reply can never be rerouted. + * A send to a non-home thread is never a delivery: "message any thread + unprompted" is deliberately out of scope, and the existing + `"no active T3 turn"` error stays verbatim for it. + * On the home thread with no active turn, any send is a delivery. + There is nothing it could belong to. + * On the home thread **with** an active turn whose session key does not + match, provenance must be positively established (`classify_delivery` + returning certain) before the send bypasses the turn. This is the + conservative half of the gate: an unattributable send in that window + falls through to the turn path — a possible misplacement inside the + same thread — rather than being torn out of a turn it may belong to. + An unclassifiable send can therefore never steal a live answer, and a + recognisable cron/lifecycle/handoff delivery never completes one. + """ + if turn is not None and self._gateway_session_key() == turn.session_id: + return False + home = home_thread_id() + if not home or thread_id != home: + return False + if turn is None: + return True + _kind, _label, certain = classify_delivery( + content, + metadata, + session_user_id=self._session_user_id(), + ) + return certain + + async def _deliver_to_home( + self, + thread_id: str, + content: str, + metadata: dict[str, Any] | None, + ) -> SendResult: + """Emit one `home.deliver`, queueing it until T3 acknowledges it. + + Deliberately touches none of the turn machinery. `_active_turns` is not + read or written, no turn/item frame is emitted, and `notify` — which + arrives True on every final cron delivery — is consumed only as a + classification hint. A delivery landing while the user has a live turn + in this same thread must leave that turn running. + """ + kind, label, _certain = classify_delivery( + content, + metadata, + session_user_id=self._session_user_id(), + ) + destination_thread_id = thread_id + if kind == "handoff": + handoff_thread_id = str((metadata or {}).get("thread_id") or "").strip() + if handoff_thread_id: + destination_thread_id = handoff_thread_id + delivery = build_delivery( + thread_id=destination_thread_id, + text=str(content or ""), + kind=kind, + label=label, + ) + delivery_id_value = str(delivery["deliveryId"]) + # Persist BEFORE sending. The queue is the durability guarantee: if the + # socket dies between here and the ack, the entry survives to be + # replayed on the next connect, and T3's `deliveryId` dedupe makes the + # replay harmless. + queued = await asyncio.to_thread(self._home_queue.append, delivery) + sent = True + try: + await self._send_frame(delivery) + except Exception as exc: # noqa: BLE001 - adapter send must return SendResult + sent = False + logger.warning( + "T3 home delivery %s could not be sent (%s); it is queued for " + "the next connect", + delivery_id_value, + exc, + ) + # Success needs EITHER leg to have held. Queued-and-unsent arrives on + # the next connect; sent-but-unqueued is already at T3 (the ack simply + # finds nothing to purge). Neither means the content is gone, and + # reporting success then would tell a cron job its brief was delivered + # when nothing on this machine still holds it. + if not (queued or sent): + return SendResult( + success=False, + message_id=delivery_id_value, + error="T3 home delivery could not be sent or queued", + ) + return SendResult(success=True, message_id=delivery_id_value) + + async def _handle_connection_accepted(self, accepted: dict[str, Any]) -> None: + """Reconcile the home designation, then flush the delivery queue. + + T3's settings blob is the authoritative designation and it republishes + it on every successful handshake, so the plugin's `T3_HOME_CHANNEL` is + a synced cache: a differing local value — including a hand-edited one — + is overwritten. Reconciling on every accept bounds drift to a single + reconnect. + """ + thread_id = str(accepted.get("homeThreadId") or "").strip() + if thread_id and thread_id != home_thread_id(): + logger.info("T3 designated home thread %s", thread_id) + save_home_thread_id(thread_id) + elif thread_id: + save_home_thread_id(thread_id) + await self._flush_home_queue() + + async def _flush_home_queue(self) -> None: + """Replay unacknowledged deliveries oldest-first. + + Entries are NOT removed here — only a `home.deliver.ack` purges one. + Re-sending an entry T3 already durably wrote is harmless (it dedupes on + `deliveryId`); dropping one it never wrote is not. + + Frames are restamped to the CURRENT protocol version before sending: an + entry queued by an older plugin carries the version it was built under, + and T3's strict-lockstep decoder closes the socket on any other version + — turning one stale queued frame into a reconnect loop that outlives + the upgrade. The delivery fields themselves are version-stable (the + v3→v4 change only added frame types), so restamping is honest. + """ + pending = await asyncio.to_thread(self._home_queue.entries) + if not pending: + return + logger.info("Flushing %d queued T3 home deliver(y|ies)", len(pending)) + sent_bytes = 0 + for entry in pending[:MAX_FLUSH_PER_CONNECT]: + encoded_bytes = len( + json.dumps(entry, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ) + if sent_bytes > 0 and sent_bytes + encoded_bytes > MAX_FLUSH_BYTES_PER_CONNECT: + logger.info( + "Stopping this T3 home delivery flush at %d bytes; the remainder " + "will ride the next reconnect", + sent_bytes, + ) + return + try: + await self._send_frame({**entry, "protocolVersion": PROTOCOL_VERSION}) + except Exception as exc: # noqa: BLE001 - the rest rides the next connect + logger.warning("T3 home delivery flush stopped: %s", exc) + return + sent_bytes += encoded_bytes + + async def _acknowledge_home_delivery(self, message: dict[str, Any]) -> None: + """Purge a delivery T3 has durably written. + + Serves `home.deliver.ack` and `media.deliver.ack` alike: both frame + types live in the same queue keyed on `deliveryId`, so the purge does + not care which kind of delivery was acknowledged. + """ + delivery_id_value = str(message.get("deliveryId") or "").strip() + if not delivery_id_value: + raise ValueError("a delivery ack requires a deliveryId") + await asyncio.to_thread(self._home_queue.purge, delivery_id_value) + + # ── outbound media ───────────────────────────────────────────────── + + def _media_turn_scope( + self, + thread_id: str, + content: str, + metadata: dict[str, Any] | None, + ) -> _TurnState | None: + """Resolve the turn a media send belongs to, or None for home delivery. + + Same provenance gate as `_is_proactive_delivery`, with one addition: + the base adapter's delivery pipeline sends a reply's final text — + notify-marked, which completes the turn here — BEFORE it dispatches + the reply's media files (`gateway/platforms/base.py:5326` then + `:5373+`), so turn media routinely arrives moments after its turn + closed and must still be able to reach back to it. + + **The session key is NOT available on the media dispatch path**, and + that is structural, not a race. The gateway binds `HERMES_SESSION_KEY` + inside `_handle_message_with_agent` and clears it in that method's own + `finally` (`gateway/run.py:12972` → `:14626`); the delivery pipeline + that sends the text and then the files lives one frame further out, in + `BasePlatformAdapter._process_message_background`, and runs entirely + AFTER the handler returned. `clear_session_vars` sets the vars to `""` + rather than resetting them, deliberately suppressing the `os.environ` + fallback — so every send the pipeline makes, text and media alike, + reads `""`. Verified against the real gateway package: inside the + handler the key resolves; on return it is `""`. + + The text path never noticed because it does not consult the key when a + live turn exists — `send()` reaches `_is_proactive_delivery`, which for + a non-home thread returns False on the thread check alone and falls + through to `_active_turns`. Media had no such fallback: it required the + key to match, so on a non-home thread the file was dropped with + "no active T3 turn" (live repro 2026-07-27 18:47:06, 36ms after the + turn's own text completed the turn). + + So the reach-back cannot be keyed on the session key. It is keyed on + the two signals that ARE trustworthy here: + + * **Recency.** A completed turn is a scope only within + `_RECENT_TURN_MEDIA_WINDOW_SECONDS` of completing. Turn media follows + its text by milliseconds; anything later is not this turn's output. + * **Provenance.** `classify_delivery` must NOT positively identify the + send as proactive. This is the same discriminator the home half of + the gate uses, applied with the opposite default — and it is what + contains the collision this window would otherwise open. + + The collision to contain is `send_message`, the one thing besides a + turn that can dispatch media to a NON-home thread + (`tools/send_message_tool.py:1880+` → `adapter.send_image_file` with a + caller-chosen `chat_id`). Cron cannot: it delivers to the home channel + and is excluded by the thread check. But `send_message` runs INSIDE a + turn's own handler — it is a tool the agent calls — so it is not a + cross-turn intruder arriving during someone else's live turn; it is + this session's own agent choosing a destination. Two cases follow. If + it targets this thread, scoping the file to the turn that produced it + is exactly right. If it targets a *different* thread, that thread's + `_recent_turns` entry is stale by far more than the window unless the + user was mid-conversation there seconds ago — and in that narrow case + the file still lands in the thread the agent addressed, attributed to a + turn that just ended in it. A slightly-wrong turn attribution on a + message row, never a stolen answer. + + That asymmetry is the whole reason this is safe where the text gate is + strict. `send()` completes turns; a misattributed text send ends a live + turn with the wrong output — the `finalize` defect class. Media touches + no turn machinery at all: `media.deliver` carries `turnId` purely as a + sequencing hint, emits no turn or item frame, and cannot complete, + interrupt, or alter a turn. The worst outcome here is a file sequenced + next to the wrong neighbour. + + A live turn whose session key matches still wins outright and is + checked first, so nothing about the ordinary in-handler path changes. + """ + turn = self._active_turns.get(thread_id) + recent = self._recent_turns.get(thread_id) + session_key = self._gateway_session_key() + if turn is not None and session_key == turn.session_id: + return turn + if turn is None and recent is not None and session_key == recent.session_id: + return recent + home = home_thread_id() + if not home or thread_id != home: + # Not home. A live turn takes the media exactly as the text path + # would. Otherwise the just-completed turn may claim it, bounded by + # recency and refused to a positively-proactive send — see above. + if turn is not None: + return turn + if not self._within_media_reachback(recent): + return None + _kind, _label, certain = classify_delivery( + content, + metadata, + session_user_id=self._session_user_id(), + ) + return None if certain else recent + if turn is None: + return None + _kind, _label, certain = classify_delivery( + content, + metadata, + session_user_id=self._session_user_id(), + ) + # Conservative half of the gate, mirroring text: an unattributable + # media send during a live home turn stays with the turn. + return None if certain else turn + + @staticmethod + def _within_media_reachback(turn: _TurnState | None) -> bool: + """True while a completed turn may still claim its own trailing media. + + A turn with no `completed_at` never went through `_complete_turn`, so + nothing is known about when it ended — treated as out of the window + rather than assumed fresh. + """ + if turn is None or turn.completed_at is None: + return False + return ( + time.monotonic() - turn.completed_at + ) <= _RECENT_TURN_MEDIA_WINDOW_SECONDS + + async def _deliver_media_file( + self, + chat_id: str, + path: str, + *, + caption: str | None = None, + name: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + """Emit one `media.deliver`, queueing it until T3 acknowledges it. + + The same durable lifecycle as `_deliver_to_home`: persist BEFORE + sending, report success once queued, purge only on the ack. The one + divergence is a payload that cannot be built at all — unreadable file, + empty, over the 25MiB ceiling — which fails the send immediately + instead of queueing a frame T3 would reject on every future flush. + + **An unscopeable file goes to Home rather than being dropped.** When + `_media_turn_scope` finds nothing on a non-home thread, the old + behaviour returned `"no active T3 turn"` — and upstream's only + response to that is `logger.error("Failed to send image: %s")` + (`gateway/platforms/base.py:3471`) before moving on. The file is gone, + silently from the user's side, after Hermes spent a generation call + producing it. Text can afford that (the agent can restate it, the user + can ask again); a produced artifact cannot. + + Routing it to Home is safe in the way the thread route is not. The + frame goes out turnless, so T3 re-resolves the instance's home thread + server-side and writes only there — a plugin cannot address an + arbitrary thread on this path even in principle + (`apps/server/src/provider/hermesGatewayHttp.ts:207-215`) — and it + carries `classify_delivery` provenance, so it renders as a badged + notification exactly like a cron artifact rather than impersonating a + thread reply. With no home designated there is genuinely nowhere to put + it, and the original error stands. + """ + thread_id = str(chat_id) + content = str(caption or "") + turn = self._media_turn_scope(thread_id, content, metadata) + home = home_thread_id() + delivery_thread_id = thread_id + if turn is None and (not home or thread_id != home): + if not home: + return SendResult(success=False, error="no active T3 turn") + logger.info( + "T3 media for thread %s has no turn to attach to; delivering " + "it to the home thread instead of dropping it", + thread_id, + ) + delivery_thread_id = home + kind, label, _certain = classify_delivery( + content, + metadata, + session_user_id=self._session_user_id(), + ) + if turn is None and kind == "handoff": + handoff_thread_id = str((metadata or {}).get("thread_id") or "").strip() + if handoff_thread_id: + delivery_thread_id = handoff_thread_id + try: + delivery = build_media_delivery( + thread_id=delivery_thread_id, + path=str(path), + kind=kind, + label=label, + turn_id=turn.turn_id if turn is not None else None, + caption=caption, + name=name, + ) + except Exception as exc: # noqa: BLE001 - adapter send must return SendResult + logger.warning("T3 media delivery for %s failed to build: %s", path, exc) + return SendResult(success=False, error=str(exc)) + delivery_id_value = str(delivery["deliveryId"]) + queued = await asyncio.to_thread(self._home_queue.append, delivery) + sent = True + try: + await self._send_frame(delivery) + except Exception as exc: # noqa: BLE001 - adapter send must return SendResult + sent = False + logger.warning( + "T3 media delivery %s could not be sent (%s); it is queued for " + "the next connect", + delivery_id_value, + exc, + ) + # Neither queued nor sent means the file is gone — the only copy was + # the bytes in this frame, and Hermes' temp file may be reaped before + # anyone could retry. Fail before the completion below, so the turn is + # not closed on media that never arrived. See `_deliver_to_home` for + # why either leg alone is honest success. + if not (queued or sent): + return SendResult( + success=False, + message_id=delivery_id_value, + error="T3 media delivery could not be sent or queued", + ) + # The same notify-completion contract `send()` honors for text. The + # base adapter notify-marks every send of a reply's FINAL delivery + # batch (`_mark_notify_metadata`, `gateway/platforms/base.py:5220`) — + # text and media alike — and an image-only reply produces no text + # send at all, so this is the only place its turn can complete. + # Guarded to the still-live turn: the common text-then-media ordering + # completes the turn on the text, and re-completing a `_recent_turns` + # entry would emit a second `turn.completed` for a turn T3 already + # folded. + if ( + turn is not None + and bool((metadata or {}).get("notify")) + and self._active_turns.get(thread_id) is turn + ): + await self._complete_turn(turn) + return SendResult(success=True, message_id=delivery_id_value) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: str | None = None, + reply_to: str | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> SendResult: + del reply_to, kwargs + return await self._deliver_media_file( + chat_id, image_path, caption=caption, metadata=metadata + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: str | None = None, + reply_to: str | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> SendResult: + del reply_to, kwargs + return await self._deliver_media_file( + chat_id, video_path, caption=caption, metadata=metadata + ) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: str | None = None, + reply_to: str | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> SendResult: + # T3 renders audio as a download card (no native player in v1), which + # is still strictly better than the base fallback's "couldn't deliver + # the audio attachment" notice. + del reply_to, kwargs + return await self._deliver_media_file( + chat_id, audio_path, caption=caption, metadata=metadata + ) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: str | None = None, + file_name: str | None = None, + reply_to: str | None = None, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> SendResult: + del reply_to, kwargs + return await self._deliver_media_file( + chat_id, + file_path, + caption=caption, + name=file_name, + metadata=metadata, + ) + + @staticmethod + def _session_user_id() -> str: + """Read the bound session's user id, for `/handoff` classification. + + Returns `""` on any failure, exactly like `_gateway_session_key`. + """ + try: + from gateway.session_context import get_session_env + + return str(get_session_env("HERMES_SESSION_USER_ID", "") or "") + except Exception: # noqa: BLE001 - classification must never raise + return "" + + def format_tool_event( + self, event: Any, *, mode: str = "all", preview_max_len: int = 40 + ) -> str | None: + """Drop textual tool-progress chrome. + + T3 already renders tool calls as typed `item.started` / `item.completed` + activity from the `pre_tool_call` / `post_tool_call` hooks, so a text + line duplicating them is strictly worse than what T3 already shows. + + NOTE: at Hermes 62e07223 this hook is NOT on the live delivery path — + `GatewayEventDispatcher` (`gateway/stream_dispatch.py:108`, its only + caller) is referenced solely by upstream tests. The path that actually + runs is `gateway/run.py:20485+`, which builds the same lines and + delivers them through `adapter.send` / `adapter.edit_message` with no + adapter hook to suppress them; it is silenced by the platform's + `tool_progress` display setting instead. This override is kept because + it is the documented contract and costs nothing if upstream routes + through the dispatcher again — but it is not what protects the turn. + The turn is protected by ignoring `finalize` in `edit_message`. + """ + del event, mode, preview_max_len + return None + + async def send_typing( + self, chat_id: str, metadata: dict[str, Any] | None = None + ) -> None: + del metadata + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return + status = getattr(self, "_status_text", {}).get(str(chat_id)) + if status: + await self._emit_generic_activity(turn, status) + + def set_status_text(self, chat_id: str, text: str | None) -> None: + super().set_status_text(chat_id, text) + if not text: + return + turn = self._active_turns.get(str(chat_id)) + if turn is not None: + self._schedule(self._emit_generic_activity(turn, text)) + + async def send_exec_approval( + self, + chat_id: str, + command: str, + session_key: str, + description: str, + metadata: dict[str, Any] | None = None, + **kwargs: Any, + ) -> SendResult: + del metadata, kwargs + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return SendResult(success=False, error="no active T3 turn") + approval_id = str(uuid.uuid4()) + self._approval_requests[approval_id] = (session_key, turn.turn_id) + await self._send_frame( + frame( + "request.opened", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + requestId=approval_id, + requestType="command_execution_approval", + detail=description or "Hermes requests permission to run a command", + args={"command": command}, + ) + ) + return SendResult(success=True, message_id=approval_id) + + async def send_clarify( + self, + chat_id: str, + question: str, + choices: list[Any] | None, + clarify_id: str, + session_key: str, + metadata: dict[str, Any] | None = None, + ) -> SendResult: + del metadata + turn = self._active_turns.get(str(chat_id)) + if turn is None: + return SendResult(success=False, error="no active T3 turn") + options = [] + for choice in choices or []: + label = str(choice.get("label") if isinstance(choice, dict) else choice) + description = ( + str(choice.get("description") or label) + if isinstance(choice, dict) + else label + ) + options.append({"label": label, "description": description}) + self._user_input_requests[clarify_id] = (session_key, turn.turn_id) + await self._send_frame( + frame( + "user-input.requested", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + requestId=clarify_id, + questions=[ + { + "id": clarify_id, + "header": "Hermes", + "question": question, + "options": options, + "multiSelect": False, + } + ], + ) + ) + return SendResult(success=True, message_id=clarify_id) + + async def _handle_server_frame(self, raw: dict[str, Any]) -> None: + request = raw.get("requestId") + try: + message = validate_server_frame(raw) + frame_type = message["type"] + if frame_type == "session.ensure": + await self._ensure_session(message) + elif frame_type == "turn.start": + if not self._gateway_interactive_turns_enabled: + raise ValueError( + "interactive turns are disabled on the Hermes companion; " + "use T3's hermes-acp provider" + ) + await self._start_turn(message) + elif frame_type == "turn.steer": + if not self._gateway_interactive_turns_enabled: + raise ValueError( + "interactive turns are disabled on the Hermes companion; " + "use T3's hermes-acp provider" + ) + await self._steer_turn(message) + elif frame_type == "turn.interrupt": + await self._interrupt_turn(message) + elif frame_type == "approval.respond": + await self._resolve_approval(message) + elif frame_type == "user-input.respond": + await self._resolve_user_input(message) + elif frame_type == "session.stop": + await self._stop_session(message) + elif frame_type == "ping": + await self._send_frame( + frame( + "pong", + requestId=message["requestId"], + sentAt=message.get("sentAt") or iso_now(), + ) + ) + elif frame_type == "describe.request": + await self._describe(message) + elif frame_type == "skill.body.request": + await self._send_skill_body(message) + elif frame_type in {"home.deliver.ack", "media.deliver.ack"}: + await self._acknowledge_home_delivery(message) + elif frame_type == "handoff.created": + self._resolve_handoff_create(message) + elif frame_type == "protocol.error": + request_id_value = str(message.get("requestId") or "").strip() + pending = self._pending_handoff_creates.pop(request_id_value, None) + if pending is not None and not pending.done(): + logger.warning("T3 rejected handoff thread creation: %s", message["message"]) + pending.set_result(None) + else: + logger.warning("T3 gateway protocol error: %s", message["message"]) + except ValueError as exc: + await self._send_frame( + protocol_error( + "unsupported-message", + str(exc), + recoverable=True, + related_request_id=str(request) if request else None, + ) + ) + except Exception as exc: + logger.exception("T3 gateway command failed") + await self._send_frame( + protocol_error( + "internal-error", + str(exc) or type(exc).__name__, + recoverable=True, + related_request_id=str(request) if request else None, + ) + ) + + async def _describe(self, message: dict[str, Any]) -> None: + """Answer `describe.request` with what this plugin knows about itself. + + Correlated by the request's own `requestId`, exactly like `ping` → + `pong`. Every Hermes-sourced field degrades to omitted inside + `describe_response`, so this branch has no failure path of its own: + an unreadable config or an older Hermes yields a thinner reply, never + a `protocol.error` and never a dropped connection. + """ + await self._send_frame( + describe_response( + request_id_value=str(message["requestId"]), + hermes_version=_hermes_version(), + ) + ) + + async def _send_skill_body(self, message: dict[str, Any]) -> None: + """Answer `skill.body.request` with one skill's markdown. + + Fired on row expand, never eagerly — bodies are the reason skills are + reported as metadata only. An unknown or unreadable skill replies with + `markdown: null` rather than an error, so the UI can render "no body + available" instead of showing the user a protocol failure. + + A *missing* skill name is different from an unreadable skill: the + response carries `skillName` back for the client to key on, and an + empty one would not decode. That case takes the ordinary correlated + `protocol.error` path instead of echoing a name that was never sent. + """ + skill_name = str(message.get("skillName") or "").strip() + if not skill_name: + raise ValueError("skill.body.request requires a skillName") + await self._send_frame( + skill_body_response( + request_id_value=str(message["requestId"]), + skill_name=skill_name, + markdown=skill_body(skill_name), + ) + ) + + async def _ensure_session(self, message: dict[str, Any]) -> None: + thread_id = str(message["threadId"]) + source = self._source(thread_id, str(message["requestId"])) + session_id = build_session_key(source) + resume_id = str(message.get("resumeSessionId") or "") + self._sessions[thread_id] = session_id + self._active_session_threads.add(thread_id) + self._thread_by_session[session_id] = thread_id + active_turn = self._active_turns.get(thread_id) + await self._send_frame( + frame( + "session.ready", + requestId=message["requestId"], + threadId=thread_id, + sessionId=session_id, + resumed=bool(resume_id and resume_id == session_id), + **( + {"activeTurnId": active_turn.turn_id} + if active_turn is not None + else {} + ), + ) + ) + await self._send_status() + + async def _start_turn(self, message: dict[str, Any]) -> None: + thread_id = str(message["threadId"]) + session_id = self._sessions.get(thread_id) + if not session_id or session_id != str(message["sessionId"]): + await self._send_frame( + protocol_error( + "session-not-found", + "Call session.ensure before starting a turn.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + if thread_id in self._active_turns: + await self._send_frame( + protocol_error( + "invalid-message", + "This Hermes session already has an active turn; use turn.steer.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + # Decode and materialize attachments BEFORE any turn state exists: a + # malformed attachment raises ValueError into the correlated + # `protocol.error` path with no half-started turn to clean up. + # + # Surfacing choice: the temp file paths ride the MessageEvent's own + # `media_urls` / `media_types` fields — Hermes' structured channel for + # exactly this (`gateway/platforms/base.py:1800`). The gateway's + # enrichment pipeline then does everything a bundled platform gets: + # vision routing for images, STT for voice, and path-pointing context + # notes for documents (`gateway/run.py:12420+`). No prompt-text + # injection is needed on this path. + media_paths, media_types = _materialize_attachments( + turn_attachments(message) + ) + turn = _TurnState( + thread_id=thread_id, + session_id=session_id, + turn_id=str(message["turnId"]), + request_id=str(message["requestId"]), + ) + self._active_turns[thread_id] = turn + # Roll the registration back if starting the turn raises. Without this + # a failed `turn.started` send (a socket that dropped between the + # decode and the write) leaves a phantom turn no completion path will + # ever reach, and the `thread_id in self._active_turns` guard above + # then rejects every future `turn.start` on this thread for the life of + # the process. Guarded on identity: an error handler that already + # replaced the entry owns it now, and clobbering that would strand the + # replacement instead. + try: + await self._send_frame( + frame( + "turn.started", + requestId=turn.request_id, + threadId=thread_id, + sessionId=session_id, + turnId=turn.turn_id, + ) + ) + await self._send_status() + await self.handle_message( + MessageEvent( + text=str(message["text"]), + message_type=( + MessageType.COMMAND + if str(message["text"]).lstrip().startswith("/") + else MessageType.TEXT + ), + source=self._source(thread_id, turn.request_id), + message_id=turn.request_id, + metadata={"t3_turn_id": turn.turn_id}, + media_urls=media_paths, + media_types=media_types, + ) + ) + except BaseException: + if self._active_turns.get(thread_id) is turn: + del self._active_turns[thread_id] + raise + + async def _steer_turn(self, message: dict[str, Any]) -> None: + turn = self._active_turns.get(str(message["threadId"])) + if turn is None or turn.turn_id != str(message["turnId"]): + await self._send_frame( + protocol_error( + "turn-not-active", + "The requested Hermes turn is no longer active.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + # Attachments on a steer cannot ride `media_urls`: Hermes' `/steer` + # handler injects only the command's text between tool iterations + # (`gateway/run.py:11254`) and never reads the event's media fields. + # The paths are appended to the injected text instead — mid-turn the + # agent reaches files through its tools anyway, so a path note is the + # natural (and only) channel here. + steer_text = str(message["text"]) + media_paths, media_types = _materialize_attachments( + turn_attachments(message) + ) + for path, mime in zip(media_paths, media_types): + steer_text += f"\n[The user attached a file ({mime}): {path}]" + # `/steer` is Hermes' official active-run injection surface. The base + # adapter dispatches active slash commands inline, then sends the + # command's textual acknowledgement back through this adapter with + # `notify=True`. Capture that one command response by request context: + # it is control traffic, not assistant output and not a turn boundary. + control = _SteerControlResponse( + thread_id=turn.thread_id, + request_id=str(message["requestId"]), + ) + context_token = _steer_control_response.set(control) + command_error: Exception | None = None + try: + await self.handle_message( + MessageEvent( + text=f"/steer {steer_text}", + message_type=MessageType.COMMAND, + source=self._source(turn.thread_id, control.request_id), + message_id=control.request_id, + metadata={"t3_turn_id": turn.turn_id, "t3_steer": True}, + ) + ) + except Exception as exc: # noqa: BLE001 - translate command failures to the wire + command_error = exc + finally: + _steer_control_response.reset(context_token) + + if command_error is not None: + await self._send_frame( + protocol_error( + "internal-error", + str(command_error) or "Hermes steering failed.", + recoverable=True, + related_request_id=control.request_id, + ) + ) + return + + response = control.messages[-1] if control.messages else "" + if not response.startswith("⏩ Steer queued"): + if response.startswith(("Agent still starting", "No active agent")): + error_code = "turn-not-active" + elif response.startswith("⚠️ Steer failed"): + error_code = "internal-error" + else: + error_code = "invalid-message" + await self._send_frame( + protocol_error( + error_code, + response or "Hermes did not acknowledge the steering request.", + recoverable=True, + related_request_id=control.request_id, + ) + ) + return + + # Correlated command acknowledgement. It intentionally reuses the + # existing turnId: this is not a second runtime turn. T3 consumes the + # steering requestId as its broker acknowledgement and suppresses the + # duplicate turn-start lifecycle projection. + await self._send_frame( + frame( + "turn.started", + requestId=control.request_id, + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + ) + ) + + def _capture_steer_control_response( + self, + chat_id: str, + content: str, + correlation_id: str | None, + ) -> SendResult | None: + """Capture only the steering command's own acknowledgement. + + A steer targets a RUNNING turn, so Hermes can legitimately emit + assistant output on the same thread while the steering command is + still awaited. Matching on `chat_id` alone would swallow that output + and drop it from the transcript, so the capture is keyed on the + steering `requestId` the plugin stamped on the dispatched + `MessageEvent` (and, for follow-up edits, on the synthetic control + message id this method returns). Everything else falls through to the + normal assistant-content path. + """ + control = _steer_control_response.get() + if control is None or control.thread_id != str(chat_id): + return None + if correlation_id is None: + return None + correlation = str(correlation_id) + if correlation not in {control.request_id, control.control_message_id}: + return None + control.messages.append(str(content)) + return SendResult(success=True, message_id=control.control_message_id) + + async def _interrupt_turn(self, message: dict[str, Any]) -> None: + thread_id = str(message["threadId"]) + turn = self._active_turns.get(thread_id) + if turn is None or turn.turn_id != str(message["turnId"]): + await self._send_frame( + protocol_error( + "turn-not-active", + "The requested Hermes turn is no longer active.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + await self.interrupt_session_activity(turn.session_id, thread_id) + await self._send_frame( + frame( + "turn.aborted", + threadId=thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + reason="Interrupted by T3 Code", + ) + ) + self._active_turns.pop(thread_id, None) + await self._send_status() + + async def _resolve_approval(self, message: dict[str, Any]) -> None: + request_id = str(message["requestId"]) + pending = self._approval_requests.pop(request_id, None) + if pending is None: + await self._send_frame( + protocol_error( + "request-not-found", + "The Hermes approval request is no longer pending.", + recoverable=True, + related_request_id=request_id, + ) + ) + return + session_key, _turn_id = pending + decision = str(message["decision"]) + choice = { + "accept": "once", + "acceptForSession": "session", + "decline": "deny", + "cancel": "deny", + }.get(decision, "deny") + from tools.approval import resolve_gateway_approval + + resolved = resolve_gateway_approval(session_key, choice) + await self._send_frame( + frame( + "request.resolved", + threadId=message["threadId"], + sessionId=message["sessionId"], + turnId=message["turnId"], + requestId=request_id, + requestType="command_execution_approval", + decision=decision, + resolution={"resolvedCount": resolved}, + ) + ) + + async def _resolve_user_input(self, message: dict[str, Any]) -> None: + request_id = str(message["requestId"]) + pending = self._user_input_requests.pop(request_id, None) + if pending is None: + await self._send_frame( + protocol_error( + "request-not-found", + "The Hermes user-input request is no longer pending.", + recoverable=True, + related_request_id=request_id, + ) + ) + return + answers = message.get("answers") or {} + answer = answers.get(request_id) if isinstance(answers, dict) else None + if answer is None and isinstance(answers, dict) and answers: + answer = next(iter(answers.values())) + if isinstance(answer, list): + response = ", ".join(str(value) for value in answer) + else: + response = str(answer or "") + from tools.clarify_gateway import resolve_gateway_clarify + + resolved = resolve_gateway_clarify(request_id, response) + await self._send_frame( + frame( + "user-input.resolved", + threadId=message["threadId"], + sessionId=message["sessionId"], + turnId=message["turnId"], + requestId=request_id, + answers=answers, + ) + ) + if not resolved: + logger.warning( + "Hermes clarify request %s was no longer pending", request_id + ) + + async def _stop_session(self, message: dict[str, Any]) -> None: + thread_id = str(message["threadId"]) + session_id = self._sessions.get(thread_id) + if session_id is None: + await self._send_frame( + protocol_error( + "session-not-found", + "The requested Hermes session is not active in this connection.", + recoverable=True, + related_request_id=str(message["requestId"]), + ) + ) + return + turn = self._active_turns.pop(thread_id, None) + if turn is not None: + await self.interrupt_session_activity(session_id, thread_id) + await self._send_frame( + frame( + "turn.aborted", + threadId=thread_id, + sessionId=session_id, + turnId=turn.turn_id, + reason="Hermes session stopped by T3 Code", + ) + ) + await self._send_frame( + frame( + "session.exited", + threadId=thread_id, + sessionId=session_id, + reason="Stopped by T3 Code", + recoverable=True, + ) + ) + # Deliberately retain the deterministic mapping and Hermes transcript. + # A later session.ensure resumes this same thread/session identity. + self._active_session_threads.discard(thread_id) + await self._send_status() + + async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None: + visible = str(content or "").replace(" ▉", "").replace("▉", "") + if not turn.assistant_started: + await self._send_frame( + frame( + "item.started", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.message_id, + itemType="assistant_message", + status="inProgress", + title="Hermes response", + ) + ) + turn.assistant_started = True + if visible.startswith(turn.visible_text): + delta = visible[len(turn.visible_text) :] + if delta: + await self._send_frame( + frame( + "content.delta", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.message_id, + streamKind="assistant_text", + delta=delta, + contentIndex=0, + ) + ) + turn.visible_text = visible + elif visible != turn.visible_text: + await self._send_frame( + frame( + "content.snapshot", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.message_id, + streamKind="assistant_text", + text=visible, + contentIndex=0, + ) + ) + turn.visible_text = visible + + async def _complete_turn(self, turn: _TurnState) -> None: + if self._active_turns.get(turn.thread_id) is not turn: + return + # Close the live status line BEFORE the assistant message. + # + # T3 orders the timeline by item timestamp and folds a settled turn's + # activity behind the "Worked for …" row — but only the entries that + # precede the turn's terminal assistant message. Completing the status + # item after that message stamped it milliseconds later, so it sorted + # below the answer, escaped the fold, and rendered as a stray "Work + # Log" section under the reply instead of joining the collapsed + # activity above it. + async with turn.generic_activity_lock: + if self._active_turns.get(turn.thread_id) is not turn: + return + if turn.generic_activity_id is not None: + await self._send_frame( + frame( + "item.completed", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.generic_activity_id, + itemType=_STATUS_ITEM_TYPE, + status="completed", + title="Hermes activity", + **( + {"detail": turn.generic_activity_detail} + if turn.generic_activity_detail + else {} + ), + ) + ) + if turn.assistant_started: + await self._send_frame( + frame( + "item.completed", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=turn.message_id, + itemType="assistant_message", + status="completed", + title="Hermes response", + ) + ) + async with turn.generic_activity_lock: + if self._active_turns.get(turn.thread_id) is not turn: + return + await self._send_frame( + frame( + "turn.completed", + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + state="completed", + stopReason=None, + ) + ) + self._active_turns.pop(turn.thread_id, None) + # Remembered for media scoping: the base adapter sends a reply's + # media files AFTER its notify-marked text, i.e. after this point. + # The stamp bounds that reach-back — see `_media_turn_scope`. + turn.completed_at = time.monotonic() + self._recent_turns[turn.thread_id] = turn + await self._send_status() + + async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None: + if not detail: + return + normalized_detail = str(detail)[:2_000] + async with turn.generic_activity_lock: + if self._active_turns.get(turn.thread_id) is not turn: + return + if turn.generic_activity_detail == normalized_detail: + return + activity_id = turn.generic_activity_id + if activity_id is None: + activity_id = item_id() + event_type = "item.started" + else: + event_type = "item.updated" + await self._send_frame( + frame( + event_type, + threadId=turn.thread_id, + sessionId=turn.session_id, + turnId=turn.turn_id, + itemId=activity_id, + itemType=_STATUS_ITEM_TYPE, + status="inProgress", + title="Hermes activity", + detail=normalized_detail, + ) + ) + turn.generic_activity_id = activity_id + turn.generic_activity_detail = normalized_detail + + def _turn_for_tool_hook(self, session_id: str) -> _TurnState | None: + """Resolve the active turn a tool hook belongs to. + + The tool hooks' `session_id` is NOT this plugin's session id. Hermes + passes `agent.session_id` (`agent/tool_executor.py:188`, `:305`, + `:341`), which the gateway sets to `SessionEntry.session_id` — a + timestamped run id like `20260725_143012_ab12cd34` + (`gateway/session.py:2388`, `agent/agent_init.py:1446-1453`). This + plugin's session ids come from `build_session_key` + (`gateway/session.py:1029`), shaped `agent:main:t3:dm:`. The two + never match, so `_thread_by_session` alone silently drops every tool + activity item. + + The gateway's stable routing key is available separately: it is bound + onto `HERMES_SESSION_KEY` for the turn's context + (`gateway/run.py:17367` → `gateway/session_context.py:200`) and + propagated into the tool worker threads + (`agent/tool_executor.py:715`, `propagate_context_to_thread`). That key + IS `build_session_key(...)`, so it matches `_thread_by_session`. + + Resolution order, all best-effort: + 1. `session_id` as a direct routing key (correct if a future Hermes + passes the gateway key here, and free to check). + 2. `HERMES_SESSION_KEY` from the Hermes session context. + 3. The sole active turn, when exactly one exists — a single-threaded + Hermes process has no ambiguity to resolve, and dropping the + activity would be strictly worse. **Cron runs are excluded from + this step** (see below). + Anything unresolved returns `None` and the item is simply not emitted; + tool activity is decorative, so this must never raise or misroute. + + The cron exclusion: these hooks are process-global, so a cron job + running tools while exactly one T3 turn happens to be live would + resolve through the sole-turn fallback and paint the cron job's tool + calls into an unrelated live conversation. Cron runs are identifiable — + the scheduler builds its agent with + `session_id=f"cron_{job_id}_{timestamp}"` (`cron/scheduler.py:3017`, + passed at `:3484`), which is exactly the value these hooks receive as + `session_id`. Upstream treats the same routing hazard as real: the + scheduler deliberately clears the process-global session env vars for + it (`cron/scheduler.py:3066-3091`). A cron job's activity belongs to + the eventual `home.deliver`, never to a live turn, so it is dropped + rather than guessed at. + """ + thread_id = self._thread_by_session.get(str(session_id)) + if thread_id is None: + thread_id = self._thread_by_session.get(self._gateway_session_key()) + if thread_id is not None: + return self._active_turns.get(thread_id) + if self._is_cron_session(session_id): + return None + if len(self._active_turns) == 1: + return next(iter(self._active_turns.values())) + return None + + @staticmethod + def _is_cron_session(session_id: str) -> bool: + """True when this hook call belongs to a cron run, not a gateway turn. + + Keyed on the `cron_` prefix the scheduler mints at + `cron/scheduler.py:3017`. Matching a prefix rather than an exported + constant carries the usual drift risk: if upstream renames the shape, + this degrades to today's behaviour (cron tool rows may again be + misattributed to a sole live turn) rather than breaking anything. + """ + return str(session_id or "").startswith("cron_") + + @staticmethod + def _gateway_session_key() -> str: + """Read the turn's gateway routing key from Hermes' session context. + + Returns `""` on any failure (older Hermes, no context bound, import + error) so callers fall through to their next resolution step. + """ + try: + from gateway.session_context import get_session_env + + return str(get_session_env("HERMES_SESSION_KEY", "") or "") + except Exception: # noqa: BLE001 - decorative activity must not raise + return "" + + def emit_tool_started( + self, + session_id: str, + tool_name: str, + args: dict[str, Any], + tool_call_id: str = "", + ) -> None: + turn = self._turn_for_tool_hook(session_id) + if turn is None: + return + tool_item_id = item_id() + correlation_key = tool_call_id or tool_name + turn.tool_items[correlation_key] = tool_item_id + data = canonical_tool_data(tool_name, args) + payload: dict[str, Any] = { + "threadId": turn.thread_id, + "sessionId": turn.session_id, + "turnId": turn.turn_id, + "itemId": tool_item_id, + "itemType": canonical_tool_item_type(tool_name), + "status": "inProgress", + "title": tool_name, + } + if data is not None: + payload["data"] = data + self._schedule( + self._send_frame( + frame( + "item.started", + **payload, + ) + ) + ) + + def emit_tool_completed( + self, + session_id: str, + tool_name: str, + result: str, + duration_ms: int | None, + tool_call_id: str = "", + status: str = "", + ) -> None: + turn = self._turn_for_tool_hook(session_id) + if turn is None: + return + correlation_key = tool_call_id or tool_name + tool_item_id = turn.tool_items.pop(correlation_key, None) or item_id() + del result + payload: dict[str, Any] = { + "threadId": turn.thread_id, + "sessionId": turn.session_id, + "turnId": turn.turn_id, + "itemId": tool_item_id, + "itemType": canonical_tool_item_type(tool_name), + "status": "failed" if status == "error" else "completed", + "title": tool_name, + } + if duration_ms is not None: + payload["detail"] = f"Completed in {duration_ms} ms" + payload["data"] = {"durationMs": duration_ms} + self._schedule( + self._send_frame( + frame( + "item.completed", + **payload, + ) + ) + ) + + @classmethod + def route_tool_started( + cls, + tool_name: str, + args: dict[str, Any], + session_id: str, + tool_call_id: str = "", + ) -> None: + for instance in list(cls._instances): + instance.emit_tool_started(session_id, tool_name, args, tool_call_id) + + @classmethod + def route_tool_completed( + cls, + tool_name: str, + result: str, + session_id: str, + duration_ms: int | None, + tool_call_id: str = "", + status: str = "", + ) -> None: + for instance in list(cls._instances): + instance.emit_tool_completed( + session_id, + tool_name, + result, + duration_ms, + tool_call_id, + status, + ) + + def _source(self, thread_id: str, message_id: str): + return self.build_source( + chat_id=thread_id, + chat_name=f"T3 thread {thread_id}", + chat_type="dm", + user_id="t3-code", + user_name="T3 Code", + message_id=message_id, + ) + + async def _send_frame(self, message: dict[str, Any]) -> None: + connection = self._connection + if connection is None: + raise ConnectionError("T3 Code gateway is offline") + await connection.send(message) + + async def _send_status(self) -> None: + if self._connection is None or not self._connection.connected: + return + await self._send_frame( + frame( + "connection.status", + activeSessionCount=len(self._active_session_threads), + ) + ) + + async def _handle_connection_state( + self, connected: bool, reason: str | None + ) -> None: + if connected: + self._mark_connected() + await self._send_status() + return + self._settle_pending_handoffs() + self._mark_disconnected() + if reason: + logger.warning("T3 gateway offline: %s", reason) + + def _schedule(self, coroutine: Coroutine[Any, Any, Any]) -> None: + """Run a coroutine on the adapter's bound loop from any thread. + + Hermes calls the tool hooks from the agent thread, so this is the + boundary back onto the gateway loop. `create_task` is only valid when + the *running* loop is the adapter's own loop — checking merely for "a + loop is running" would schedule onto whichever unrelated loop happens + to be current. Created tasks are held in a strong-reference set (asyncio + only holds a weak one) and their exceptions are logged rather than + surfacing as bare "task exception was never retrieved" warnings. + """ + loop = self._event_loop + if loop is None or loop.is_closed(): + coroutine.close() + return + try: + running_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + if running_loop is loop: + task = loop.create_task(coroutine) + self._scheduled_tasks.add(task) + task.add_done_callback(self._finish_scheduled_task) + return + try: + asyncio.run_coroutine_threadsafe(coroutine, loop) + except RuntimeError: # loop closed between the check and the submit + coroutine.close() + + def _finish_scheduled_task(self, task: asyncio.Task[Any]) -> None: + self._scheduled_tasks.discard(task) + if task.cancelled(): + return + error = task.exception() + if error is not None: + logger.error("T3 gateway background task failed: %s", error, exc_info=error) + + +def check_requirements() -> bool: + return dependency_available() + + +def validate_config(config: PlatformConfig) -> bool: + extra = getattr(config, "extra", {}) or {} + return ( + bool(extra.get("url") or os.environ.get(URL_ENV, "")) + and bool(extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, "")) + and bool(extra.get("credential") or os.environ.get(CREDENTIAL_ENV, "")) + ) + + +def env_enablement() -> dict[str, Any] | None: + """Seed `PlatformConfig.extra` from the environment at config-load time. + + Called by the platform registry's env-enablement hook before the adapter is + constructed, so `gateway status` and `get_connected_platforms()` reflect an + env-only enrollment without instantiating a connection. + + `home_channel` is a **magic key**, not an ordinary extra: core pops it out + of the returned dict and promotes it to a real `HomeChannel` dataclass on + the `PlatformConfig` (`gateway/config.py:2648-2660`, reading only + `chat_id` / `name` / `thread_id`). That promotion is what makes + `get_home_channel("t3")` resolve, which is in turn what makes + `send_message` with a bare `t3` target, the gateway's lifecycle broadcasts, + and `/handoff t3` work at all — core hardcodes env promotion only for + built-in platforms, so a plugin must supply it here. Pattern copied from + IRC (`plugins/platforms/irc/adapter.py:653-701`). + + The thread id comes from `T3_HOME_CHANNEL`, which T3 owns: the plugin + rewrites it from `homeThreadId` on every `connection.accepted`. Before the + first accept there is nothing to seed and the key is simply absent — Hermes + then behaves exactly as it did pre-home-channel, which is why the + `/sethome` nudge suppression is still needed for that window. + """ + url = os.environ.get(URL_ENV, "").strip() + instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip() + credential = os.environ.get(CREDENTIAL_ENV, "").strip() + if not (url and instance_id and credential): + return None + seed: dict[str, Any] = { + "url": url, + "instance_id": instance_id, + "credential": credential, + "nickname": os.environ.get(NICKNAME_ENV, "").strip() or "Hermes", + } + home = os.environ.get(HOME_CHANNEL_ENV, "").strip() + if home: + # T3 threads are the addressing unit end to end: `chat_id` IS the + # thread id, and the separate `thread_id` field stays unset. Setting + # both would make Hermes route `chat_id` + `thread_id` metadata at a + # platform whose `send(chat_id, ...)` already resolves the thread. + seed["home_channel"] = {"chat_id": home, "name": "Home"} + return seed diff --git a/integrations/hermes-t3-gateway/cli.py b/integrations/hermes-t3-gateway/cli.py new file mode 100644 index 000000000000..383f8dbbe966 --- /dev/null +++ b/integrations/hermes-t3-gateway/cli.py @@ -0,0 +1,125 @@ +"""`hermes t3 connect` enrollment command.""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys + +from .connection import ConnectionRejected, enroll_once, websocket_url + +URL_ENV = "HERMES_T3_GATEWAY_URL" +INSTANCE_ID_ENV = "HERMES_T3_GATEWAY_INSTANCE_ID" +CREDENTIAL_ENV = "HERMES_T3_GATEWAY_CREDENTIAL" +NICKNAME_ENV = "HERMES_T3_GATEWAY_NICKNAME" + + +def _hermes_version() -> str: + try: + from hermes_cli import __version__ + + return str(__version__) + except Exception: # noqa: BLE001 - version discovery must not block enrollment + return "unknown" + + +def register_cli(parser: argparse.ArgumentParser) -> None: + commands = parser.add_subparsers(dest="t3_command") + connect = commands.add_parser( + "connect", + help="Pair this Hermes process with a named T3 Code provider instance", + ) + connect.add_argument( + "--url", + required=True, + help="T3 browser origin or explicit ws(s) gateway URL", + ) + connect.add_argument( + "--token", + required=True, + help="Short-lived, one-time enrollment token generated by T3 Code", + ) + connect.set_defaults(func=t3_command) + + status = commands.add_parser("status", help="Show local T3 enrollment state") + status.set_defaults(func=t3_command) + + +def t3_command(args) -> None: + command = getattr(args, "t3_command", None) + if command == "status": + _print_status() + return + if command != "connect": + print("Usage: hermes t3 connect --url --token ") + return + + url = str(getattr(args, "url", "") or "").strip() + token = str(getattr(args, "token", "") or "").strip() + try: + normalized_url = websocket_url(url) + accepted = asyncio.run( + enroll_once( + url=normalized_url, + token=token, + hermes_version=_hermes_version(), + ) + ) + except ConnectionRejected as exc: + print(f"✗ T3 enrollment rejected ({exc.code}): {exc}") + raise SystemExit(1) from exc + except Exception as exc: + print(f"✗ Could not enroll with T3 Code: {exc}") + raise SystemExit(1) from exc + + values = { + URL_ENV: normalized_url, + INSTANCE_ID_ENV: str(accepted["instanceId"]), + CREDENTIAL_ENV: str(accepted["credential"]), + NICKNAME_ENV: str(accepted.get("nickname") or "Hermes"), + } + try: + from hermes_cli.config import get_env_path, save_env_value + + for key, value in values.items(): + save_env_value(key, value) + env_path = get_env_path() + except Exception as exc: + print(f"✗ Enrollment succeeded, but credentials could not be saved: {exc}") + print(" The credential was not printed. Revoke and re-enroll this instance.") + raise SystemExit(1) from exc + + # Mirror the newly written values into this process for status output and + # tests. A running gateway still needs a restart to construct the adapter. + os.environ.update(values) + print(f'✓ Connected Hermes to T3 Code as "{values[NICKNAME_ENV]}"') + print(f" Instance: {values[INSTANCE_ID_ENV]}") + print(f" Gateway: {normalized_url}") + print(f" Saved the credential securely in {env_path} (value hidden).") + print(" Restart `hermes gateway` to activate the connection.") + + +def _print_status() -> None: + url = os.environ.get(URL_ENV, "").strip() + instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip() + credential = os.environ.get(CREDENTIAL_ENV, "").strip() + nickname = os.environ.get(NICKNAME_ENV, "").strip() or "Hermes" + if not (url and instance_id and credential): + print("T3 Code: not enrolled") + print("Run: hermes t3 connect --url --token ") + return + print(f"T3 Code: enrolled as {nickname}") + print(f" Instance: {instance_id}") + print(f" Gateway: {url}") + print(" Credential: configured (hidden)") + + +if __name__ == "__main__": # pragma: no cover - executable fallback + parser = argparse.ArgumentParser(prog="python -m hermes_t3_gateway.cli") + register_cli(parser) + parsed = parser.parse_args() + if not hasattr(parsed, "func"): + parser.print_help() + sys.exit(2) + parsed.func(parsed) diff --git a/integrations/hermes-t3-gateway/connection.py b/integrations/hermes-t3-gateway/connection.py new file mode 100644 index 000000000000..9c669dc0f302 --- /dev/null +++ b/integrations/hermes-t3-gateway/connection.py @@ -0,0 +1,379 @@ +"""Outbound authenticated WebSocket connection to a T3 Code server.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from .protocol import PROTOCOL_VERSION, WEBSOCKET_PATH, connection_hello, iso_now + +logger = logging.getLogger(__name__) + +try: + import websockets +except ImportError: # pragma: no cover - Hermes currently installs websockets + websockets = None + +MessageHandler = Callable[[dict[str, Any]], Awaitable[None]] +StateHandler = Callable[[bool, str | None], Awaitable[None] | None] +AcceptedHandler = Callable[[dict[str, Any]], Awaitable[None]] + + +class ConnectionRejected(RuntimeError): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +def websocket_url(url: str) -> str: + """Normalize an HTTP(S) browser origin or WS(S) URL to the gateway route.""" + raw = (url or "").strip() + parsed = urlsplit(raw) + scheme = parsed.scheme.lower() + if scheme == "https": + scheme = "wss" + elif scheme == "http": + scheme = "ws" + if scheme not in {"ws", "wss"} or not parsed.netloc: + raise ValueError("URL must use http://, https://, ws://, or wss://") + path = parsed.path.rstrip("/") + if path != WEBSOCKET_PATH: + path = WEBSOCKET_PATH + return urlunsplit((scheme, parsed.netloc, path, "", "")) + + +def dependency_available() -> bool: + return websockets is not None + + +async def _open_socket(url: str): + if websockets is None: + raise RuntimeError( + "The `websockets` package is unavailable. Install the standard " + "Hermes Agent dependencies and retry." + ) + return await websockets.connect( # type: ignore[union-attr] + websocket_url(url), + open_timeout=20, + ping_interval=20, + ping_timeout=20, + close_timeout=5, + # Protocol v4 turn frames may carry inline base64 attachments up to + # 25MB raw (~34MB encoded, `protocol.MAX_MEDIA_BYTES`). 64MB leaves + # room for the JSON envelope and T3's per-turn total while still + # bounding a pathological frame. + max_size=64 * 1024 * 1024, + ) + + +async def authenticate_socket( + socket: Any, + *, + authentication: dict[str, str], + hermes_version: str, + timeout: float = 20, + role: str = "gateway", +) -> dict[str, Any]: + hello = connection_hello( + hermes_version=hermes_version, + authentication=authentication, + role=role, + ) + await socket.send(json.dumps(hello, separators=(",", ":"), ensure_ascii=False)) + + # Read until the reply to THIS hello arrives. The handshake is not + # guaranteed to be the only frame in flight — the server may already be + # probing liveness — and treating whatever arrives first as the reply + # tears down the connection that was just established, in a loop. + deadline = asyncio.get_running_loop().time() + timeout + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError("T3 did not answer the gateway handshake in time") + raw = await asyncio.wait_for(socket.recv(), timeout=remaining) + message = json.loads(raw) + if not isinstance(message, dict): + raise TypeError("T3 returned a non-object handshake frame") + if message.get("type") == "ping": + # Answer inline: the read loop that normally handles this has not + # started yet, and an unanswered ping counts against liveness. + await socket.send( + json.dumps( + { + "type": "pong", + "protocolVersion": PROTOCOL_VERSION, + "requestId": message.get("requestId"), + "sentAt": message.get("sentAt") or iso_now(), + }, + separators=(",", ":"), + ensure_ascii=False, + ) + ) + continue + if message.get("requestId") != hello["requestId"]: + # Some other correlated frame raced the handshake; keep waiting for + # ours rather than failing the whole connection. + logger.debug( + "Ignoring a non-handshake frame while authenticating: %s", + message.get("type"), + ) + continue + break + if message.get("type") == "connection.rejected": + raise ConnectionRejected( + str(message.get("code") or "internal-error"), + str(message.get("message") or "T3 rejected the gateway connection"), + ) + if message.get("type") != "connection.accepted": + raise RuntimeError( + f"expected connection.accepted, received {message.get('type')!r}" + ) + if message.get("protocolVersion") != PROTOCOL_VERSION: + raise RuntimeError("T3 accepted the connection with an incompatible version") + return message + + +async def enroll_once( + *, + url: str, + token: str, + hermes_version: str, +) -> dict[str, Any]: + socket = await _open_socket(url) + try: + accepted = await authenticate_socket( + socket, + authentication={"type": "enrollment-token", "token": token}, + hermes_version=hermes_version, + ) + if not accepted.get("instanceId") or not accepted.get("credential"): + raise RuntimeError( + "T3 accepted enrollment without returning an instance credential" + ) + return accepted + finally: + await socket.close() + + +class T3GatewayConnection: + """Reconnectable runtime connection authenticated by an instance credential.""" + + def __init__( + self, + *, + url: str, + instance_id: str, + credential: str, + hermes_version: str, + on_message: MessageHandler, + on_state: StateHandler | None = None, + on_accepted: AcceptedHandler | None = None, + ): + self.url = websocket_url(url) + self.instance_id = instance_id + self.credential = credential + self.hermes_version = hermes_version + self._on_message = on_message + self._on_state = on_state + self._on_accepted = on_accepted + self._socket: Any = None + self._supervisor: asyncio.Task[None] | None = None + self._send_lock = asyncio.Lock() + self._connected = asyncio.Event() + self._first_result: asyncio.Future[bool] | None = None + self._handlers: set[asyncio.Task[None]] = set() + self._stopping = False + + @property + def connected(self) -> bool: + return self._connected.is_set() + + async def connect(self, timeout: float = 30) -> bool: + if self._supervisor is not None and not self._supervisor.done(): + return self.connected + self._stopping = False + self._first_result = asyncio.get_running_loop().create_future() + self._supervisor = asyncio.create_task( + self._supervise(), name="hermes-t3-gateway" + ) + try: + return await asyncio.wait_for(asyncio.shield(self._first_result), timeout) + except TimeoutError: + await self.disconnect() + return False + + async def disconnect(self) -> None: + self._stopping = True + self._connected.clear() + if self._socket is not None: + with suppress(Exception): + await self._socket.close() + self._socket = None + if self._supervisor is not None: + self._supervisor.cancel() + with suppress(asyncio.CancelledError): + await self._supervisor + self._supervisor = None + # Command handlers can outlive the read loop (a turn handler commonly + # waits on Hermes for minutes). They must not continue mutating adapter + # state after consumers have observed the disconnected notification. + handlers = tuple(self._handlers) + for task in handlers: + task.cancel() + if handlers: + await asyncio.gather(*handlers, return_exceptions=True) + await self._notify_state(False, None) + + async def send(self, message: dict[str, Any]) -> None: + if not self.connected or self._socket is None: + raise ConnectionError("T3 Code gateway is offline") + encoded = json.dumps(message, separators=(",", ":"), ensure_ascii=False) + async with self._send_lock: + await self._socket.send(encoded) + + def _spawn_handler(self, message: dict[str, Any]) -> None: + """Run one command handler off the read loop. + + asyncio only holds a weak reference to tasks, so the handle is kept + until completion — otherwise a long turn can be garbage collected + mid-flight. Failures are logged rather than surfacing as bare + "task exception was never retrieved" warnings. + """ + task = asyncio.create_task(self._on_message(message)) + self._handlers.add(task) + + def _finished(completed: asyncio.Task[None]) -> None: + self._handlers.discard(completed) + if completed.cancelled(): + return + error = completed.exception() + if error is not None: + logger.warning( + "T3 gateway command handler failed: %s", error, exc_info=error + ) + + task.add_done_callback(_finished) + + async def _send_pong(self, ping: dict[str, Any]) -> None: + """Answer a liveness probe without going through command dispatch.""" + request_id = ping.get("requestId") + if not request_id: + return + try: + await self.send( + { + "type": "pong", + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id, + "sentAt": ping.get("sentAt") or iso_now(), + } + ) + except Exception: # noqa: BLE001 - a failed pong must not kill the read loop + logger.debug("Failed to answer a T3 liveness ping", exc_info=True) + + async def _supervise(self) -> None: + delay = 1.0 + while not self._stopping: + reason: str | None = None + accepted_task: asyncio.Task[None] | None = None + try: + socket = await _open_socket(self.url) + self._socket = socket + accepted = await authenticate_socket( + socket, + authentication={ + "type": "instance-credential", + "instanceId": self.instance_id, + "credential": self.credential, + }, + hermes_version=self.hermes_version, + ) + self._connected.set() + if self._first_result is not None and not self._first_result.done(): + self._first_result.set_result(True) + # Deliberately after `_connected.set()`: the accepted callback + # reconciles the home designation and flushes the durable + # delivery queue, and both send frames back over this socket. + # It must not run *before* the read loop, though: a large media + # backlog can apply send backpressure while its acknowledgements + # and liveness pings wait unread on the same socket. Running it + # as a generation-local task lets the loop consume both. + accepted_task = asyncio.create_task( + self._notify_accepted(accepted), name="hermes-t3-accepted" + ) + await self._notify_state(True, None) + delay = 1.0 + async for raw in socket: + message = json.loads(raw) + if not isinstance(message, dict): + continue + # Liveness is answered inline; it never touches Hermes. + if message.get("type") == "ping": + await self._send_pong(message) + continue + # Commands are dispatched WITHOUT awaiting them. A handler + # awaits Hermes — `turn.start` blocks for the whole agent + # turn — and awaiting it here would stop reading the + # socket, so a ping sent mid-turn would not even be read, + # let alone answered, and T3 would close a healthy + # connection as half-open. Ordering within a session is + # still preserved by the plugin's own per-thread state. + self._spawn_handler(message) + except asyncio.CancelledError: + raise + except ConnectionRejected as exc: + reason = f"{exc.code}: {exc}" + if self._first_result is not None and not self._first_result.done(): + self._first_result.set_exception(exc) + # Revoked credentials and version mismatches need operator + # action; reconnecting the same secret can never recover. + if exc.code in { + "instance-revoked", + "invalid-authentication", + "version-incompatible", + }: + self._stopping = True + except Exception as exc: # noqa: BLE001 - reconnect every transient transport failure + reason = str(exc) + logger.warning("T3 gateway connection dropped: %s", exc) + finally: + if accepted_task is not None and not accepted_task.done(): + accepted_task.cancel() + with suppress(asyncio.CancelledError): + await accepted_task + self._connected.clear() + self._socket = None + await self._notify_state(False, reason) + if self._stopping: + break + await asyncio.sleep(delay) + delay = min(delay * 2, 30.0) + + async def _notify_accepted(self, accepted: dict[str, Any]) -> None: + """Hand the `connection.accepted` frame to the adapter, best-effort. + + A failure here — a read-only `.env`, an unwritable queue file — must + not tear down a connection that authenticated successfully, so it is + logged and swallowed exactly like the state callback. + """ + if self._on_accepted is None: + return + try: + await self._on_accepted(accepted) + except Exception: # noqa: BLE001 - reconciliation must not fail a good handshake + logger.warning("T3 connection accepted callback failed", exc_info=True) + + async def _notify_state(self, connected: bool, reason: str | None) -> None: + if self._on_state is None: + return + try: + result = self._on_state(connected, reason) + if asyncio.iscoroutine(result): + await result + except Exception: + logger.debug("T3 connection state callback failed", exc_info=True) diff --git a/integrations/hermes-t3-gateway/coreshim.py b/integrations/hermes-t3-gateway/coreshim.py new file mode 100644 index 000000000000..6ce3deca3ca0 --- /dev/null +++ b/integrations/hermes-t3-gateway/coreshim.py @@ -0,0 +1,328 @@ +"""In-process compensation for two upstream `send_message` media defects. + +Both defects live in Hermes core's `tools/send_message_tool.py` and both are +about plugin platforms that *can* carry media but are not on core's hard-coded +prose list. Line numbers below are against **Hermes v0.19.0**. + +**Bug A — the false warning (cosmetic, but it lies to the agent).** +`tools/send_message_tool.py:1108-1113` precomputes:: + + warning = f"MEDIA attachments were omitted for {platform.value}; ..." + +whenever `media_files` is non-empty and the platform is not one of the nine +names spelled out in that string. Line 1154-1157 then appends it to *any* +successful result without ever consulting whether the send actually dropped +anything. Our standalone sender delivers media as `media.deliver` frames and +waits for the acks — and is then told, in the same JSON blob, that the files +were omitted. A live agent read that warning and reported a delivery failure to +the user for files T3 had already rendered. + +**Bug B — the silent drop (the one that actually loses data).** +`tools/send_message_tool.py:711-732`: when the gateway is co-resident, the +runner weakref resolves and `runner.adapters.get(platform)` returns our live +adapter, so core calls:: + + result = await adapter.send(chat_id=chat_id, content=chunk, metadata=metadata) + +and returns. `media_files` is never passed. It cannot be recovered from +`content` either, because `BasePlatformAdapter.extract_media` +(`tools/send_message_tool.py:442`) already stripped the `MEDIA:` directives out +of the text before chunking. So `send_message` / `hermes send` with attachments, +run in the same process as `hermes gateway`, loses the files with no error and +no warning. This went unnoticed in live testing only because `hermes send` ran +out-of-process, where the runner weakref is empty and core falls through to the +`standalone_sender_fn` path (line 731+), which does pass `media_files`. + +**What this module does.** At plugin load it wraps the two functions in the +already-imported `tools.send_message_tool` module object: + +* `_send_via_adapter` — for platform `t3` with non-empty `media_files`, skip + core's live-adapter shortcut entirely and call our own `standalone_send` + (from `.home`), which handles media correctly. Text-only `t3` sends and every + other platform reach the original function untouched. +* `_send_to_platform` — post-process the result, dropping the Bug A warning from + `result["warnings"]`. Matched by the stable prefix `"MEDIA attachments were + omitted for t3"`, never the full prose: the nine-platform list inside that + sentence changes between releases, and matching it exactly would silently + stop working on the next upgrade. + +**Removable.** Both wrappers become dead weight the moment upstream grows +capability-driven media handling — i.e. once `PlatformEntry` can advertise +"this platform delivers media" and core consults it instead of the hard-coded +list, and once the co-resident branch forwards `media_files` to `adapter.send`. +Delete this module and its `register()` call at that point; nothing else in the +plugin depends on it. + +**Fail-open contract.** This module never raises into the plugin's `register()` +and never makes a working Hermes worse: + +* Every patch feature-detects its target first — the module must be importable, + the attribute must exist, it must be a coroutine function, and its signature + must carry the parameters we rely on. Any mismatch means no patch. +* On any failure we log exactly one warning and leave core untouched. The + fallback is the current upstream behaviour: buggy, but working and known. +* An upstream upgrade that renames, re-signatures, or restructures these + functions therefore degrades to "unpatched", never to a crash. +* Applying twice is a no-op — the wrappers carry a marker attribute. + +Even fully failed open, the residual damage is bounded: `standalone_send` +stamps `media_count` / `acked_count` / a delivered-count `note` onto every +success result (see `home.py`), so the false warning always sits next to +counter-evidence. The un-compensated Bug B remains a real silent drop, which is +why the patch is attempted at all. +""" + +from __future__ import annotations + +import inspect +import logging +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +PLATFORM = "t3" + +# Bug A's warning text, matched by prefix only. The remainder of the sentence +# names the platforms core believes support media, and that list has changed +# across releases — pinning the full prose would silently stop matching. The +# trailing ";" is part of the prefix and load-bearing: without it the match is +# also satisfied by a platform whose name merely *starts with* "t3". +_OMISSION_WARNING_PREFIX = f"MEDIA attachments were omitted for {PLATFORM};" + +# Marker set on every wrapper we install, so a second `register()` (or a +# `discover_plugins(force=True)` rescan) does not stack wrappers on wrappers. +_MARKER = "_t3_gateway_shim" + + +def _platform_name(platform: Any) -> str: + """Core passes a `Platform` enum member; be liberal about what we accept.""" + value = getattr(platform, "value", platform) + return str(value or "").strip().lower() + + +def _target_module() -> Any | None: + """Return the upstream module, or None when it cannot be imported. + + Imported lazily and defensively: this plugin is loaded by Hermes itself, so + the module is normally already in `sys.modules` and this is a dict hit, but + a stripped or restructured install must degrade to "no patch" rather than + breaking plugin registration. + """ + try: + import tools.send_message_tool as module + + return module + except Exception: # noqa: BLE001 - any import failure means "do not patch" + logger.warning( + "T3 gateway: tools.send_message_tool is unavailable; leaving core " + "send_message unpatched (media may be dropped on the co-resident " + "path and a false omission warning may appear)", + exc_info=True, + ) + return None + + +def _usable(module: Any, name: str, required_params: tuple[str, ...]) -> Callable | None: + """Feature-detect one patch target. Returns the function, or None. + + Checks, in order: the attribute exists, it is a coroutine function (we wrap + it with `async def`, so a sync target would break every caller), it is not + already wrapped, and its signature exposes the parameters this shim reads by + name. A `*args, **kwargs`-style signature is accepted only if the named + parameters are genuinely present — we never guess positionally. + """ + original = getattr(module, name, None) + if original is None: + logger.warning( + "T3 gateway: %s.%s is missing; leaving it unpatched", module.__name__, name + ) + return None + if getattr(original, _MARKER, False): + return None # already patched; idempotent no-op, nothing to report + if not inspect.iscoroutinefunction(original): + logger.warning( + "T3 gateway: %s.%s is not a coroutine function; leaving it unpatched", + module.__name__, + name, + ) + return None + try: + parameters = inspect.signature(original).parameters + except (TypeError, ValueError): + logger.warning( + "T3 gateway: %s.%s has an unreadable signature; leaving it unpatched", + module.__name__, + name, + exc_info=True, + ) + return None + missing = [param for param in required_params if param not in parameters] + if missing: + logger.warning( + "T3 gateway: %s.%s no longer takes %s; leaving it unpatched (upstream " + "may have fixed this, or changed shape)", + module.__name__, + name, + ", ".join(missing), + ) + return None + return original + + +def _patch_send_via_adapter(module: Any) -> bool: + """Bug B: route co-resident `t3` media sends through our own sender.""" + original = _usable( + module, + "_send_via_adapter", + ("platform", "pconfig", "chat_id", "chunk", "thread_id", "media_files"), + ) + if original is None: + return False + + from .home import standalone_send + signature = inspect.signature(original) + + async def _send_via_adapter(*args, **kwargs): + bound = signature.bind(*args, **kwargs) + bound.apply_defaults() + values = bound.arguments + platform = values["platform"] + media_files = values["media_files"] + if _platform_name(platform) == PLATFORM and media_files: + # Core would hand this to `adapter.send(chat_id, content, metadata)` + # and drop `media_files` on the floor. Our sender takes the whole + # send — text frame plus one media frame per file — over a + # short-lived `role: "delivery"` socket, which by design cannot + # displace the live gateway connection sitting in the same process. + return await standalone_send( + values["pconfig"], + values["chat_id"], + values["chunk"], + thread_id=values.get("thread_id"), + media_files=media_files, + force_document=values.get("force_document", False), + ) + return await original(*args, **kwargs) + + setattr(_send_via_adapter, _MARKER, True) + _send_via_adapter.__wrapped__ = original + module._send_via_adapter = _send_via_adapter + return True + + +def _strip_false_warning(result: Any) -> Any: + """Drop Bug A's warning from a result dict, in place. Anything else passes.""" + if not isinstance(result, dict): + return result + warnings = result.get("warnings") + if not isinstance(warnings, list): + return result + kept = [ + warning + for warning in warnings + if not ( + isinstance(warning, str) + and warning.startswith(_OMISSION_WARNING_PREFIX) + ) + ] + if len(kept) == len(warnings): + return result + if kept: + result["warnings"] = kept + else: + # An empty list would still read as "this send had warnings" to a + # skimming agent; the key is optional upstream, so remove it. + result.pop("warnings", None) + return result + + +def _patch_send_to_platform(module: Any) -> bool: + """Bug A: strip the unconditional omission warning, and rescue media-only sends. + + Two interceptions, both scoped to media-bearing `t3` sends: + + *Before* the original, one narrow bypass. A send with attachments and no + text hard-errors at `tools/send_message_tool.py:1101-1107` (v0.19.0):: + + if media_files and not message.strip(): + return {"error": "... target t3 had only media attachments"} + + That check sits above the chunk loop, so the send never reaches + `_send_via_adapter` and the Bug B patch cannot see it. `MEDIA:/tmp/x.png` + with no prose — a perfectly ordinary agent send — fails outright. We route + it straight to our sender, which already handles an empty message by + emitting media frames only. Chunking is not skipped in any meaningful sense: + there is no text to chunk. + + *After* the original, warning removal. Post-processing is the least invasive + seam available: `_send_to_platform` is a ~380-line router whose warning is + computed at line 1111 and attached at line 1154 with nothing interceptable + in between. The original runs in full and we drop only the one warning we + know to be false; every other warning it may add survives, and non-`t3` + results are returned without even being inspected. + + A success from our sender means the frames were handed over — acked, or + durably queued for the next connect (`media_count` / `acked_count` on the + result say which). Nothing deliverable is reported as a hard `error`. + """ + original = _usable( + module, + "_send_to_platform", + ("platform", "pconfig", "chat_id", "message", "thread_id", "media_files"), + ) + if original is None: + return False + + from .home import standalone_send + signature = inspect.signature(original) + + async def _send_to_platform(*args, **kwargs): + bound = signature.bind(*args, **kwargs) + bound.apply_defaults() + values = bound.arguments + platform = values["platform"] + media_files = values["media_files"] + message = values["message"] + is_t3_media = _platform_name(platform) == PLATFORM and bool(media_files) + if is_t3_media and not str(message or "").strip(): + return await standalone_send( + values["pconfig"], + values["chat_id"], + message or "", + thread_id=values.get("thread_id"), + media_files=media_files, + force_document=values.get("force_document", False), + ) + result = await original(*args, **kwargs) + if not is_t3_media: + return result + if isinstance(result, dict) and result.get("success"): + return _strip_false_warning(result) + return result + + setattr(_send_to_platform, _MARKER, True) + _send_to_platform.__wrapped__ = original + module._send_to_platform = _send_to_platform + return True + + +def apply(module: Any | None = None) -> dict[str, bool]: + """Install both wrappers. Never raises. + + Returns a per-patch applied/not-applied map, which is what the tests assert + on. `module` is injectable so tests can drive a faithful fake of the + upstream shape without importing Hermes. + """ + applied = {"_send_via_adapter": False, "_send_to_platform": False} + try: + target = module if module is not None else _target_module() + if target is None: + return applied + applied["_send_via_adapter"] = _patch_send_via_adapter(target) + applied["_send_to_platform"] = _patch_send_to_platform(target) + except Exception: # noqa: BLE001 - a broken shim must not break the plugin + logger.warning( + "T3 gateway: could not patch core send_message; leaving it unpatched", + exc_info=True, + ) + return applied diff --git a/integrations/hermes-t3-gateway/home.py b/integrations/hermes-t3-gateway/home.py new file mode 100644 index 000000000000..4e660674307f --- /dev/null +++ b/integrations/hermes-t3-gateway/home.py @@ -0,0 +1,950 @@ +"""Home-channel delivery: durable queue, classification, standalone sender. + +Hermes-initiated output — cron results, the agent's `send_message` tool with a +bare `t3` target, gateway lifecycle notices, `/handoff t3` — has no T3-issued +turn to stream into. It is delivered as a `home.deliver` frame against the +instance's durable **home thread**, whose id T3 owns and republishes on every +`connection.accepted`. + +Three concerns live here rather than in the adapter: + +* **The durable queue.** A delivery is written to disk before it is sent and + removed only when T3 acknowledges it, so nothing is lost when either side + restarts mid-flight. T3 dedupes on `deliveryId`, which is what makes + re-flushing the whole queue safe. +* **Kind/label classification.** Best-effort provenance recovery from the send + context — see `classify_delivery`. +* **The standalone sender.** Out-of-process cron has no live adapter, so it + dials T3 itself over a short-lived `role: "delivery"` socket. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import mimetypes +import os +import tempfile +import threading +from pathlib import Path +from typing import Any + +from .protocol import ( + HOME_DELIVERY_KINDS, + PROTOCOL_VERSION, + delivery_id, + home_deliver, + iso_now, + media_deliver, +) + +logger = logging.getLogger(__name__) + +try: # POSIX advisory locking; absent on Windows. + import fcntl +except ImportError: # pragma: no cover - Windows fallback + fcntl = None # type: ignore[assignment] + +# The env var carrying T3's home-thread designation. +# +# The name is NOT arbitrary. Hermes resolves a platform's cron home target with +# `_home_target_env_var` (`gateway/run.py:1541`), which falls back to +# `f"{PLATFORM.upper()}_HOME_CHANNEL"` for any platform without an override +# entry. Platform `t3` therefore resolves to exactly this string, so the +# `send_message` error hints, `/sethome` messaging, and cron's env-only +# resolution path all agree with the value the plugin writes — with no upstream +# override table entry required. +HOME_CHANNEL_ENV = "T3_HOME_CHANNEL" + +# Queue location. `gateway/` under the Hermes home is the subdirectory Hermes' +# own bundled plugins use for per-profile adapter state (the Discord adapter's +# command-sync and non-conversational stores, `plugins/platforms/discord/ +# adapter.py:52`, `:272`, `:1694`). Using `get_hermes_home()` as the base means +# the queue is profile-scoped for free: a second profile with its own +# `HERMES_HOME` gets its own queue rather than replaying another profile's +# deliveries into its own home thread. +QUEUE_SUBDIR = "gateway" +QUEUE_FILENAME = "t3_home_delivery_queue.jsonl" + +# Bound the queue. Deliveries are small (a cron brief, a lifecycle line), so a +# few hundred is generous for any realistic outage while keeping the file +# readable and the flush bounded. Overflow drops the OLDEST entries: a +# fortnight-old cron brief is worth less than this morning's, and dropping +# newest would make a wedged queue permanently swallow current output. +MAX_QUEUE_ENTRIES = 300 + +# Media frames carry base64 bytes, so an entry-count cap alone is not a disk +# bound: 300 maximum-sized frames would exceed 10GiB. Keep enough room for +# several full-size files and ordinary text history without letting a wedged +# companion consume a Hermes host's disk indefinitely. +MAX_QUEUE_BYTES = 256 * 1024 * 1024 + +# Bound one flush attempt. A reconnect must not spend minutes replaying before +# the connection is usable for live traffic; the remainder rides the next +# reconnect. +MAX_FLUSH_PER_CONNECT = 50 +MAX_FLUSH_BYTES_PER_CONNECT = 100 * 1024 * 1024 + + +def hermes_home() -> Path: + """Resolve Hermes' home directory, degrading to the documented default. + + Prefers Hermes' own accessor so a context-local profile override + (`set_hermes_home_override`) is honoured, then `HERMES_HOME`, then the + platform default. The plugin must not create state outside the active + profile, but it also must not fail to queue a delivery merely because + Hermes could not be imported (the standalone cron path can run in a very + thin process). + """ + try: + from hermes_cli.config import get_hermes_home + + return Path(get_hermes_home()) + except Exception: # noqa: BLE001 - queueing must never depend on Hermes importing + pass + try: + from hermes_constants import get_hermes_home + + return Path(get_hermes_home()) + except Exception: # noqa: BLE001 - same + pass + override = os.environ.get("HERMES_HOME", "").strip() + return Path(override) if override else Path.home() / ".hermes" + + +def queue_path() -> Path: + return hermes_home() / QUEUE_SUBDIR / QUEUE_FILENAME + + +def home_thread_id() -> str: + """Read the currently designated home thread from the environment.""" + return os.environ.get(HOME_CHANNEL_ENV, "").strip() + + +def save_home_thread_id(thread_id: str) -> bool: + """Persist T3's home designation, mirroring it into this process. + + T3's settings blob is authoritative and this env var is a synced cache, so + a differing local value is overwritten rather than merged — including one a + user hand-edited (documented in the plugin README). + + Returns True when the value was durably written. A read-only or managed + `.env` degrades to the in-process mirror only: routing works for the life + of this gateway and re-reconciles on the next connect. + """ + value = str(thread_id or "").strip() + if not value: + return False + saved = False + try: + from hermes_cli.config import save_env_value + + save_env_value(HOME_CHANNEL_ENV, value) + saved = True + except Exception as exc: # noqa: BLE001 - a read-only .env must not break the handshake + # No stack trace: outside a Hermes install this is simply + # ModuleNotFoundError for hermes_cli, which is expected and noisy. + logger.warning( + "Could not persist %s (%s); T3 home delivery will use the " + "in-process value until the next reconnect", + HOME_CHANNEL_ENV, + exc, + ) + # Mirror into this process exactly as enrollment does (`cli.py`): the + # running gateway resolves the home channel from the environment and must + # not need a restart to see a freshly designated thread. + os.environ[HOME_CHANNEL_ENV] = value + return saved + + +class HomeDeliveryQueue: + """Append-only JSONL outbox of unacknowledged delivery frames. + + Entries are stored as raw wire frames keyed on `deliveryId`, so the queue + carries `home.deliver` and `media.deliver` alike: flushing replays the + frame verbatim and T3 discriminates on `type`. A media entry is large (up + to ~34MB of base64 on one line), so the entry cap doubles as a coarse disk + bound; a genuinely wedged connection under heavy media output trades disk + for durability, which is the documented preference. + + Correctness rests on one rule: an entry is removed **only** when T3 acks + its `deliveryId`. Everything else — a socket that dropped mid-send, a + server that died before writing, a plugin that restarted — leaves the entry + on disk to be replayed. Replay is safe because T3 dedupes on `deliveryId`, + so the failure mode of this design is a duplicate suppressed server-side, + never a lost delivery. + + Two processes can hold the same queue: the gateway adapter and an + out-of-process cron run using the standalone sender. Writes take a POSIX + advisory lock on a sidecar file where `fcntl` is available; on Windows the + in-process lock alone applies and a concurrent cron process could in + principle interleave a rewrite. The consequence there is a duplicate + delivery (deduped by T3), not corruption of an already-acked entry. + """ + + def __init__( + self, + path: Path | None = None, + max_entries: int = MAX_QUEUE_ENTRIES, + max_bytes: int = MAX_QUEUE_BYTES, + ): + self._path = Path(path) if path is not None else None + self._max_entries = max(1, int(max_entries)) + self._max_bytes = max(1, int(max_bytes)) + self._lock = threading.RLock() + + @property + def path(self) -> Path: + # Resolved lazily, not in __init__: `get_hermes_home()` honours a + # context-local profile override that may be installed after the + # adapter is constructed. + return self._path if self._path is not None else queue_path() + + def _ensure_parent(self) -> None: + # 0700: queued frames carry message text and base64 media, so the + # outbox must not be readable by other users of the machine. Only a + # directory this call creates is affected — an existing `gateway/` is + # shared with Hermes' own plugin state and is not re-permissioned here. + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + + def _read_lines(self) -> list[dict[str, Any]]: + """Parse every queued entry. Raises `OSError` when the file is unreadable. + + The distinction callers depend on: a MISSING file is an empty queue, + while an unreadable one is an unknown queue. Collapsing the two into + `[]` is what would let a rewrite-based caller overwrite live entries it + merely failed to read. + """ + path = self.path + if not path.exists(): + return [] + raw = path.read_text(encoding="utf-8") + entries: list[dict[str, Any]] = [] + lines = raw.splitlines(keepends=True) + for index, raw_line in enumerate(lines): + line = raw_line.strip() + if not line: + continue + try: + entry = json.loads(line) + except ValueError: + # Only an unterminated final line can be a killed append that + # never returned success. Interior or newline-terminated + # corruption is an unknown queue, and callers must not rewrite + # it from a partial parse and destroy still-live deliveries. + if index == len(lines) - 1 and not raw_line.endswith(("\n", "\r")): + logger.warning("Ignoring a torn final T3 delivery queue record") + continue + raise OSError(f"corrupt T3 delivery queue record {index + 1}") + if not isinstance(entry, dict) or not entry.get("deliveryId"): + raise OSError(f"invalid T3 delivery queue record {index + 1}") + entries.append(entry) + return entries + + @staticmethod + def _encode(entry: dict[str, Any]) -> str: + return json.dumps(entry, separators=(",", ":"), ensure_ascii=False) + "\n" + + def _write_all(self, entries: list[dict[str, Any]]) -> None: + self._ensure_parent() + payload = "".join(self._encode(entry) for entry in entries) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", suffix=".tmp", dir=self.path.parent + ) + temporary = Path(temporary_name) + try: + # `mkstemp` is exclusive and does not follow a predictable symlink. + # Flush before replace, then fsync the directory so returning True + # means the renamed queue survives a host crash, not only a process + # restart with a warm page cache. + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = -1 + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, self.path) + self._fsync_parent() + finally: + if descriptor >= 0: + os.close(descriptor) + try: + temporary.unlink() + except FileNotFoundError: + pass + + def _fsync_parent(self) -> None: + """Persist a queue rename on filesystems that support directory fsync.""" + if os.name != "posix": + return + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + descriptor = os.open(self.path.parent, flags) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + def _append_line(self, entry: dict[str, Any]) -> None: + """Append one entry without reading or rewriting the file. + + The fallback when the queue could not be read: a single `O_APPEND` + write cannot destroy entries it cannot see, where the rewrite path + would replace all of them with this one. + """ + self._ensure_parent() + flags = ( + os.O_WRONLY + | os.O_CREAT + | os.O_APPEND + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + descriptor = os.open( + self.path, flags, 0o600 + ) + with os.fdopen(descriptor, "a", encoding="utf-8") as handle: + os.fchmod(handle.fileno(), 0o600) + handle.write(self._encode(entry)) + handle.flush() + os.fsync(handle.fileno()) + self._fsync_parent() + + def _file_lock(self): + """Advisory cross-process lock, or a no-op where unavailable.""" + + class _NoLock: + def __enter__(self): + return None + + def __exit__(self, *args): + return False + + if fcntl is None: + return _NoLock() + + queue = self.path + try: + queue.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + except OSError: + return _NoLock() + + class _FileLock: + def __init__(self, lock_path: Path): + self._lock_path = lock_path + self._handle: Any = None + + def __enter__(self): + descriptor: int | None = None + try: + # 0600 like the queue itself: the sidecar carries no + # payload, but a world-writable lock is a way to stall + # another user's deliveries. + flags = ( + os.O_RDWR + | os.O_CREAT + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + descriptor = os.open( + self._lock_path, flags, 0o600 + ) + os.fchmod(descriptor, 0o600) + self._handle = os.fdopen(descriptor, "a+") + descriptor = None + fcntl.flock(self._handle, fcntl.LOCK_EX) + except OSError: + if self._handle is not None: + self._handle.close() + elif descriptor is not None: + os.close(descriptor) + self._handle = None + return None + + def __exit__(self, *args): + if self._handle is not None: + try: + fcntl.flock(self._handle, fcntl.LOCK_UN) + finally: + self._handle.close() + self._handle = None + return False + + return _FileLock(queue.with_suffix(".jsonl.lock")) + + def append(self, frame: dict[str, Any]) -> bool: + """Persist one delivery. Returns False when it could not be written.""" + if not isinstance(frame, dict) or not frame.get("deliveryId"): + return False + with self._lock, self._file_lock(): + try: + entries = self._read_lines() + except OSError: + # The rewrite path is off the table: it would replace every + # entry we failed to read with just this one, destroying an + # arbitrary number of unacked deliveries over a transient + # error. Append blind instead. The costs are bounded and both + # self-correct — the cap goes unenforced until the next + # successful read (a too-long queue is trimmed then), and a + # duplicate of an entry already on disk is deduped by T3 on + # `deliveryId`. + logger.warning( + "Could not read the T3 home delivery queue at %s; appending " + "without rewriting it", + self.path, + exc_info=True, + ) + try: + self._append_line(dict(frame)) + except OSError: + logger.error( + "Could not persist a T3 home delivery to %s", + self.path, + exc_info=True, + ) + return False + return True + if any( + entry.get("deliveryId") == frame["deliveryId"] for entry in entries + ): + return True + entries.append(dict(frame)) + encoded_sizes = [len(self._encode(entry).encode("utf-8")) for entry in entries] + total_bytes = sum(encoded_sizes) + dropped = 0 + while len(entries) > 1 and ( + len(entries) > self._max_entries or total_bytes > self._max_bytes + ): + entries.pop(0) + total_bytes -= encoded_sizes.pop(0) + dropped += 1 + if dropped: + logger.warning( + "T3 home delivery queue is full (%d entries or %d bytes); " + "dropping the %d oldest undelivered %s", + self._max_entries, + self._max_bytes, + dropped, + "delivery" if dropped == 1 else "deliveries", + ) + try: + self._write_all(entries) + except OSError: + logger.error( + "Could not persist a T3 home delivery to %s", + self.path, + exc_info=True, + ) + return False + return True + + def entries(self) -> list[dict[str, Any]]: + """Every unacknowledged delivery, oldest first. + + An unreadable queue reports empty rather than raising: the callers are + the flush loop and `__len__`, and skipping a flush cycle costs one + reconnect's delay while the entries stay on disk. + """ + with self._lock: + try: + return self._read_lines() + except OSError: + logger.warning( + "Could not read the T3 home delivery queue at %s", + self.path, + exc_info=True, + ) + return [] + + def purge(self, delivery_id_value: str) -> bool: + """Remove one acknowledged delivery. Returns True when it was present.""" + target = str(delivery_id_value or "").strip() + if not target: + return False + with self._lock, self._file_lock(): + try: + entries = self._read_lines() + except OSError: + # Purge only ever rewrites, so an unreadable queue means doing + # nothing. The entry stays and is replayed once — harmless, + # because T3 acked it and therefore dedupes the replay. + logger.warning( + "Could not read the T3 home delivery queue at %s to purge " + "%s; it will be replayed and deduped", + self.path, + target, + exc_info=True, + ) + return False + remaining = [ + entry for entry in entries if entry.get("deliveryId") != target + ] + if len(remaining) == len(entries): + return False + try: + self._write_all(remaining) + except OSError: + logger.error( + "Could not purge acknowledged T3 home delivery %s from %s", + target, + self.path, + exc_info=True, + ) + return False + return True + + def __len__(self) -> int: + return len(self.entries()) + + +# ── classification ──────────────────────────────────────────────────────── + +# Hermes' lifecycle broadcasts, matched by their literal shapes at 62e07223. +# These are inline f-strings upstream, not exported constants, so the same +# drift risk documented for the `/sethome` notice applies: a wording change +# upstream downgrades these to `kind: "message"`, which costs a badge and a +# quiet-delivery classification, never the delivery itself. +_LIFECYCLE_EXACT = frozenset( + { + # gateway/run.py:17277 + "♻️ Gateway online — Hermes is back and ready.", + # gateway/run.py:17236 + "♻ Gateway restarted successfully. Your session continues.", + } +) +_LIFECYCLE_PREFIXES = ( + # gateway/run.py:6599 — f"⚠️ Gateway {action} — {hint}" + "⚠️ Gateway restarting — ", + "⚠️ Gateway shutting down — ", +) + +# cron/scheduler.py:1513 builds this header when `cron.wrap_response` is on +# (the default). +_CRON_HEADER = "Cronjob Response: " + +# gateway/run.py:8854 — the handoff destination's synthetic source identity. +_HANDOFF_USER_ID = "system:handoff" + + +def classify_delivery( + content: str, + metadata: dict[str, Any] | None = None, + *, + session_user_id: str = "", +) -> tuple[str, str, bool]: + """Best-effort `(kind, label, provenance_certain)` for a proactive send. + + Hermes' `adapter.send()` contract carries no structured "this is a cron + delivery" marker on every path, so this reads the signals that do exist: + + * **`metadata["job_id"]`** — the cron scheduler stamps it into the routed + metadata for every live-gateway delivery (`cron/scheduler.py:1782`), and + `DeliveryRouter._deliver_to_platform` passes that dict through to + `adapter.send` unchanged (`gateway/delivery.py:606`). This is the + strongest signal available and the only structured one. + * **The cron wrap header** — `cron.wrap_response` (default on) prefixes the + brief with `Cronjob Response: ` (`cron/scheduler.py:1513`), which + also supplies a human job name for the badge. + * **Lifecycle literals** — the gateway's online/restart/shutdown notices. + * **The bound session identity** — `/handoff` dispatches its synthetic turn + under `user_id="system:handoff"` (`gateway/run.py:8854`), bound onto the + session context by `_set_session_env` (`gateway/run.py:17372`). + + The third element reports whether provenance was *positively* established. + The adapter uses it as the tiebreaker when a live turn exists in the home + thread: only a positively-identified proactive send may bypass that turn, + so an unclassifiable send can never steal a user's answer. + + Worst case for the first two elements is a wrong badge, never a lost + delivery — an unrecognised send is `("message", "Hermes", False)`. + """ + text = str(content or "") + meta = metadata or {} + + job_id = str(meta.get("job_id") or "").strip() + job_name = _cron_job_name(text) + if job_id or job_name: + label = f"Cron: {job_name or job_id}" + return "cron", label, True + + stripped = text.strip() + if stripped in _LIFECYCLE_EXACT or stripped.startswith(_LIFECYCLE_PREFIXES): + return "lifecycle", "Gateway", True + + if str(session_user_id or "").strip() == _HANDOFF_USER_ID: + return "handoff", "Handoff", True + + return "message", "Hermes", False + + +def _cron_job_name(text: str) -> str: + """Recover the job name from the cron wrap header, if present.""" + first_line = text.lstrip().split("\n", 1)[0] + if not first_line.startswith(_CRON_HEADER): + return "" + return first_line[len(_CRON_HEADER) :].strip() + + +def build_delivery( + *, + thread_id: str, + text: str, + kind: str = "message", + label: str = "Hermes", + created_at: str | None = None, +) -> dict[str, Any]: + """Mint one `home.deliver` frame, id and timestamp included. + + `createdAt` is stamped here — when Hermes produced the content — not when + the frame reaches T3, so a delivery flushed after a two-hour outage still + reports the moment it was written. + """ + normalized_kind = kind if kind in HOME_DELIVERY_KINDS else "other" + return home_deliver( + delivery_id_value=delivery_id(), + thread_id=thread_id, + kind=normalized_kind, + label=label, + text=text, + created_at=created_at or iso_now(), + ) + + +def build_media_delivery( + *, + thread_id: str, + path: str, + kind: str = "message", + label: str = "Hermes", + turn_id: str | None = None, + caption: str | None = None, + name: str | None = None, + created_at: str | None = None, +) -> dict[str, Any]: + """Mint one `media.deliver` frame from a local file, id and timestamp included. + + Reads the file eagerly so the queued copy is self-contained: Hermes' media + files live in temp/cache directories that may be gone by the time a queued + delivery flushes after an outage, and a queue entry pointing at a dead path + would be unsendable forever. `createdAt` is stamped here for the same + reason as `build_delivery`. + + Raises `OSError` when the file cannot be read and `ValueError` when it is + empty or over the 25MB wire ceiling — the caller decides whether that is a + logged skip (adapter) or a reported error (standalone sender). + """ + file_path = Path(path) + data = file_path.read_bytes() + display_name = str(name or "").strip() or file_path.name + mime, _encoding = mimetypes.guess_type(display_name) + return media_deliver( + delivery_id_value=delivery_id(), + thread_id=thread_id, + kind=kind if kind in HOME_DELIVERY_KINDS else "other", + label=label, + name=display_name, + mime_type=mime or "application/octet-stream", + data=data, + turn_id=turn_id, + caption=caption, + created_at=created_at or iso_now(), + ) + + +# ── standalone (out-of-process) sender ──────────────────────────────────── + + +async def standalone_send( + pconfig: Any, + chat_id: str, + message: str, + *, + thread_id: str | None = None, + media_files: list[str] | None = None, + force_document: bool = False, +) -> dict[str, Any]: + """Deliver to the home thread with no live gateway adapter in this process. + + Registered as `standalone_sender_fn` and called by + `tools/send_message_tool._send_via_adapter` when the in-process adapter + weakref is empty — the `hermes cron` process running separately from + `hermes gateway`. Without it, `deliver=t3` cron jobs fail with "No live + adapter for platform". + + The socket announces `role: "delivery"`. That is load-bearing: T3's broker + registers a `gateway` connection under generation fencing and displaces the + previous one, so a naive cron dial-in would kick the live gateway socket + off its own instance mid-turn. A `delivery` connection is authenticated + identically, never becomes the primary, and is expected to close promptly. + + A connection failure is **not** a cron failure. The delivery is already on + disk before the socket is opened, so the job reports success-with-queued + and the live gateway flushes it on its next `connection.accepted`. + + `media_files` rides the v4 wire as one `media.deliver` frame per file + (upstream passes `(path, is_voice)` tuples; bare path strings are accepted + too). A file that cannot be read or exceeds the 25MB frame ceiling is + reported in `detail` and skipped — never queued, because a queued frame + that T3 will always reject would sit in the outbox forever. `force_document` + remains signature parity only: T3 derives rendering from `mimeType`, so + there is no document/photo distinction to force. + + Every success result carries additive accounting keys alongside the + unchanged `message_id`: `delivery_ids` (every frame this call minted, text + and media), `media_count`, `acked_count`, and — when media was sent — a + `note` naming the delivered file count. Upstream consumers read this dict by + specific key and otherwise `json.dumps` it wholesale, so extra keys are + inert there while giving an agent reading the tool output direct evidence + against core's unconditional "MEDIA attachments were omitted" warning. + """ + del force_document + + extra = getattr(pconfig, "extra", None) or {} + + def _setting(key: str, env: str) -> str: + return str(extra.get(key) or os.environ.get(env, "") or "").strip() + + url = _setting("url", "HERMES_T3_GATEWAY_URL") + instance_id = _setting("instance_id", "HERMES_T3_GATEWAY_INSTANCE_ID") + credential = _setting("credential", "HERMES_T3_GATEWAY_CREDENTIAL") + if not (url and instance_id and credential): + return { + "error": ( + "T3 standalone send: this Hermes is not enrolled. Run " + "`hermes t3 connect --url --token ` first." + ) + } + + target = str(chat_id or "").strip() or str(thread_id or "").strip() + if not target: + target = home_thread_id() + if not target: + return { + "error": ( + "T3 standalone send: no home thread is designated. Start " + "`hermes gateway` once so T3 can publish one." + ) + } + + kind, label, _certain = classify_delivery(message, None) + frames: list[dict[str, Any]] = [] + media_ids: list[str] = [] + skipped: list[str] = [] + # The text frame is skipped only when media makes the send non-empty + # anyway; a bare text send keeps today's behaviour (empty text normalizes + # inside `home_deliver`). + if str(message or "").strip() or not media_files: + frames.append( + build_delivery(thread_id=target, text=message, kind=kind, label=label) + ) + for entry in media_files or []: + media_path = entry[0] if isinstance(entry, (tuple, list)) else entry + try: + media_frame = build_media_delivery( + thread_id=target, + path=str(media_path), + kind=kind, + label=label, + ) + except Exception as exc: # noqa: BLE001 - one bad file must not sink the send + # Never queued: a frame T3 will always reject (unreadable then, + # oversized forever) would otherwise sit in the outbox for good. + logger.warning( + "T3 standalone send skipping media file %s: %s", media_path, exc + ) + skipped.append(f"{media_path}: {exc}") + else: + frames.append(media_frame) + media_ids.append(str(media_frame["deliveryId"])) + if not frames: + return { + "error": "T3 standalone send: no deliverable content " + + "; ".join(skipped) + } + delivery = str(frames[0]["deliveryId"]) + all_ids = [str(frame["deliveryId"]) for frame in frames] + + def _observed( + result: dict[str, Any], acked_ids: set[str], *, queued_only: bool + ) -> dict[str, Any]: + """Attach the additive delivery-accounting keys to a success result. + + `message_id` is deliberately untouched — upstream reads that key by + name. Everything here is new, and exists as **counter-evidence**: core + appends a hard-coded "MEDIA attachments were omitted for t3" warning to + any successful generic-path send (`tools/send_message_tool.py:1108` @ + v0.19.0) without ever asking whether the sender delivered them. The + `coreshim` module strips that warning when it can, but if it has failed + open the numbers below still sit in the same JSON blob the agent reads, + so "2 media file(s) delivered and acknowledged" contradicts the stale + warning directly rather than leaving the agent to guess. + """ + acked_media = [entry for entry in media_ids if entry in acked_ids] + result["delivery_ids"] = list(all_ids) + result["media_count"] = len(media_ids) + result["acked_count"] = len(acked_ids) + if media_ids: + verb = "queued for delivery" if queued_only else "delivered" + count = len(media_ids) if queued_only else len(acked_media) + note = f"{count} media file(s) {verb}" + if not queued_only: + note += " and acknowledged" + existing = str(result.get("note") or "").strip() + result["note"] = f"{existing}; {note}" if existing else note + return result + + queue = HomeDeliveryQueue() + # Never short-circuit this batch. A failed first write must not prevent the + # later media frames from reaching the durable outbox, and success below is + # computed per delivery rather than from one misleading aggregate boolean. + append_results = [] + for frame in frames: + append_results.append(await asyncio.to_thread(queue.append, frame)) + queued_ids = { + str(frame["deliveryId"]) + for frame, appended in zip(frames, append_results) + if appended + } + expected_ids = set(all_ids) + + try: + acked = await _deliver_over_short_lived_socket( + url=url, + instance_id=instance_id, + credential=credential, + frames=frames, + ) + except Exception as exc: # noqa: BLE001 - cron delivery must not raise + logger.debug("T3 standalone send raised", exc_info=True) + if queued_ids == expected_ids: + return _observed( + { + "success": True, + "message_id": delivery, + "queued": True, + "detail": f"T3 unreachable ({exc}); queued for the next connect", + }, + set(), + queued_only=True, + ) + missing = len(expected_ids - queued_ids) + return { + "error": ( + f"T3 standalone send failed: {exc}; {missing} delivery frame(s) " + "were not durably queued" + ) + } + + for acked_id in acked: + await asyncio.to_thread(queue.purge, acked_id) + result_detail = "; ".join(f"skipped {entry}" for entry in skipped) + if len(acked) == len(frames): + result: dict[str, Any] = {"success": True, "message_id": delivery} + if result_detail: + result["detail"] = result_detail + return _observed(result, acked, queued_only=False) + unacknowledged = expected_ids - acked + if unacknowledged.issubset(queued_ids): + return _observed( + { + "success": True, + "message_id": delivery, + "queued": True, + "detail": ( + "T3 did not acknowledge every delivery; queued for retry" + + (f" ({result_detail})" if result_detail else "") + ), + }, + acked, + queued_only=len(acked) == 0, + ) + return {"error": "T3 standalone send: the delivery was neither acked nor queued"} + + +async def _deliver_over_short_lived_socket( + *, + url: str, + instance_id: str, + credential: str, + frames: list[dict[str, Any]], + timeout: float = 20.0, +) -> set[str]: + """Open, authenticate as `delivery`, send, await the acks, close. + + Returns the set of `deliveryId`s T3 acknowledged (`home.deliver.ack` and + `media.deliver.ack` are equivalent here). A timeout returns whatever was + acked so far — the caller purges exactly those and leaves the rest queued. + """ + import asyncio + + from .connection import _open_socket, authenticate_socket + + hermes_version = _hermes_version() + expected = {str(frame["deliveryId"]) for frame in frames} + acked: set[str] = set() + socket = await _open_socket(url) + try: + await authenticate_socket( + socket, + authentication={ + "type": "instance-credential", + "instanceId": instance_id, + "credential": credential, + }, + hermes_version=hermes_version, + role="delivery", + ) + for frame in frames: + await socket.send( + json.dumps(frame, separators=(",", ":"), ensure_ascii=False) + ) + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while acked != expected: + remaining = deadline - loop.time() + if remaining <= 0: + return acked + try: + raw = await asyncio.wait_for(socket.recv(), timeout=remaining) + except TimeoutError: + return acked + try: + reply = json.loads(raw) + except ValueError: + continue + if not isinstance(reply, dict): + continue + if reply.get("type") == "ping" and reply.get("requestId"): + await socket.send( + json.dumps( + { + "type": "pong", + "protocolVersion": PROTOCOL_VERSION, + "requestId": reply["requestId"], + "sentAt": reply.get("sentAt") or iso_now(), + }, + separators=(",", ":"), + ) + ) + continue + if reply.get("type") in {"home.deliver.ack", "media.deliver.ack"}: + delivery_id_value = str(reply.get("deliveryId") or "") + if delivery_id_value in expected: + acked.add(delivery_id_value) + # Anything else (a liveness ping, an unrelated frame) is ignored: + # this socket exists only to hand over these deliveries. + return acked + finally: + try: + await socket.close() + except Exception: # noqa: BLE001 - a failed close must not fail the send + logger.debug("T3 standalone socket close failed", exc_info=True) + + +def _hermes_version() -> str: + try: + from hermes_cli import __version__ + + return str(__version__) + except Exception: # noqa: BLE001 - version discovery must not block delivery + return "unknown" diff --git a/integrations/hermes-t3-gateway/install.sh b/integrations/hermes-t3-gateway/install.sh new file mode 100755 index 000000000000..991a2d322312 --- /dev/null +++ b/integrations/hermes-t3-gateway/install.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# Install the T3 Code gateway plugin into the active Hermes profile. +# +# Symlinks this directory into "$HERMES_HOME/plugins/hermes-t3-gateway" (the +# documented user-plugin path, hermes_cli/plugins.py:10) and enables it. +# Safe to re-run: an existing correct symlink is left alone, and enabling an +# already-enabled plugin is a no-op in Hermes. + +set -eu + +PLUGIN_NAME="hermes-t3-gateway" +SOURCE_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) +HERMES_HOME_DIR=${HERMES_HOME:-"$HOME/.hermes"} +PLUGINS_DIR="$HERMES_HOME_DIR/plugins" +TARGET="$PLUGINS_DIR/$PLUGIN_NAME" + +if ! command -v hermes >/dev/null 2>&1; then + cat >&2 < $SOURCE_DIR" + else + echo "• Relinking $TARGET (was -> $CURRENT)" + rm -f "$TARGET" + ln -s "$SOURCE_DIR" "$TARGET" + fi +elif [ -e "$TARGET" ]; then + cat >&2 < $SOURCE_DIR" +fi + +echo "• Enabling $PLUGIN_NAME" +# --no-allow-tool-override: this plugin registers a platform adapter and two +# observer hooks. It never replaces a built-in tool, and passing the flag keeps +# the run non-interactive instead of stopping on the consent prompt. +hermes plugins enable "$PLUGIN_NAME" --no-allow-tool-override + +cat < --token + + 2. Restart the gateway so it picks up the new connection: + + hermes gateway restart + + Verify anytime with: hermes t3 status +EOF diff --git a/integrations/hermes-t3-gateway/plugin.yaml b/integrations/hermes-t3-gateway/plugin.yaml new file mode 100644 index 000000000000..42ea5a34cc5f --- /dev/null +++ b/integrations/hermes-t3-gateway/plugin.yaml @@ -0,0 +1,24 @@ +name: hermes-t3-gateway +label: T3 Code +kind: platform +version: 0.5.0 +description: > + Optional outbound companion for durable T3 Code Home, cron, handoff, and + media delivery. Interactive conversations continue through hermes-acp. +author: T3 Tools +provides_hooks: + - pre_tool_call + - post_tool_call +optional_env: + - name: HERMES_T3_GATEWAY_URL + description: "T3 Code server URL used by the outbound gateway connection" + prompt: "T3 Code URL" + password: false + - name: HERMES_T3_GATEWAY_INSTANCE_ID + description: "Opaque T3 Code Hermes instance identifier issued at enrollment" + prompt: "T3 Code Hermes instance ID" + password: false + - name: HERMES_T3_GATEWAY_CREDENTIAL + description: "Long-lived per-instance credential issued at enrollment" + prompt: "T3 Code Hermes credential" + password: true diff --git a/integrations/hermes-t3-gateway/protocol.py b/integrations/hermes-t3-gateway/protocol.py new file mode 100644 index 000000000000..d84695692d9c --- /dev/null +++ b/integrations/hermes-t3-gateway/protocol.py @@ -0,0 +1,607 @@ +"""Pure-Python helpers for the T3 Code ↔ Hermes gateway wire contract.""" + +from __future__ import annotations + +import base64 +import binascii +import json +import uuid +from datetime import datetime, timezone +from typing import Any + +PROTOCOL_VERSION = 4 +PLUGIN_VERSION = "0.5.0" +WEBSOCKET_PATH = "/api/hermes-gateway/ws" + +# What a connecting socket intends to be. `gateway` is the instance's one live +# plugin connection; `delivery` is a short-lived socket (an out-of-process cron +# run) that hands over a `home.deliver` and leaves. T3 never registers a +# `delivery` socket as the primary connection, so it cannot displace a healthy +# gateway connection under the broker's generation fencing. +CONNECTION_ROLES = frozenset({"gateway", "delivery"}) + +CAPABILITIES = { + "protocolVersion": PROTOCOL_VERSION, + "streaming": True, + "activity": True, + "approvals": True, + "userInput": True, + # Part of the v4 contract itself, not a negotiated option: the T3 schema + # pins `attachments` to the literal `true`, so a plugin that cannot handle + # them is a v3 plugin and is rejected at the version gate. + "attachments": True, +} + +SERVER_COMMANDS = frozenset( + { + "session.ensure", + "turn.start", + "turn.steer", + "turn.interrupt", + "approval.respond", + "user-input.respond", + "session.stop", + "ping", + "describe.request", + "skill.body.request", + "home.deliver.ack", + "media.deliver.ack", + "handoff.created", + "protocol.error", + } +) + +# Kinds a `home.deliver` may carry. Mirrors the T3 contract's +# `HermesGatewayHomeDeliveryKind`; anything not classified lands on "message". +HOME_DELIVERY_KINDS = frozenset({"cron", "message", "lifecycle", "handoff", "other"}) + +# Wire bounds from the T3 contract (`HermesGatewayHomeDeliver`): `label` is a +# trimmed non-empty string of at most 200 chars, `text` is 1..120000 chars. +# Enforced here so a pathological Hermes payload is clamped rather than +# rejected by the server after the plugin already dropped its local copy. +MAX_HOME_DELIVERY_LABEL_CHARS = 200 +MAX_HOME_DELIVERY_TEXT_CHARS = 120_000 + +# Ceiling on a single skill body crossing the wire. Skill markdown is +# human-authored documentation, not data: 512 KiB is far past any real +# SKILL.md while still bounding a pathological file from stalling the socket. +MAX_SKILL_BODY_CHARS = 512_000 + +# Raw-byte ceiling for a single `media.deliver` frame, and for each attachment +# riding an inbound turn frame. Mirrors `HERMES_MEDIA_MAX_BYTES` in the T3 +# contract: 25MB of raw bytes is ~34MB of base64, and the schema bound there is +# on the encoded string so an oversized frame fails at decode. Deliberately no +# chunking — a file that does not fit does not send, with a clear error. +MAX_MEDIA_BYTES = 25 * 1024 * 1024 + +# Wire bounds from the T3 contract (`HermesGatewayMediaDeliver`): `name` is a +# trimmed non-empty string of at most 255 chars, `mimeType` at most 100, and +# `caption` at most 2000. Enforced here for the same reason as the +# `home.deliver` bounds: a queued frame must already be wire-valid on disk. +MAX_MEDIA_NAME_CHARS = 255 +MAX_MEDIA_MIME_CHARS = 100 +MAX_MEDIA_CAPTION_CHARS = 2_000 + + +def request_id() -> str: + return str(uuid.uuid4()) + + +def item_id() -> str: + return str(uuid.uuid4()) + + +def delivery_id() -> str: + """Mint the idempotency key for one home delivery. + + Stable across retries by construction: the id is minted once, when the + delivery is created, and the queued copy carries it verbatim through every + flush. T3 dedupes on it, which is what makes double-flushing safe. + """ + return str(uuid.uuid4()) + + +def iso_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def frame(frame_type: str, **payload: Any) -> dict[str, Any]: + return { + "type": frame_type, + "protocolVersion": PROTOCOL_VERSION, + **payload, + } + + +def configured_model() -> str | None: + """Return Hermes' configured default model, or None when unavailable. + + Reads `hermes_cli.config.load_config_readonly()`, the documented read-only + accessor. That function returns the *shared, process-wide cached* config + dict, so nothing here mutates it or hands a nested structure to a caller + that might: only a trimmed string copy of `model.default` leaves this + function. + + Every failure mode — no Hermes on the path, an older Hermes without the + accessor, a config with no model section — degrades to None so the field is + omitted from the handshake rather than sent as null or empty. + """ + try: + from hermes_cli.config import load_config_readonly + + model = load_config_readonly().get("model", {}).get("default") + except Exception: # noqa: BLE001 - model reporting must never break the handshake + return None + if not isinstance(model, str): + return None + trimmed = model.strip() + return trimmed or None + + +def configured_reasoning_effort() -> str | None: + """Return Hermes' configured reasoning effort, or None when unavailable. + + Same discipline as `configured_model()`: `load_config_readonly()` hands + back the *shared, process-wide cached* config dict, so this reads + `agent.reasoning_effort` and copies out a trimmed string — nothing here + mutates the cache or lets a nested structure escape to a caller that + might. + + Every failure mode — no Hermes on the path, an older Hermes without the + accessor, a config with no `agent` section, a non-string value — degrades + to None so the field is omitted from `describe.response` rather than sent + as null or empty. + """ + try: + from hermes_cli.config import load_config_readonly + + effort = load_config_readonly().get("agent", {}).get("reasoning_effort") + except Exception: # noqa: BLE001 - describe must never break the connection + return None + if not isinstance(effort, str): + return None + trimmed = effort.strip() + return trimmed or None + + +def installed_skills() -> list[dict[str, Any]]: + """Return metadata for the skills Hermes currently exposes. + + Reads the documented `tools.skills_tool.skills_list()` tool surface — the + same JSON the agent itself sees — rather than the private `_find_all_skills` + scanner behind it. `skills_list()` already applies Hermes' platform, + environment, and disabled-skill filters, so every entry it returns is a + skill this Hermes would actually load; `enabled` is therefore always True + here and disabled skills are simply absent (see COMPATIBILITY.md). + + Only trimmed string copies of `name`, `description`, and `category` leave + this function: the payload is rebuilt entry by entry so nothing Hermes owns + — cached or otherwise — is handed to a caller that might mutate it. + + Every failure mode — no Hermes on the path, an older Hermes without the + tool, a non-JSON or unsuccessful response, a malformed entry — degrades to + an empty list. Describing an agent must never break the connection. + """ + try: + from tools.skills_tool import skills_list + + payload = json.loads(skills_list()) + except Exception: # noqa: BLE001 - describe must never break the connection + return [] + if not isinstance(payload, dict) or not payload.get("success"): + return [] + entries = payload.get("skills") + if not isinstance(entries, list): + return [] + skills: list[dict[str, Any]] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + continue + skill: dict[str, Any] = {"name": name.strip(), "enabled": True} + description = entry.get("description") + if isinstance(description, str) and description.strip(): + skill["description"] = description.strip() + # Hermes' category is the closest thing it publishes to an install + # source. There is no on-disk path in this surface; see COMPATIBILITY.md. + source = entry.get("category") + if isinstance(source, str) and source.strip(): + skill["source"] = source.strip() + skills.append(skill) + return skills + + +def skill_body(name: str) -> str | None: + """Return a skill's SKILL.md markdown, or None when unavailable. + + Reads the documented `tools.skills_tool.skill_view()` tool surface with + `preprocess=False`: T3 renders the skill for a human to read, so the + literal authored markdown is wanted, not Hermes' template/inline-shell + rendering of it. + + Unlike the omit-on-failure optional fields, `markdown` is explicitly null + on failure: the request named a specific skill, so the caller needs to + distinguish "asked and there is nothing to show" from a dropped reply. + A missing skill, an unreadable file, an ambiguous name, an older Hermes, + or no Hermes at all all land on None. + """ + if not isinstance(name, str) or not name.strip(): + return None + try: + from tools.skills_tool import skill_view + + payload = json.loads(skill_view(name.strip(), preprocess=False)) + except Exception: # noqa: BLE001 - describe must never break the connection + return None + if not isinstance(payload, dict) or not payload.get("success"): + return None + content = payload.get("content") + if not isinstance(content, str) or not content.strip(): + return None + return content[:MAX_SKILL_BODY_CHARS] + + +def describe_response( + *, + request_id_value: str, + hermes_version: str, + model: str | None = None, + reasoning_effort: str | None = None, + skills: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build the reply to a `describe.request`. + + Mirrors `connection_hello`: the version/capability block is always present + because the plugin owns it outright, while every Hermes-sourced optional + field is *omitted* when it cannot be read rather than sent as null or + empty, so a server that has the field still falls back to its own generic + label. `skills` is always present — an empty list is the truthful answer + when Hermes reports none, and it keeps the client's rendering shape stable. + """ + resolved_model = model if model is not None else configured_model() + resolved_effort = ( + reasoning_effort + if reasoning_effort is not None + else configured_reasoning_effort() + ) + resolved_skills = installed_skills() if skills is None else skills + payload: dict[str, Any] = { + "type": "describe.response", + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id_value, + "pluginVersion": PLUGIN_VERSION, + "hermesVersion": hermes_version, + "capabilities": dict(CAPABILITIES), + "skills": [dict(skill) for skill in resolved_skills], + "describedAt": iso_now(), + } + if resolved_model: + payload["model"] = resolved_model + if resolved_effort: + payload["reasoningEffort"] = resolved_effort + return payload + + +def skill_body_response( + *, + request_id_value: str, + skill_name: str, + markdown: str | None, +) -> dict[str, Any]: + """Build the reply to a `skill.body.request`. + + `markdown` is explicitly nullable here — the request named a skill, so the + caller must be able to tell "Hermes has no body to show for this" apart + from a reply that never arrived. Empty strings normalize to null. + """ + return { + "type": "skill.body.response", + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id_value, + "skillName": skill_name, + "markdown": markdown if markdown else None, + } + + +def connection_hello( + *, + hermes_version: str, + authentication: dict[str, str], + hello_request_id: str | None = None, + model: str | None = None, + role: str = "gateway", +) -> dict[str, Any]: + """Build the handshake frame. + + `role` is sent explicitly even though the T3 contract decodes a missing + value as `"gateway"`: an out-of-process cron sender MUST announce + `"delivery"` or the broker registers it as the instance's primary + connection and generation-fences the live gateway socket off. + """ + resolved_model = model if model is not None else configured_model() + normalized_role = str(role or "gateway").strip().lower() + if normalized_role not in CONNECTION_ROLES: + normalized_role = "gateway" + hello: dict[str, Any] = { + "type": "connection.hello", + "requestId": hello_request_id or request_id(), + "protocolVersion": PROTOCOL_VERSION, + "pluginVersion": PLUGIN_VERSION, + "hermesVersion": hermes_version, + "capabilities": dict(CAPABILITIES), + "authentication": authentication, + "role": normalized_role, + } + # Optional on the wire: omit entirely rather than send null/empty so a + # server that has the field still falls back to its generic label. + if resolved_model: + hello["model"] = resolved_model + return hello + + +def _normalize_delivery_provenance(kind: str, label: str) -> tuple[str, str]: + normalized_kind = str(kind or "").strip().lower() + if normalized_kind not in HOME_DELIVERY_KINDS: + normalized_kind = "other" + normalized_label = str(label or "").strip()[:MAX_HOME_DELIVERY_LABEL_CHARS].strip() + return normalized_kind, normalized_label or "Hermes" + + +def home_deliver( + *, + delivery_id_value: str, + thread_id: str, + kind: str, + label: str, + text: str, + created_at: str | None = None, +) -> dict[str, Any]: + """Build a `home.deliver` frame with every wire bound already applied. + + Like `protocol_error`, this wraps the generic `frame()` helper rather than + assembling the envelope itself — but unlike the plain outbound frames the + adapter builds inline, a delivery has server-side validation the plugin + must not trip: `label` is trimmed non-empty ≤200 chars and `text` is + 1..120000 chars in the T3 contract. Normalizing here rather than at the + call sites means a queued delivery is already wire-valid on disk, so a + flush after a plugin upgrade cannot resurrect a payload the server will + reject — the plugin would purge it only on an ack that never comes. + + An unknown `kind` degrades to `"other"` and an empty label to `"Hermes"`: + a misclassified badge is the documented worst case, a dropped delivery is + not. + """ + normalized_kind, normalized_label = _normalize_delivery_provenance(kind, label) + normalized_text = str(text or "")[:MAX_HOME_DELIVERY_TEXT_CHARS] + if not normalized_text: + normalized_text = " " + return frame( + "home.deliver", + deliveryId=delivery_id_value, + threadId=str(thread_id), + kind=normalized_kind, + label=normalized_label, + text=normalized_text, + createdAt=created_at or iso_now(), + ) + + +def media_deliver( + *, + delivery_id_value: str, + thread_id: str, + kind: str, + label: str, + name: str, + mime_type: str, + data: bytes, + turn_id: str | None = None, + caption: str | None = None, + created_at: str | None = None, +) -> dict[str, Any]: + """Build a `media.deliver` frame with every wire bound already applied. + + Mirrors `home_deliver`: normalizing here rather than at the call sites + means a queued delivery is already wire-valid on disk, so a flush after a + plugin upgrade cannot resurrect a payload the server will reject — the + plugin would purge it only on an ack that never comes. + + The clamp-vs-reject split follows what each field can survive. Provenance + (`kind`, `label`) and presentation (`caption`) degrade exactly like + `home_deliver`'s — a wrong badge or a shortened caption is the documented + worst case. The payload itself cannot degrade: truncated bytes are a + corrupt file, so an empty payload, a payload over `MAX_MEDIA_BYTES`, or a + missing `deliveryId` raises `ValueError` instead — better a loud send-time + failure than a poisoned queue entry T3 rejects forever. + + `data` is raw bytes; base64 encoding happens here so no call site can get + the wire encoding wrong, and `sizeBytes` is derived from the same bytes so + the two can never disagree. + """ + if not str(delivery_id_value or "").strip(): + raise ValueError("media.deliver requires a deliveryId") + if not isinstance(data, (bytes, bytearray)): + raise TypeError("media.deliver data must be bytes") + if len(data) == 0: + raise ValueError("media.deliver requires a non-empty payload") + if len(data) > MAX_MEDIA_BYTES: + raise ValueError( + f"media.deliver payload is {len(data)} bytes; " + f"the wire ceiling is {MAX_MEDIA_BYTES} bytes (25MB)" + ) + normalized_kind, normalized_label = _normalize_delivery_provenance(kind, label) + normalized_name = str(name or "").strip()[:MAX_MEDIA_NAME_CHARS].strip() + if not normalized_name: + normalized_name = "attachment.bin" + normalized_mime = str(mime_type or "").strip()[:MAX_MEDIA_MIME_CHARS].strip() + if not normalized_mime: + normalized_mime = "application/octet-stream" + payload: dict[str, Any] = { + "deliveryId": delivery_id_value, + "threadId": str(thread_id), + "kind": normalized_kind, + "label": normalized_label, + "name": normalized_name, + "mimeType": normalized_mime, + "sizeBytes": len(data), + "data": base64.b64encode(bytes(data)).decode("ascii"), + "createdAt": created_at or iso_now(), + } + # Optional on the wire: omit rather than send null/empty, matching the + # T3 schema's `Schema.optional` fields. + if turn_id: + payload["turnId"] = str(turn_id) + normalized_caption = str(caption or "")[:MAX_MEDIA_CAPTION_CHARS] + if normalized_caption: + payload["caption"] = normalized_caption + return frame("media.deliver", **payload) + + +def turn_attachments(message: dict[str, Any]) -> list[dict[str, Any]]: + """Decode the optional `attachments` on a `turn.start` / `turn.steer`. + + Returns `[{"name", "mimeType", "data": bytes}, ...]` with the base64 + already decoded and each payload bounded by `MAX_MEDIA_BYTES`. The wire + `sizeBytes` is advisory — the decoded length is the truth, so it is what + callers get. + + A malformed entry raises `ValueError` rather than being skipped: T3 + validates these frames against its own schema before sending, so a bad + entry here means version drift, and silently dropping a file the user + attached is worse than a correlated `protocol.error` they can see. + """ + raw = message.get("attachments") + if raw is None: + return [] + if not isinstance(raw, list): + raise ValueError("turn attachments must be a list") + attachments: list[dict[str, Any]] = [] + for index, entry in enumerate(raw): + if not isinstance(entry, dict): + raise ValueError(f"turn attachment {index} must be an object") + name = str(entry.get("name") or "").strip() + if not name: + raise ValueError(f"turn attachment {index} is missing a name") + encoded = entry.get("data") + if not isinstance(encoded, str) or not encoded: + raise ValueError(f"turn attachment {name!r} carries no data") + try: + data = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError( + f"turn attachment {name!r} is not valid base64" + ) from exc + if len(data) == 0: + raise ValueError(f"turn attachment {name!r} decoded to zero bytes") + if len(data) > MAX_MEDIA_BYTES: + raise ValueError( + f"turn attachment {name!r} is {len(data)} bytes; " + f"the wire ceiling is {MAX_MEDIA_BYTES} bytes (25MB)" + ) + mime = str(entry.get("mimeType") or "").strip() + attachments.append( + { + "name": name, + "mimeType": mime or "application/octet-stream", + "data": data, + } + ) + return attachments + + +def protocol_error( + code: str, + message: str, + *, + recoverable: bool, + related_request_id: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "code": code, + "message": message, + "recoverable": recoverable, + } + if related_request_id: + payload["requestId"] = related_request_id + return frame("protocol.error", **payload) + + +def validate_server_frame(message: Any) -> dict[str, Any]: + if not isinstance(message, dict): + raise TypeError("gateway frame must be a JSON object") + frame_type = message.get("type") + if frame_type not in SERVER_COMMANDS: + raise ValueError(f"unsupported T3 gateway frame: {frame_type!r}") + if message.get("protocolVersion") != PROTOCOL_VERSION: + raise ValueError( + f"unsupported protocol version: {message.get('protocolVersion')!r}" + ) + return message + + +def canonical_tool_item_type(tool_name: str) -> str: + normalized = (tool_name or "").strip().lower() + if normalized in {"terminal", "execute_code", "shell", "bash"}: + return "command_execution" + if normalized in { + "apply_patch", + "write_file", + "edit_file", + "delete_file", + "move_file", + }: + return "file_change" + if normalized.startswith(("mcp", "mcp__")): + return "mcp_tool_call" + if normalized in {"delegate_task", "spawn_agent", "send_message"}: + return "collab_agent_tool_call" + if "search" in normalized or normalized in {"web_fetch", "fetch_url"}: + return "web_search" + if normalized in {"view_image", "open_image"}: + return "image_view" + return "dynamic_tool_call" + + +def canonical_tool_data(tool_name: str, args: Any) -> dict[str, Any] | None: + """Project known-safe, canonical fields; never forward arbitrary tool args.""" + if not isinstance(args, dict): + return None + item_type = canonical_tool_item_type(tool_name) + if item_type == "command_execution": + command = args.get("command") + cwd = args.get("cwd") or args.get("workdir") + projected = {} + if isinstance(command, str) and command.strip(): + projected["command"] = command[:4_000] + if isinstance(cwd, str) and cwd.strip(): + projected["cwd"] = cwd[:1_000] + return projected or None + if item_type == "file_change": + path = args.get("path") or args.get("file_path") or args.get("filename") + return ( + {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None + ) + if item_type == "web_search": + query = args.get("query") or args.get("q") or args.get("url") + return ( + {"query": query[:2_000]} + if isinstance(query, str) and query.strip() + else None + ) + if item_type == "image_view": + path = args.get("path") or args.get("image_path") + return ( + {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None + ) + if item_type == "mcp_tool_call": + server = args.get("server") + operation = args.get("tool") or args.get("operation") + projected = {} + if isinstance(server, str) and server.strip(): + projected["server"] = server[:200] + if isinstance(operation, str) and operation.strip(): + projected["operation"] = operation[:200] + return projected or None + return None diff --git a/integrations/hermes-t3-gateway/pyproject.toml b/integrations/hermes-t3-gateway/pyproject.toml new file mode 100644 index 000000000000..c384077cb322 --- /dev/null +++ b/integrations/hermes-t3-gateway/pyproject.toml @@ -0,0 +1,15 @@ +# Tool configuration only — the plugin is installed by copying the directory +# (see install.sh), not packaged, so there is deliberately no [project] table. + +[tool.ruff] +# The plugin's floor: the code uses `X | None` unions and dict/list generics +# that require Python 3.10+. +target-version = "py310" + +[tool.ruff.lint] +# F catches real bugs — F811 (redefinition) is the rule that would have +# flagged the duplicated definitions this config was added alongside. +select = ["E", "W", "F"] +# The plugin favors long explanatory docstrings and comments; do not enforce a +# line length rather than reflowing existing prose. +ignore = ["E501"] diff --git a/integrations/hermes-t3-gateway/tests/test_adapter.py b/integrations/hermes-t3-gateway/tests/test_adapter.py new file mode 100644 index 000000000000..3dddf2105d9d --- /dev/null +++ b/integrations/hermes-t3-gateway/tests/test_adapter.py @@ -0,0 +1,2947 @@ +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +import enum +import importlib.util +import pathlib +import sys +import tempfile +import threading +import types +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PACKAGE = "hermes_t3_gateway_adapter_test" + + +class Platform(str, enum.Enum): + T3 = "t3" + + @classmethod + def _missing_(cls, value): + if value == "t3": + return cls.T3 + return None + + +@dataclasses.dataclass +class PlatformConfig: + enabled: bool = True + extra: dict = dataclasses.field(default_factory=dict) + + +class MessageType(enum.Enum): + TEXT = "text" + COMMAND = "command" + + +@dataclasses.dataclass +class MessageEvent: + text: str + message_type: MessageType + source: object + message_id: str + metadata: dict + # Media attachments, defaulted exactly like upstream + # (`gateway/platforms/base.py:1801`): local file paths plus aligned MIMEs. + media_urls: list = dataclasses.field(default_factory=list) + media_types: list = dataclasses.field(default_factory=list) + + +@dataclasses.dataclass +class SendResult: + success: bool + message_id: str | None = None + error: str | None = None + + +@dataclasses.dataclass +class Source: + platform: Platform + chat_id: str + message_id: str + + +class BasePlatformAdapter: + def __init__(self, config, platform): + self.config = config + self.platform = platform + self._status_text = {} + self.messages = [] + self._running = False + self._message_handler = None + + def build_source(self, *, chat_id, message_id, **kwargs): + return Source(self.platform, str(chat_id), str(message_id)) + + async def handle_message(self, event): + self.messages.append(event) + if ( + self._message_handler is not None + and event.message_type == MessageType.COMMAND + and event.text.startswith("/steer ") + ): + # Faithful model of Hermes BasePlatformAdapter's active-command + # bypass path (gateway/platforms/base.py ~4926 at upstream + # 62e07223): the gateway handler returns a control + # acknowledgement, then the base adapter sends it through the + # platform adapter with `reply_to=_reply_anchor_for_event(event)` + # — which, for a platform with no thread_id, is the dispatched + # event's own message_id — and notify=True metadata. + response = await self._message_handler(event) + if response: + await self.send( + event.source.chat_id, + response, + reply_to=event.message_id, + metadata={"notify": True}, + ) + + async def interrupt_session_activity(self, session_key, chat_id): + self.interrupted = (session_key, chat_id) + + def set_status_text(self, chat_id, text): + if text: + self._status_text[str(chat_id)] = text + else: + self._status_text.pop(str(chat_id), None) + + def _mark_connected(self): + self._running = True + + def _mark_disconnected(self): + self._running = False + + def _set_fatal_error(self, *args, **kwargs): + self.fatal_error = (args, kwargs) + + +def build_session_key(source): + return f"agent:main:t3:dm:{source.chat_id}" + + +def install_fake_hermes_modules(): + gateway = types.ModuleType("gateway") + config = types.ModuleType("gateway.config") + config.Platform = Platform + config.PlatformConfig = PlatformConfig + platforms = types.ModuleType("gateway.platforms") + base = types.ModuleType("gateway.platforms.base") + base.BasePlatformAdapter = BasePlatformAdapter + base.MessageEvent = MessageEvent + base.MessageType = MessageType + base.SendResult = SendResult + session = types.ModuleType("gateway.session") + session.build_session_key = build_session_key + sys.modules.update( + { + "gateway": gateway, + "gateway.config": config, + "gateway.platforms": platforms, + "gateway.platforms.base": base, + "gateway.session": session, + } + ) + + +def load_plugin_modules(): + install_fake_hermes_modules() + package = types.ModuleType(PACKAGE) + package.__path__ = [str(ROOT)] + sys.modules[PACKAGE] = package + for name in ("protocol", "connection", "cli", "home", "adapter"): + spec = importlib.util.spec_from_file_location( + f"{PACKAGE}.{name}", ROOT / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"{PACKAGE}.{name}"] = module + spec.loader.exec_module(module) + return sys.modules[f"{PACKAGE}.adapter"] + + +adapter_module = load_plugin_modules() +protocol_module = sys.modules[f"{PACKAGE}.protocol"] +home_module = sys.modules[f"{PACKAGE}.home"] + + +@contextlib.contextmanager +def hermes_without_describe_surfaces(): + """Model an older Hermes: the modules import, the accessors are absent.""" + names = ("hermes_cli", "hermes_cli.config", "tools", "tools.skills_tool") + saved = {name: sys.modules.get(name) for name in names} + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.__path__ = [] + config = types.ModuleType("hermes_cli.config") + tools = types.ModuleType("tools") + tools.__path__ = [] + skills_tool = types.ModuleType("tools.skills_tool") + sys.modules.update( + { + "hermes_cli": hermes_cli, + "hermes_cli.config": config, + "tools": tools, + "tools.skills_tool": skills_tool, + } + ) + try: + yield + finally: + for name, module in saved.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +class FakeConnection: + def __init__(self): + self.connected = True + self.messages = [] + + async def send(self, message): + self.messages.append(message) + + +class AdapterTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.adapter = adapter_module.T3PlatformAdapter( + PlatformConfig( + extra={ + "url": "wss://t3.example/api/hermes-gateway/ws", + "instance_id": "instance", + "credential": "credential", + } + ) + ) + self.connection = FakeConnection() + self.adapter._connection = self.connection + # Exercise retained callbacks independently of the production boundary. + self.adapter._gateway_interactive_turns_enabled = True + + async def test_gateway_interactive_turns_are_disabled_by_default(self): + self.adapter._gateway_interactive_turns_enabled = False + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "interactive-disabled", + "threadId": "thread-1", + "sessionId": "session-1", + "turnId": "turn-1", + "text": "do not run", + } + ) + error = self.connection.messages[-1] + self.assertEqual(error["type"], "protocol.error") + self.assertEqual(error["requestId"], "interactive-disabled") + self.assertIn("hermes-acp", error["message"]) + + async def _start_turn(self, thread_id: str, turn_id: str): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": f"ensure-{thread_id}", + "threadId": thread_id, + } + ) + session_id = self.adapter._sessions[thread_id] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": f"start-{thread_id}", + "threadId": thread_id, + "sessionId": session_id, + "turnId": turn_id, + "text": "Start", + } + ) + return session_id + + async def test_thread_ensure_start_stream_and_complete(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": "ensure-1", + "threadId": "thread-1", + } + ) + ready = self.connection.messages[-2] + self.assertEqual(ready["type"], "session.ready") + self.assertEqual(ready["sessionId"], "agent:main:t3:dm:thread-1") + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1) + + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-1", + "threadId": "thread-1", + "sessionId": ready["sessionId"], + "turnId": "turn-1", + "text": "Hello Hermes", + } + ) + self.assertEqual(self.adapter.messages[-1].text, "Hello Hermes") + await self.adapter.send("thread-1", "Hello", metadata={"expect_edits": True}) + # `finalize` must NOT complete the turn — the gateway's progress loop + # sets it on every progress edit. Only a `notify=True` send does. + await self.adapter.edit_message( + "thread-1", "message", "Hello world", finalize=True + ) + self.assertNotIn( + "turn.completed", [m["type"] for m in self.connection.messages] + ) + await self.adapter.send("thread-1", "Hello world", metadata={"notify": True}) + types_seen = [message["type"] for message in self.connection.messages] + self.assertIn("content.delta", types_seen) + self.assertIn("turn.completed", types_seen) + deltas = [ + message["delta"] + for message in self.connection.messages + if message["type"] == "content.delta" + ] + self.assertEqual(deltas, ["Hello", " world"]) + + async def test_tool_progress_bubble_edits_never_complete_the_turn(self): + """Regression: the gateway's progress loop must not end a T3 turn. + + This is the defect this plugin shipped with. Declaring + ``REQUIRES_EDIT_FINALIZE = True`` makes the gateway's tool-progress + loop pass ``finalize=True`` on EVERY progress-bubble edit + (``gateway/run.py:20777-20780`` at upstream 62e07223) — it is a + presentation hint for rich-card surfaces, not a turn boundary. + Treating it as "turn finished" closed the T3 turn on the first tool + call; every later send then failed with "no active T3 turn" and the + real answer never reached the transcript. The turn ends on exactly one + signal: ``notify=True`` metadata on ``send``, which the gateway applies + via ``_mark_notify_metadata`` (``gateway/platforms/base.py:89``) only + for genuine user-visible replies. + """ + # Declaring the flag is what arms the gateway's finalize-on-every-edit + # branch, so the declaration itself is part of the contract under test. + # It is NOT sufficient on its own: the stream consumer also passes + # finalize=True on every mid-turn segment break regardless of the flag + # (`gateway/stream_consumer.py:938-940`), which is why `edit_message` + # must ignore `finalize` outright — see the segment-break leg below. + self.assertFalse(self.adapter.REQUIRES_EDIT_FINALIZE) + + await self._start_turn("thread-progress", "turn-progress") + turn = self.adapter._active_turns["thread-progress"] + progress_start = len(self.connection.messages) + + # Progress metadata is thread/routing metadata only; the progress loop + # never marks it notify-worthy (verified: zero _mark_notify_metadata + # calls in gateway/run.py:20700-20960). + progress_metadata = {"thread_id": "thread-progress"} + + async def edit_progress_message(message_id: str, content: str): + """Mirror of the gateway's `_edit_progress_message` closure.""" + kwargs = { + "chat_id": "thread-progress", + "message_id": message_id, + "content": content, + } + if getattr(self.adapter, "REQUIRES_EDIT_FINALIZE", False): + kwargs["finalize"] = True + kwargs["metadata"] = progress_metadata + return await self.adapter.edit_message(**kwargs) + + # First progress bubble is a plain send, never notify-marked. + first = await self.adapter.send( + "thread-progress", + "📚 Reading skill hermes-agent", + reply_to=None, + metadata=progress_metadata, + ) + self.assertTrue(first.success) + self.assertIn("thread-progress", self.adapter._active_turns) + + # Then the loop edits that one bubble once per tool event. + progress_lines = ["📚 Reading skill hermes-agent"] + for line in ( + "🔍 Searching the web for hermes gateway", + "📖 Reading file gateway/run.py", + "🛠️ Running tests", + ): + progress_lines.append(line) + result = await edit_progress_message( + first.message_id, "\n".join(progress_lines) + ) + self.assertTrue(result.success) + # Every single edit must leave the turn running. + self.assertIn("thread-progress", self.adapter._active_turns) + self.assertIs(self.adapter._active_turns["thread-progress"], turn) + + self.assertNotIn( + "turn.completed", + [message["type"] for message in self.connection.messages], + ) + + # Second, flag-independent leg: the stream consumer finalizes the + # current content message at every tool/segment boundary + # (`gateway/stream_consumer.py:938-940` passes + # `finalize=(got_done or got_segment_break)`), and it does so whether + # or not the adapter declares REQUIRES_EDIT_FINALIZE. A mid-turn + # segment break is not a turn boundary either. + for partial in ("Let me check the docs.", "Let me check the docs. Found it."): + segment = await self.adapter.edit_message( + "thread-progress", + first.message_id, + partial, + finalize=True, + metadata=progress_metadata, + ) + self.assertTrue(segment.success) + self.assertIn("thread-progress", self.adapter._active_turns) + self.assertNotIn( + "turn.completed", + [message["type"] for message in self.connection.messages], + ) + + # Now the real answer arrives as the gateway's notify-marked final + # send. That — and only that — closes the turn, exactly once. + answer = await self.adapter.send( + "thread-progress", + "\n".join(progress_lines) + "\nHere is the real answer.", + metadata={"notify": True}, + ) + self.assertTrue(answer.success) + self.assertNotIn("thread-progress", self.adapter._active_turns) + self.assertEqual( + [ + message["type"] + for message in self.connection.messages[progress_start:] + if message["type"] == "turn.completed" + ], + ["turn.completed"], + ) + + # A late finalize edit after completion cannot resurrect or re-close + # the turn; it fails closed with the "no active turn" result. + late = await edit_progress_message(first.message_id, "late progress") + self.assertFalse(late.success) + self.assertEqual(late.error, "no active T3 turn") + self.assertEqual( + len( + [ + message + for message in self.connection.messages + if message["type"] == "turn.completed" + ] + ), + 1, + ) + + async def test_cumulative_edits_emit_delta_snapshot_delta_then_finalize(self): + await self._start_turn("thread-snapshot", "turn-snapshot") + content_start = len(self.connection.messages) + + await self.adapter.send("thread-snapshot", "Hello") + duplicate_start = len(self.connection.messages) + await self.adapter.edit_message("thread-snapshot", "message", "Hello") + self.assertEqual(len(self.connection.messages), duplicate_start) + + await self.adapter.edit_message("thread-snapshot", "message", "Help") + snapshot_duplicate_start = len(self.connection.messages) + await self.adapter.edit_message("thread-snapshot", "message", "Help") + self.assertEqual(len(self.connection.messages), snapshot_duplicate_start) + + await self.adapter.edit_message( + "thread-snapshot", + "message", + "Helpful", + finalize=True, + ) + # `finalize` is inert; the notify send is what closes the turn. + await self.adapter.send("thread-snapshot", "Helpful", metadata={"notify": True}) + + content_frames = self.connection.messages[content_start:] + self.assertEqual( + [message["type"] for message in content_frames], + [ + "item.started", + "content.delta", + "content.snapshot", + "content.delta", + "item.completed", + "turn.completed", + "connection.status", + ], + ) + self.assertEqual(content_frames[1]["delta"], "Hello") + self.assertEqual(content_frames[2]["text"], "Help") + self.assertEqual(content_frames[3]["delta"], "ful") + + async def test_empty_and_duplicate_cumulative_edits_are_reconciled(self): + await self._start_turn("thread-empty", "turn-empty") + content_start = len(self.connection.messages) + + await self.adapter.send("thread-empty", "") + duplicate_start = len(self.connection.messages) + await self.adapter.edit_message("thread-empty", "message", "") + self.assertEqual(len(self.connection.messages), duplicate_start) + + await self.adapter.edit_message("thread-empty", "message", "Visible") + await self.adapter.edit_message("thread-empty", "message", "") + empty_snapshot_end = len(self.connection.messages) + await self.adapter.edit_message("thread-empty", "message", "") + self.assertEqual(len(self.connection.messages), empty_snapshot_end) + await self.adapter.edit_message( + "thread-empty", + "message", + "", + finalize=True, + ) + await self.adapter.send("thread-empty", "", metadata={"notify": True}) + + content_frames = self.connection.messages[content_start:] + self.assertEqual( + [message["type"] for message in content_frames], + [ + "item.started", + "content.delta", + "content.snapshot", + "item.completed", + "turn.completed", + "connection.status", + ], + ) + self.assertEqual(content_frames[1]["delta"], "Visible") + self.assertEqual(content_frames[2]["text"], "") + + async def test_failed_content_sends_do_not_advance_visible_text(self): + await self._start_turn("thread-retry", "turn-retry") + await self.adapter.send("thread-retry", "Hello") + original_send = self.connection.send + + async def fail_content(message): + if message["type"] in {"content.delta", "content.snapshot"}: + raise ConnectionError("send failed") + await original_send(message) + + self.connection.send = fail_content + failed = await self.adapter.edit_message( + "thread-retry", + "message", + "Hello world", + ) + self.assertFalse(failed.success) + self.assertEqual( + self.adapter._active_turns["thread-retry"].visible_text, + "Hello", + ) + + self.connection.send = original_send + retried = await self.adapter.edit_message( + "thread-retry", + "message", + "Hello world", + ) + self.assertTrue(retried.success) + self.assertEqual(self.connection.messages[-1]["delta"], " world") + + self.connection.send = fail_content + failed_snapshot = await self.adapter.edit_message( + "thread-retry", + "message", + "Hi", + ) + self.assertFalse(failed_snapshot.success) + self.assertEqual( + self.adapter._active_turns["thread-retry"].visible_text, + "Hello world", + ) + + self.connection.send = original_send + retried_snapshot = await self.adapter.edit_message( + "thread-retry", + "message", + "Hi", + ) + self.assertTrue(retried_snapshot.success) + self.assertEqual(self.connection.messages[-1]["type"], "content.snapshot") + self.assertEqual(self.connection.messages[-1]["text"], "Hi") + + async def test_failed_generic_activity_start_retries_the_full_lifecycle(self): + await self._start_turn("thread-activity-retry", "turn-activity-retry") + turn = self.adapter._active_turns["thread-activity-retry"] + original_send = self.connection.send + + async def fail_activity_start(message): + if message["type"] == "item.started": + raise ConnectionError("send failed") + await original_send(message) + + self.connection.send = fail_activity_start + with self.assertRaisesRegex(ConnectionError, "send failed"): + await self.adapter._emit_generic_activity(turn, "Reading repository") + + self.assertIsNone(turn.generic_activity_id) + self.assertIsNone(turn.generic_activity_detail) + + self.connection.send = original_send + await self.adapter._emit_generic_activity(turn, "Reading repository") + started = self.connection.messages[-1] + self.assertEqual(started["type"], "item.started") + self.assertEqual(started["detail"], "Reading repository") + self.assertEqual(turn.generic_activity_id, started["itemId"]) + self.assertEqual(turn.generic_activity_detail, "Reading repository") + + await self.adapter._emit_generic_activity(turn, "Running tests") + updated = self.connection.messages[-1] + self.assertEqual(updated["type"], "item.updated") + self.assertEqual(updated["itemId"], started["itemId"]) + self.assertEqual(updated["detail"], "Running tests") + + async def test_live_status_uses_status_text_not_the_unknown_sentinel(self): + await self._start_turn("thread-status-type", "turn-status-type") + turn = self.adapter._active_turns["thread-status-type"] + + await self.adapter._emit_generic_activity(turn, "Reading repository") + await self.adapter._emit_generic_activity(turn, "Running tests") + await self.adapter._complete_turn(turn) + + status_frames = [ + message + for message in self.connection.messages + if message.get("itemId") == turn.generic_activity_id + ] + self.assertEqual( + [message["type"] for message in status_frames], + ["item.started", "item.updated", "item.completed"], + ) + # `unknown` is the canonical "could not classify" sentinel other + # adapters rely on being inert; status lines get their own type. + self.assertEqual( + {message["itemType"] for message in status_frames}, + {"status_text"}, + ) + # T3 renders these rows preferring `detail`, so the real status string + # must ride there rather than only in `title`. + self.assertEqual(status_frames[0]["detail"], "Reading repository") + self.assertEqual(status_frames[1]["detail"], "Running tests") + self.assertEqual(status_frames[2]["detail"], "Running tests") + + async def test_concurrent_generic_activity_updates_share_one_lifecycle(self): + await self._start_turn("thread-activity-concurrent", "turn-activity-concurrent") + turn = self.adapter._active_turns["thread-activity-concurrent"] + original_send = self.connection.send + first_send_started = asyncio.Event() + release_first_send = asyncio.Event() + + async def block_first_activity_send(message): + if message["type"] == "item.started" and not first_send_started.is_set(): + first_send_started.set() + await release_first_send.wait() + await original_send(message) + + self.connection.send = block_first_activity_send + first_update = asyncio.create_task( + self.adapter._emit_generic_activity(turn, "Reading repository") + ) + await first_send_started.wait() + second_update = asyncio.create_task( + self.adapter._emit_generic_activity(turn, "Running tests") + ) + await asyncio.sleep(0) + release_first_send.set() + await asyncio.gather(first_update, second_update) + + activity_frames = [ + message + for message in self.connection.messages + if message["type"] in {"item.started", "item.updated"} + ] + self.assertEqual( + [message["type"] for message in activity_frames], + ["item.started", "item.updated"], + ) + self.assertEqual(activity_frames[0]["itemId"], activity_frames[1]["itemId"]) + self.assertEqual(turn.generic_activity_id, activity_frames[0]["itemId"]) + self.assertEqual(turn.generic_activity_detail, "Running tests") + + async def test_turn_completion_waits_for_in_flight_generic_activity_update(self): + await self._start_turn("thread-activity-complete", "turn-activity-complete") + turn = self.adapter._active_turns["thread-activity-complete"] + await self.adapter._emit_generic_activity(turn, "Reading repository") + activity_id = turn.generic_activity_id + original_send = self.connection.send + update_send_started = asyncio.Event() + release_update_send = asyncio.Event() + + async def block_activity_update(message): + if message["type"] == "item.updated": + update_send_started.set() + await release_update_send.wait() + await original_send(message) + + self.connection.send = block_activity_update + in_flight_update = asyncio.create_task( + self.adapter._emit_generic_activity(turn, "Running tests") + ) + await update_send_started.wait() + completion = asyncio.create_task(self.adapter._complete_turn(turn)) + await asyncio.sleep(0) + self.assertFalse(completion.done()) + + release_update_send.set() + await asyncio.gather(in_flight_update, completion) + + lifecycle_frames = [ + message + for message in self.connection.messages + if message["type"] + in {"item.started", "item.updated", "item.completed", "turn.completed"} + ] + self.assertEqual( + [message["type"] for message in lifecycle_frames], + ["item.started", "item.updated", "item.completed", "turn.completed"], + ) + self.assertTrue( + all( + message["itemId"] == activity_id + for message in lifecycle_frames + if message["type"].startswith("item.") + ) + ) + self.assertNotIn("thread-activity-complete", self.adapter._active_turns) + + def test_home_channel_notice_literal_matches_hermes_construction(self): + # Hermes builds this notice inline from an f-string rather than + # exporting a constant (gateway/run.py:13780 at upstream 62e07223), and + # the adapter suppresses it by exact string equality. Reconstruct it the + # same way so upstream wording drift fails here loudly instead of + # leaking the notice into a T3 transcript. + platform_name = "t3" # Platform("t3").value + sethome_cmd = "/sethome" # non-Slack branch + expected = ( + f"📬 No home channel is set for {platform_name.title()}. " + f"A home channel is where Hermes delivers cron job results " + f"and cross-platform messages.\n\n" + f"Type {sethome_cmd} to make this chat your home channel, " + f"or ignore to skip." + ) + self.assertEqual(adapter_module._T3_HOME_CHANNEL_NOTICE, expected) + + async def test_exact_t3_home_channel_notice_is_suppressed(self): + await self._start_turn("thread-notice", "turn-notice") + content_start = len(self.connection.messages) + notice = ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, or ignore to skip." + ) + + suppressed = await self.adapter.send("thread-notice", notice) + self.assertTrue(suppressed.success) + self.assertEqual(len(self.connection.messages), content_start) + self.assertFalse( + self.adapter._active_turns["thread-notice"].assistant_started + ) + + await self.adapter.edit_message( + "thread-notice", + "message", + "The actual Hermes response", + finalize=True, + ) + await self.adapter.send( + "thread-notice", + "The actual Hermes response", + metadata={"notify": True}, + ) + content_frames = self.connection.messages[content_start:] + self.assertEqual( + [message["type"] for message in content_frames], + [ + "item.started", + "content.delta", + "item.completed", + "turn.completed", + "connection.status", + ], + ) + self.assertEqual(content_frames[1]["delta"], "The actual Hermes response") + + async def test_terminal_send_suppresses_exact_home_notice_and_completes_turn(self): + await self._start_turn("thread-terminal-notice-send", "turn-terminal-notice-send") + content_start = len(self.connection.messages) + notice = ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, or ignore to skip." + ) + + suppressed = await self.adapter.send( + "thread-terminal-notice-send", + notice, + metadata={"notify": True}, + ) + + self.assertTrue(suppressed.success) + self.assertNotIn("thread-terminal-notice-send", self.adapter._active_turns) + self.assertEqual( + [ + message["type"] + for message in self.connection.messages[content_start:] + ], + ["turn.completed", "connection.status"], + ) + + async def test_edit_of_exact_home_notice_is_suppressed_without_completing(self): + await self._start_turn("thread-terminal-notice-edit", "turn-terminal-notice-edit") + content_start = len(self.connection.messages) + notice = ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, or ignore to skip." + ) + + suppressed = await self.adapter.edit_message( + "thread-terminal-notice-edit", + "message", + notice, + finalize=True, + ) + + self.assertTrue(suppressed.success) + # The notice is still swallowed, but an edit — even a `finalize` one — + # no longer ends the turn: the progress loop sets `finalize` on every + # progress bubble, so acting on it truncated real turns. + self.assertIn("thread-terminal-notice-edit", self.adapter._active_turns) + self.assertEqual(self.connection.messages[content_start:], []) + + async def test_near_match_home_channel_text_is_not_suppressed(self): + await self._start_turn("thread-notice-near-match", "turn-notice-near-match") + content_start = len(self.connection.messages) + await self.adapter.edit_message( + "thread-notice-near-match", + "message", + ( + "📬 No home channel is set for T3. " + "A home channel is where Hermes delivers cron job results " + "and cross-platform messages.\n\n" + "Type /sethome to make this chat your home channel, " + "or ignore to skip. " + ), + finalize=True, + ) + await self.adapter.send( + "thread-notice-near-match", "done", metadata={"notify": True} + ) + self.assertEqual( + [ + message["type"] + for message in self.connection.messages[content_start:] + ], + [ + "item.started", + "content.delta", + "content.snapshot", + "item.completed", + "turn.completed", + "connection.status", + ], + ) + + async def test_session_ready_reports_an_active_turn_on_reconnect(self): + session_id = await self._start_turn("thread-reconnect", "turn-reconnect") + + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": "ensure-reconnect", + "threadId": "thread-reconnect", + "resumeSessionId": session_id, + } + ) + + ready = self.connection.messages[-2] + self.assertEqual(ready["type"], "session.ready") + self.assertTrue(ready["resumed"]) + self.assertEqual(ready["activeTurnId"], "turn-reconnect") + + async def test_steer_uses_official_hermes_command(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": "ensure-2", + "threadId": "thread-2", + } + ) + session_id = self.adapter._sessions["thread-2"] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-2", + "threadId": "thread-2", + "sessionId": session_id, + "turnId": "turn-2", + "text": "Start", + } + ) + messages_before_steer = len(self.connection.messages) + + async def accept_steer(_event): + return ( + "⏩ Steer queued — arrives after the next tool call: 'Focus on tests'" + ) + + self.adapter._message_handler = accept_steer + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 4, + "requestId": "steer-2", + "threadId": "thread-2", + "sessionId": session_id, + "turnId": "turn-2", + "text": "Focus on tests", + } + ) + self.assertEqual(self.adapter.messages[-1].text, "/steer Focus on tests") + self.assertEqual(self.adapter.messages[-1].message_type, MessageType.COMMAND) + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], ["turn.started"] + ) + self.assertEqual(steer_messages[0]["requestId"], "steer-2") + self.assertIn("thread-2", self.adapter._active_turns) + + # A post-steer edit streams the real answer. `finalize` is inert — the + # gateway sets it on every tool-progress edit — so the turn must stay + # open until the notify-marked final send arrives. + await self.adapter.edit_message( + "thread-2", + "message", + "Actual response after steering", + finalize=True, + ) + deltas = [ + message["delta"] + for message in self.connection.messages + if message["type"] == "content.delta" + ] + self.assertEqual(deltas, ["Actual response after steering"]) + self.assertIn("thread-2", self.adapter._active_turns) + self.assertNotIn( + "turn.completed", [m["type"] for m in self.connection.messages] + ) + + await self.adapter.send( + "thread-2", + "Actual response after steering", + metadata={"notify": True}, + ) + self.assertNotIn("thread-2", self.adapter._active_turns) + self.assertEqual( + [ + message["type"] + for message in self.connection.messages + if message["type"] == "turn.completed" + ], + ["turn.completed"], + ) + + async def test_assistant_output_during_a_steer_is_not_captured_as_control(self): + session_id = await self._start_turn("thread-steer-race", "turn-steer-race") + messages_before_steer = len(self.connection.messages) + + async def stream_while_steering(event): + # A steer targets a RUNNING turn, so Hermes can emit genuine + # assistant output on this same thread while the steering command + # is still being awaited. That output must reach the transcript. + await self.adapter.edit_message( + "thread-steer-race", + "hermes-stream-message", + "Mid-steer assistant output", + ) + del event + return "⏩ Steer queued — arrives after the next tool call: 'Focus'" + + self.adapter._message_handler = stream_while_steering + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 4, + "requestId": "steer-race", + "threadId": "thread-steer-race", + "sessionId": session_id, + "turnId": "turn-steer-race", + "text": "Focus", + } + ) + + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], + ["item.started", "content.delta", "turn.started"], + ) + self.assertEqual(steer_messages[1]["delta"], "Mid-steer assistant output") + # The acknowledgement itself is still captured and suppressed, so the + # steer is acknowledged rather than failing closed on the prefix check. + self.assertEqual(steer_messages[2]["requestId"], "steer-race") + self.assertIn("thread-steer-race", self.adapter._active_turns) + self.assertEqual( + self.adapter._active_turns["thread-steer-race"].visible_text, + "Mid-steer assistant output", + ) + + async def test_steer_control_acknowledgement_edits_stay_suppressed(self): + session_id = await self._start_turn("thread-steer-edit", "turn-steer-edit") + messages_before_steer = len(self.connection.messages) + acknowledgement = "⏩ Steer queued — arrives after the next tool call: 'Focus'" + + async def edit_own_acknowledgement(event): + sent = await self.adapter.send( + "thread-steer-edit", + acknowledgement, + reply_to=event.message_id, + metadata={"notify": True}, + ) + # A retry/finalize edit of the control message correlates by the + # synthetic control message id, so it stays out of the transcript. + await self.adapter.edit_message( + "thread-steer-edit", + sent.message_id, + acknowledgement, + finalize=True, + ) + + self.adapter._message_handler = edit_own_acknowledgement + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 4, + "requestId": "steer-edit", + "threadId": "thread-steer-edit", + "sessionId": session_id, + "turnId": "turn-steer-edit", + "text": "Focus", + } + ) + + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], ["turn.started"] + ) + self.assertIn("thread-steer-edit", self.adapter._active_turns) + + async def test_rejected_steer_emits_error_without_completing_active_turn(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": "ensure-rejected-steer", + "threadId": "thread-rejected-steer", + } + ) + session_id = self.adapter._sessions["thread-rejected-steer"] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-rejected-steer", + "threadId": "thread-rejected-steer", + "sessionId": session_id, + "turnId": "turn-rejected-steer", + "text": "Start", + } + ) + messages_before_steer = len(self.connection.messages) + + async def reject_steer(_event): + return "Steer rejected (empty payload)." + + self.adapter._message_handler = reject_steer + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 4, + "requestId": "steer-rejected", + "threadId": "thread-rejected-steer", + "sessionId": session_id, + "turnId": "turn-rejected-steer", + "text": "Focus on tests", + } + ) + + # The core invariant: rejecting a steer reports an error and leaves the + # running turn untouched. The rejection must emit exactly the error — + # no turn lifecycle frame of any kind. + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], ["protocol.error"] + ) + self.assertEqual(steer_messages[0]["requestId"], "steer-rejected") + self.assertEqual(steer_messages[0]["code"], "invalid-message") + self.assertIn("thread-rejected-steer", self.adapter._active_turns) + + # The still-active turn keeps streaming. `finalize` on an edit is inert + # (the gateway sets it on every progress bubble), so the turn survives. + await self.adapter.edit_message( + "thread-rejected-steer", + "message", + "Actual response after rejected steering", + finalize=True, + ) + self.assertEqual(self.connection.messages[-1]["type"], "content.delta") + self.assertIn("thread-rejected-steer", self.adapter._active_turns) + self.assertNotIn( + "turn.completed", [m["type"] for m in self.connection.messages] + ) + + # Only the notify-marked final send ends it. + await self.adapter.send( + "thread-rejected-steer", + "Actual response after rejected steering", + metadata={"notify": True}, + ) + self.assertEqual(self.connection.messages[-1]["type"], "connection.status") + self.assertNotIn("thread-rejected-steer", self.adapter._active_turns) + + async def test_failed_steer_emits_correlated_internal_error(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": "ensure-failed-steer", + "threadId": "thread-failed-steer", + } + ) + session_id = self.adapter._sessions["thread-failed-steer"] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-failed-steer", + "threadId": "thread-failed-steer", + "sessionId": session_id, + "turnId": "turn-failed-steer", + "text": "Start", + } + ) + messages_before_steer = len(self.connection.messages) + + async def fail_steer(_event): + raise RuntimeError("running agent rejected steering") + + self.adapter._message_handler = fail_steer + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 4, + "requestId": "steer-failed", + "threadId": "thread-failed-steer", + "sessionId": session_id, + "turnId": "turn-failed-steer", + "text": "Focus on tests", + } + ) + + steer_messages = self.connection.messages[messages_before_steer:] + self.assertEqual( + [message["type"] for message in steer_messages], ["protocol.error"] + ) + self.assertEqual(steer_messages[0]["requestId"], "steer-failed") + self.assertEqual(steer_messages[0]["code"], "internal-error") + self.assertIn("thread-failed-steer", self.adapter._active_turns) + + async def test_schedule_keeps_a_strong_reference_until_the_task_finishes(self): + self.adapter._event_loop = asyncio.get_running_loop() + released = asyncio.Event() + + async def work(): + await asyncio.sleep(0) + released.set() + + self.adapter._schedule(work()) + self.assertEqual(len(self.adapter._scheduled_tasks), 1) + await released.wait() + await asyncio.sleep(0) + self.assertEqual(self.adapter._scheduled_tasks, set()) + + async def test_schedule_logs_background_task_failures(self): + self.adapter._event_loop = asyncio.get_running_loop() + + async def boom(): + raise RuntimeError("background frame failed") + + with self.assertLogs(adapter_module.logger, level="ERROR") as captured: + self.adapter._schedule(boom()) + await asyncio.sleep(0) + await asyncio.sleep(0) + self.assertTrue( + any("background frame failed" in line for line in captured.output) + ) + self.assertEqual(self.adapter._scheduled_tasks, set()) + + async def test_schedule_does_not_create_tasks_on_a_foreign_loop(self): + other_loop = asyncio.new_event_loop() + self.adapter._event_loop = other_loop + + async def work(): + return None + + coroutine = work() + try: + with ( + unittest.mock.patch.object(other_loop, "create_task") as create_task, + unittest.mock.patch.object( + adapter_module.asyncio, "run_coroutine_threadsafe" + ) as threadsafe, + ): + # The running loop is this test's loop, not the adapter's, so + # create_task would schedule onto the wrong loop entirely. + self.adapter._schedule(coroutine) + create_task.assert_not_called() + threadsafe.assert_called_once_with(coroutine, other_loop) + self.assertEqual(self.adapter._scheduled_tasks, set()) + finally: + coroutine.close() + other_loop.close() + + async def test_schedule_closes_the_coroutine_when_the_loop_is_gone(self): + self.adapter._event_loop = None + started = False + + async def work(): + nonlocal started + started = True + + coroutine = work() + self.adapter._schedule(coroutine) + self.assertFalse(started) + self.assertEqual(self.adapter._scheduled_tasks, set()) + + async def test_session_status_counts_ready_sessions_and_stop_decrements(self): + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": "ensure-3", + "threadId": "thread-3", + } + ) + session_id = self.adapter._sessions["thread-3"] + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1) + await self.adapter._handle_server_frame( + { + "type": "session.stop", + "protocolVersion": 4, + "requestId": "stop-3", + "threadId": "thread-3", + "sessionId": session_id, + } + ) + self.assertEqual(self.connection.messages[-1]["type"], "connection.status") + self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 0) + self.assertEqual(self.adapter._sessions["thread-3"], session_id) + + async def test_describe_request_replies_with_the_requests_own_id(self): + with unittest.mock.patch.object( + adapter_module, "_hermes_version", return_value="0.19.0" + ), unittest.mock.patch.object( + adapter_module, + "describe_response", + wraps=adapter_module.describe_response, + ) as describe: + await self.adapter._handle_server_frame( + { + "type": "describe.request", + "protocolVersion": 4, + "requestId": "describe-1", + } + ) + + reply = self.connection.messages[-1] + self.assertEqual(reply["type"], "describe.response") + # Correlation, exactly like ping -> pong. + self.assertEqual(reply["requestId"], "describe-1") + self.assertEqual(reply["protocolVersion"], 4) + self.assertEqual(reply["hermesVersion"], "0.19.0") + self.assertIsInstance(reply["skills"], list) + self.assertIn("capabilities", reply) + self.assertEqual(describe.call_count, 1) + + async def test_describe_request_survives_hermes_being_unreadable(self): + # An older Hermes whose modules exist but export none of the accessors + # the plugin reads. The reply gets thinner; it never becomes an error + # and never breaks the connection. + with hermes_without_describe_surfaces(): + await self.adapter._handle_server_frame( + { + "type": "describe.request", + "protocolVersion": 4, + "requestId": "describe-degraded", + } + ) + reply = self.connection.messages[-1] + self.assertEqual(reply["type"], "describe.response") + self.assertEqual(reply["requestId"], "describe-degraded") + self.assertNotIn("reasoningEffort", reply) + self.assertNotIn("model", reply) + self.assertEqual(reply["skills"], []) + self.assertEqual(reply["pluginVersion"], protocol_module.PLUGIN_VERSION) + + async def test_skill_body_request_survives_hermes_being_unreadable(self): + with hermes_without_describe_surfaces(): + await self.adapter._handle_server_frame( + { + "type": "skill.body.request", + "protocolVersion": 4, + "requestId": "body-degraded", + "skillName": "codex", + } + ) + reply = self.connection.messages[-1] + self.assertEqual(reply["type"], "skill.body.response") + self.assertEqual(reply["requestId"], "body-degraded") + self.assertEqual(reply["skillName"], "codex") + self.assertIsNone(reply["markdown"]) + + async def test_skill_body_request_replies_with_correlated_markdown(self): + with unittest.mock.patch.object( + adapter_module, "skill_body", return_value="# Codex\n" + ) as read_body: + await self.adapter._handle_server_frame( + { + "type": "skill.body.request", + "protocolVersion": 4, + "requestId": "body-1", + "skillName": "codex", + } + ) + + reply = self.connection.messages[-1] + self.assertEqual(reply["type"], "skill.body.response") + self.assertEqual(reply["requestId"], "body-1") + self.assertEqual(reply["skillName"], "codex") + self.assertEqual(reply["markdown"], "# Codex\n") + read_body.assert_called_once_with("codex") + + async def test_skill_body_request_replies_null_for_an_unknown_skill(self): + await self.adapter._handle_server_frame( + { + "type": "skill.body.request", + "protocolVersion": 4, + "requestId": "body-2", + "skillName": "does-not-exist", + } + ) + reply = self.connection.messages[-1] + self.assertEqual(reply["type"], "skill.body.response") + self.assertEqual(reply["requestId"], "body-2") + self.assertEqual(reply["skillName"], "does-not-exist") + # Present but null, not an error the UI would have to render. + self.assertIn("markdown", reply) + self.assertIsNone(reply["markdown"]) + + async def test_skill_body_request_without_a_name_is_a_correlated_error(self): + # `skillName` is echoed back for the client to key on and is non-empty + # on the wire, so a nameless request cannot be answered with a + # response frame — it takes the ordinary protocol.error path. + await self.adapter._handle_server_frame( + { + "type": "skill.body.request", + "protocolVersion": 4, + "requestId": "body-3", + } + ) + reply = self.connection.messages[-1] + self.assertEqual(reply["type"], "protocol.error") + self.assertEqual(reply["requestId"], "body-3") + self.assertEqual(reply["code"], "unsupported-message") + self.assertTrue(reply["recoverable"]) + + async def test_describe_frames_never_emit_a_protocol_error(self): + for message in ( + { + "type": "describe.request", + "protocolVersion": 4, + "requestId": "describe-no-error", + }, + { + "type": "skill.body.request", + "protocolVersion": 4, + "requestId": "body-no-error", + "skillName": "codex", + }, + ): + with self.subTest(frame_type=message["type"]): + await self.adapter._handle_server_frame(message) + self.assertNotIn( + "protocol.error", + [message["type"] for message in self.connection.messages], + ) + + def test_tool_progress_chrome_is_dropped(self): + """Tool chrome is redundant with T3's typed activity items. + + T3 already renders tool calls as typed `item.started` / + `item.completed` activity from the `pre_tool_call` / `post_tool_call` + hooks, so a text line duplicating them is strictly worse. + + NOTE: this override is NOT what protects the turn. At Hermes 62e07223 + it is not even on the live path — its only caller, + `GatewayEventDispatcher` (`gateway/stream_dispatch.py:108`), is + referenced solely by upstream tests. The turn is protected by ignoring + `finalize` in `edit_message`; see + `test_tool_progress_bubble_edits_never_complete_the_turn`. + """ + + class _ToolCallChunk: + tool_name = "skill_view" + preview = "hermes-agent" + args = {"name": "hermes-agent"} + + for mode in ("all", "new", "verbose"): + with self.subTest(mode=mode): + self.assertIsNone( + self.adapter.format_tool_event(_ToolCallChunk(), mode=mode) + ) + + async def test_tool_hooks_resolve_the_turn_from_the_gateway_session_key(self): + """Tool hooks carry Hermes' run id, not this plugin's session id. + + `agent.session_id` (`agent/tool_executor.py:188`) is a timestamped run + id like `20260725_143012_ab12cd34` (`gateway/session.py:2388`), while + this plugin's session ids come from `build_session_key` + (`agent:main:t3:dm:`). Keying `_thread_by_session` on the hook's + value alone therefore never matches and silently drops every tool + activity item. The gateway's stable routing key is available from + `HERMES_SESSION_KEY` (`gateway/run.py:17367`), which IS the + build_session_key value. + """ + self.adapter._event_loop = asyncio.get_running_loop() + session_id = await self._start_turn("thread-tools", "turn-tools") + frames_before = len(self.connection.messages) + + # What Hermes actually passes: an unrelated run id. + hermes_run_id = "20260725_143012_ab12cd34" + self.assertNotIn(hermes_run_id, self.adapter._thread_by_session) + + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(lambda: session_id), + ): + self.adapter.emit_tool_started( + hermes_run_id, "web_search", {"query": "hermes"}, "call-1" + ) + self.adapter.emit_tool_completed( + hermes_run_id, "web_search", "result", 42, "call-1" + ) + await asyncio.sleep(0) + + tool_frames = self.connection.messages[frames_before:] + self.assertEqual( + [message["type"] for message in tool_frames], + ["item.started", "item.completed"], + ) + # Both halves must correlate onto ONE activity item, or T3 renders a + # started row that never resolves plus an orphan completion. + self.assertEqual(tool_frames[0]["itemId"], tool_frames[1]["itemId"]) + self.assertEqual(tool_frames[0]["title"], "web_search") + self.assertEqual(tool_frames[1]["status"], "completed") + self.assertEqual(tool_frames[0]["threadId"], "thread-tools") + self.assertEqual(tool_frames[0]["sessionId"], session_id) + + async def test_tool_hooks_fall_back_to_the_sole_active_turn(self): + """With exactly one active turn there is no ambiguity to resolve.""" + self.adapter._event_loop = asyncio.get_running_loop() + await self._start_turn("thread-only", "turn-only") + frames_before = len(self.connection.messages) + + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(lambda: ""), + ): + self.adapter.emit_tool_started( + "20260725_143012_ab12cd34", "read_file", {"path": "a.py"}, "call-2" + ) + await asyncio.sleep(0) + + tool_frames = self.connection.messages[frames_before:] + self.assertEqual([m["type"] for m in tool_frames], ["item.started"]) + self.assertEqual(tool_frames[0]["threadId"], "thread-only") + + async def test_tool_hooks_drop_when_the_turn_is_ambiguous(self): + """Two concurrent turns and no routing key: emit nothing. + + Guessing would attach one thread's tool activity to another's + transcript. Tool activity is decorative, so dropping is correct. + """ + self.adapter._event_loop = asyncio.get_running_loop() + await self._start_turn("thread-a", "turn-a") + await self._start_turn("thread-b", "turn-b") + frames_before = len(self.connection.messages) + + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(lambda: ""), + ): + self.adapter.emit_tool_started( + "20260725_143012_ab12cd34", "read_file", {"path": "a.py"}, "call-3" + ) + self.adapter.emit_tool_completed( + "20260725_143012_ab12cd34", "read_file", "ok", 5, "call-3" + ) + await asyncio.sleep(0) + + self.assertEqual(self.connection.messages[frames_before:], []) + + async def test_tool_hook_session_key_lookup_never_raises(self): + """An unavailable Hermes session context must degrade, not raise.""" + self.adapter._event_loop = asyncio.get_running_loop() + await self._start_turn("thread-ctx-a", "turn-ctx-a") + await self._start_turn("thread-ctx-b", "turn-ctx-b") + frames_before = len(self.connection.messages) + + def _boom(): + raise RuntimeError("no session context bound") + + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(_boom), + ): + with self.assertRaises(RuntimeError): + adapter_module.T3PlatformAdapter._gateway_session_key() + + # The real accessor swallows its own failures rather than propagating. + with unittest.mock.patch.dict(sys.modules, {"gateway.session_context": None}): + self.assertEqual(self.adapter._gateway_session_key(), "") + self.adapter.emit_tool_started( + "20260725_143012_ab12cd34", "read_file", {"path": "a.py"}, "call-4" + ) + await asyncio.sleep(0) + self.assertEqual(self.connection.messages[frames_before:], []) + + async def test_status_line_closes_before_the_assistant_message(self): + """The status item must complete BEFORE the terminal assistant message. + + T3 folds a settled turn's activity behind the "Worked for …" row, but + only entries that precede the turn's terminal assistant message. + Completing the status item afterwards stamped it milliseconds later, so + it sorted below the answer, escaped the fold, and rendered as a stray + "Work Log" section under the reply. + """ + await self._start_turn("thread-order", "turn-order") + turn = self.adapter._active_turns["thread-order"] + await self.adapter._emit_generic_activity(turn, "Reading repository") + await self.adapter.send("thread-order", "The answer") + order_start = len(self.connection.messages) + + await self.adapter.send("thread-order", "The answer", metadata={"notify": True}) + + completions = [ + message + for message in self.connection.messages[order_start:] + if message["type"] == "item.completed" + ] + self.assertEqual( + [message["itemType"] for message in completions], + ["status_text", "assistant_message"], + ) + types_after = [m["type"] for m in self.connection.messages[order_start:]] + self.assertEqual(types_after[-2:], ["turn.completed", "connection.status"]) + + async def test_cron_tool_hooks_are_excluded_from_the_sole_turn_fallback(self): + """A cron job's tool calls must never land in an unrelated live turn. + + The hooks are process-global, so a cron job running tools while exactly + one T3 turn happens to be active would otherwise resolve through the + sole-active-turn fallback and paint its tool rows into a conversation + it has nothing to do with. Cron runs are identifiable by the + `cron__` session id the scheduler mints + (`cron/scheduler.py:3017`); their activity belongs to the eventual + `home.deliver`, not to any turn. + """ + self.adapter._event_loop = asyncio.get_running_loop() + await self._start_turn("thread-live", "turn-live") + frames_before = len(self.connection.messages) + cron_session = "cron_daily-digest_20260726_090000" + + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(lambda: ""), + ): + self.adapter.emit_tool_started( + cron_session, "web_search", {"query": "weather"}, "cron-call-1" + ) + self.adapter.emit_tool_completed( + cron_session, "web_search", "sunny", 12, "cron-call-1" + ) + await asyncio.sleep(0) + + self.assertEqual(self.connection.messages[frames_before:], []) + # The unrelated turn is untouched and still streaming. + self.assertIn("thread-live", self.adapter._active_turns) + + # A genuine gateway run id still takes the fallback — the exclusion is + # scoped to cron, not a blanket removal of the fallback. + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(lambda: ""), + ): + self.adapter.emit_tool_started( + "20260726_090000_ab12cd34", "read_file", {"path": "a.py"}, "call-9" + ) + await asyncio.sleep(0) + fallback_frames = self.connection.messages[frames_before:] + self.assertEqual([m["type"] for m in fallback_frames], ["item.started"]) + self.assertEqual(fallback_frames[0]["threadId"], "thread-live") + + async def test_a_failed_turn_start_leaves_no_phantom_turn_behind(self): + """A turn that never started must not wedge its thread forever. + + `_active_turns[thread_id]` is registered before `turn.started` goes out, + so a socket that drops in that window used to leave an entry no + completion path could ever reach — and the duplicate-turn guard then + rejected every future `turn.start` on that thread for the life of the + process. One dropped frame permanently silenced the thread. + """ + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": "ensure-wedged", + "threadId": "thread-wedged", + } + ) + session_id = self.adapter._sessions["thread-wedged"] + + original_send = self.connection.send + + async def drop_the_turn_started(message): + if message.get("type") == "turn.started": + raise ConnectionError("socket dropped mid-handshake") + await original_send(message) + + with unittest.mock.patch.object( + self.connection, "send", drop_the_turn_started + ): + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-wedged", + "threadId": "thread-wedged", + "sessionId": session_id, + "turnId": "turn-wedged", + "text": "Start", + } + ) + + # Rolled back, and the failure was reported against its own request. + self.assertEqual(self.adapter._active_turns, {}) + self.assertEqual(self.connection.messages[-1]["type"], "protocol.error") + self.assertEqual(self.connection.messages[-1]["requestId"], "start-wedged") + + # The thread is usable again on the very next attempt. + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-recovered", + "threadId": "thread-wedged", + "sessionId": session_id, + "turnId": "turn-recovered", + "text": "Try again", + } + ) + self.assertEqual( + self.adapter._active_turns["thread-wedged"].turn_id, "turn-recovered" + ) + self.assertEqual(self.adapter.messages[-1].text, "Try again") + + +class HomeDeliveryTests(unittest.IsolatedAsyncioTestCase): + """The proactive `home.deliver` branch and its durable queue.""" + + HOME = "home-thread" + + async def asyncSetUp(self): + self.adapter = adapter_module.T3PlatformAdapter( + PlatformConfig( + extra={ + "url": "wss://t3.example/api/hermes-gateway/ws", + "instance_id": "instance", + "credential": "credential", + } + ) + ) + self.connection = FakeConnection() + self.adapter._connection = self.connection + self.adapter._gateway_interactive_turns_enabled = True + + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl" + self.queue = home_module.HomeDeliveryQueue(path=queue_file) + self.adapter._home_queue = self.queue + + environment = unittest.mock.patch.dict( + adapter_module.os.environ, + {home_module.HOME_CHANNEL_ENV: self.HOME}, + ) + environment.start() + self.addCleanup(environment.stop) + + # No Hermes session context is bound in tests, so the real accessors + # would fall through to os.environ. Pin them to "no session" — the + # state a cron run or a lifecycle broadcast is actually in. + for name in ("_gateway_session_key", "_session_user_id"): + patch = unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, name, staticmethod(lambda: "") + ) + patch.start() + self.addCleanup(patch.stop) + + async def _start_turn(self, thread_id: str, turn_id: str) -> str: + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": f"ensure-{thread_id}", + "threadId": thread_id, + } + ) + session_id = self.adapter._sessions[thread_id] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": f"start-{thread_id}", + "threadId": thread_id, + "sessionId": session_id, + "turnId": turn_id, + "text": "Start", + } + ) + return session_id + + async def test_a_proactive_send_to_home_emits_home_deliver(self): + frames_before = len(self.connection.messages) + + result = await self.adapter.send( + self.HOME, + "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.", + metadata={"notify": True, "job_id": "nightly"}, + ) + + frames = self.connection.messages[frames_before:] + self.assertEqual([frame["type"] for frame in frames], ["home.deliver"]) + delivery = frames[0] + self.assertEqual(delivery["protocolVersion"], 4) + self.assertEqual(delivery["threadId"], self.HOME) + self.assertEqual(delivery["kind"], "cron") + self.assertEqual(delivery["label"], "Cron: nightly") + self.assertTrue(delivery["createdAt"].endswith("Z")) + self.assertTrue(result.success) + self.assertEqual(result.message_id, delivery["deliveryId"]) + + # No turn was invented, and none was completed. + self.assertEqual(self.adapter._active_turns, {}) + + async def test_a_delivery_never_emits_turn_or_item_frames(self): + frames_before = len(self.connection.messages) + await self.adapter.send(self.HOME, "♻️ Gateway online — Hermes is back and ready.") + emitted = {frame["type"] for frame in self.connection.messages[frames_before:]} + self.assertEqual(emitted, {"home.deliver"}) + self.assertEqual(self.adapter._active_turns, {}) + + async def test_public_handoff_callback_creates_and_routes_to_a_t3_thread(self): + create = asyncio.create_task( + self.adapter.create_handoff_thread(self.HOME, "Hermes — shipping") + ) + await asyncio.sleep(0) + request = self.connection.messages[-1] + self.assertEqual(request["type"], "handoff.create") + self.assertEqual(request["parentThreadId"], self.HOME) + self.assertEqual(request["name"], "Hermes — shipping") + + await self.adapter._handle_server_frame( + { + "type": "handoff.created", + "protocolVersion": 4, + "requestId": request["requestId"], + "threadId": "handoff-thread", + } + ) + self.assertEqual(await create, "handoff-thread") + self.assertEqual(self.adapter._pending_handoff_creates, {}) + + frames_before = len(self.connection.messages) + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_session_user_id", + staticmethod(lambda: "system:handoff"), + ): + result = await self.adapter.send( + self.HOME, + "The CLI session is ready here.", + metadata={"thread_id": "handoff-thread", "notify": True}, + ) + self.assertTrue(result.success) + delivery = self.connection.messages[frames_before] + self.assertEqual(delivery["type"], "home.deliver") + self.assertEqual(delivery["kind"], "handoff") + self.assertEqual(delivery["threadId"], "handoff-thread") + + async def test_handoff_protocol_error_returns_official_home_fallback(self): + create = asyncio.create_task( + self.adapter.create_handoff_thread(self.HOME, "Unavailable") + ) + await asyncio.sleep(0) + request = self.connection.messages[-1] + with self.assertLogs(adapter_module.logger, level="WARNING"): + await self.adapter._handle_server_frame( + { + "type": "protocol.error", + "protocolVersion": 4, + "requestId": request["requestId"], + "code": "unsupported-message", + "message": "Upgrade T3", + "recoverable": True, + } + ) + self.assertIsNone(await create) + self.assertEqual(self.adapter._pending_handoff_creates, {}) + + async def test_reconnect_releases_pending_handoff_without_a_deadlock(self): + create = asyncio.create_task( + self.adapter.create_handoff_thread(self.HOME, "Reconnect") + ) + await asyncio.sleep(0) + request = self.connection.messages[-1] + + await self.adapter._handle_connection_state(False, "socket replaced") + self.assertIsNone(await asyncio.wait_for(create, timeout=0.1)) + self.assertEqual(self.adapter._pending_handoff_creates, {}) + + # A response from the fenced socket is late and inert; it cannot + # recreate a pending entry or resolve a newer request accidentally. + self.adapter._resolve_handoff_create( + { + "requestId": request["requestId"], + "threadId": "late-thread", + } + ) + self.assertEqual(self.adapter._pending_handoff_creates, {}) + + async def test_handoff_timeout_cleans_up_the_pending_request(self): + with unittest.mock.patch.object( + adapter_module, + "_HANDOFF_CREATE_TIMEOUT_SECONDS", + 0.001, + ): + with self.assertLogs(adapter_module.logger, level="WARNING"): + result = await self.adapter.create_handoff_thread(self.HOME, "Timeout") + self.assertIsNone(result) + self.assertEqual(self.adapter._pending_handoff_creates, {}) + + async def test_a_notify_stamped_delivery_does_not_complete_the_live_turn(self): + """THE deadlock regression. + + A cron delivery landing in Home while the user has a live turn there + arrives notify-stamped (`_mark_notify_metadata`, + `gateway/platforms/base.py:89`). Under a naive "no active turn → + deliver" gate it would fall into the active-turn path, stream as that + turn's assistant content, and its notify stamp would COMPLETE the + user's turn with the cron output as the answer. The gate is provenance, + not turn absence: the cron send does not carry the turn's session key, + so it becomes a `home.deliver` and the turn keeps running. + """ + session_id = await self._start_turn(self.HOME, "turn-user") + # The user's turn has already streamed some of its real answer. + await self.adapter.send(self.HOME, "Working on it") + frames_before = len(self.connection.messages) + + # A cron delivery fires mid-turn, notify-stamped as every final cron + # delivery is. + result = await self.adapter.send( + self.HOME, + "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.", + metadata={"notify": True, "job_id": "nightly"}, + ) + + frames = self.connection.messages[frames_before:] + self.assertEqual([frame["type"] for frame in frames], ["home.deliver"]) + self.assertTrue(result.success) + + # The user's turn is untouched: still active, still owning its stream. + self.assertIn(self.HOME, self.adapter._active_turns) + turn = self.adapter._active_turns[self.HOME] + self.assertEqual(turn.turn_id, "turn-user") + self.assertEqual(turn.visible_text, "Working on it") + self.assertNotIn( + "turn.completed", [frame["type"] for frame in self.connection.messages] + ) + + # …and it still completes normally on its own notify, inside its own + # session context. + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(lambda: session_id), + ): + await self.adapter.send( + self.HOME, "Here is the answer", metadata={"notify": True} + ) + self.assertNotIn(self.HOME, self.adapter._active_turns) + self.assertIn( + "turn.completed", [frame["type"] for frame in self.connection.messages] + ) + + async def test_a_turn_reply_in_home_is_never_rerouted_to_a_delivery(self): + """Output produced inside the turn's session context is turn content.""" + session_id = await self._start_turn(self.HOME, "turn-user") + frames_before = len(self.connection.messages) + + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(lambda: session_id), + ): + await self.adapter.send(self.HOME, "Streaming answer") + + types = [frame["type"] for frame in self.connection.messages[frames_before:]] + self.assertEqual(types, ["item.started", "content.delta"]) + self.assertNotIn("home.deliver", types) + + async def test_an_unclassifiable_send_during_a_live_home_turn_stays_with_it(self): + """The conservative half of the gate. + + With a live turn in Home and no positive provenance, the send may well + be that turn's own output arriving from a context where the session key + did not resolve. Routing it to `home.deliver` would tear a real answer + out of the turn; leaving it with the turn is at worst a misplacement + inside the same thread. + """ + await self._start_turn(self.HOME, "turn-user") + frames_before = len(self.connection.messages) + + await self.adapter.send(self.HOME, "Something unclassifiable") + + types = [frame["type"] for frame in self.connection.messages[frames_before:]] + self.assertEqual(types, ["item.started", "content.delta"]) + self.assertIn(self.HOME, self.adapter._active_turns) + + async def test_a_non_home_thread_without_a_turn_still_errors(self): + """"Message any thread unprompted" stays out of scope.""" + frames_before = len(self.connection.messages) + + result = await self.adapter.send( + "some-other-thread", + "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.", + metadata={"notify": True, "job_id": "nightly"}, + ) + + self.assertFalse(result.success) + self.assertEqual(result.error, "no active T3 turn") + self.assertEqual(self.connection.messages[frames_before:], []) + + async def test_no_designated_home_means_no_proactive_delivery(self): + """Before the first `connection.accepted` there is nowhere to deliver.""" + with unittest.mock.patch.dict( + adapter_module.os.environ, {home_module.HOME_CHANNEL_ENV: ""} + ): + result = await self.adapter.send(self.HOME, "Nowhere to go") + self.assertFalse(result.success) + self.assertEqual(result.error, "no active T3 turn") + + async def test_edit_message_has_no_proactive_branch(self): + """A delivery is an atomic document, not a streaming surface.""" + result = await self.adapter.edit_message( + self.HOME, "some-message", "Revised delivery", finalize=True + ) + self.assertFalse(result.success) + self.assertEqual(result.error, "no active T3 turn") + self.assertEqual( + [frame["type"] for frame in self.connection.messages], [] + ) + + async def test_a_delivery_is_queued_before_it_is_sent_and_purged_on_ack(self): + result = await self.adapter.send(self.HOME, "Queued then acked") + delivery_id = result.message_id + self.assertEqual( + [entry["deliveryId"] for entry in self.queue.entries()], [delivery_id] + ) + + await self.adapter._handle_server_frame( + { + "type": "home.deliver.ack", + "protocolVersion": 4, + "deliveryId": delivery_id, + } + ) + self.assertEqual(self.queue.entries(), []) + + async def test_delivery_queue_io_runs_off_the_gateway_event_loop(self): + event_loop_thread = threading.get_ident() + + def append_in_worker(frame): + self.assertNotEqual(threading.get_ident(), event_loop_thread) + return True + + with unittest.mock.patch.object( + self.queue, "append", side_effect=append_in_worker + ): + result = await self.adapter.send(self.HOME, "Non-blocking queue write") + + self.assertTrue(result.success) + self.assertEqual(self.connection.messages[0]["type"], "home.deliver") + + async def test_a_delivery_survives_a_dead_socket_and_flushes_on_reconnect(self): + """Offline delivery: nothing is lost across either side restarting.""" + + class DeadConnection: + connected = False + + async def send(self, message): + raise ConnectionError("T3 Code gateway is offline") + + self.adapter._connection = DeadConnection() + with self.assertLogs(adapter_module.logger, level="WARNING"): + offline = await self.adapter.send(self.HOME, "Sent while offline") + # Reported successful: it is durably queued and WILL arrive, so a cron + # job must not log a failure for it. + self.assertTrue(offline.success) + self.assertEqual( + [entry["text"] for entry in self.queue.entries()], ["Sent while offline"] + ) + + # Reconnect: the accepted frame reconciles the designation and flushes. + self.adapter._connection = self.connection + await self.adapter._handle_connection_accepted( + { + "type": "connection.accepted", + "protocolVersion": 4, + "requestId": "hello-1", + "instanceId": "instance", + "nickname": "Hermes", + "homeThreadId": self.HOME, + } + ) + + flushed = self.connection.messages + self.assertEqual([frame["type"] for frame in flushed], ["home.deliver"]) + self.assertEqual(flushed[0]["text"], "Sent while offline") + self.assertEqual(flushed[0]["deliveryId"], offline.message_id) + # Still queued — only the ack purges it. + self.assertEqual(len(self.queue.entries()), 1) + + await self.adapter._handle_server_frame( + { + "type": "home.deliver.ack", + "protocolVersion": 4, + "deliveryId": offline.message_id, + } + ) + self.assertEqual(self.queue.entries(), []) + + async def test_a_delivery_that_is_neither_queued_nor_sent_reports_failure(self): + """Success is a claim about durability, so it needs one leg to hold. + + A queue write that failed used to be ignored: an offline socket then + produced "delivered" for content held nowhere, and the cron job that + wrote it logged a success for output that will never appear. + """ + + class DeadConnection: + connected = False + + async def send(self, message): + raise ConnectionError("T3 Code gateway is offline") + + self.adapter._connection = DeadConnection() + with unittest.mock.patch.object( + self.queue, "append", return_value=False + ), self.assertLogs(adapter_module.logger, level="WARNING"): + result = await self.adapter.send(self.HOME, "Held nowhere at all") + + self.assertFalse(result.success) + self.assertIn("queued", result.error) + + async def test_a_delivery_that_reached_t3_is_honest_success_unqueued(self): + """T3 has it; the ack will simply find nothing to purge.""" + with unittest.mock.patch.object(self.queue, "append", return_value=False): + result = await self.adapter.send(self.HOME, "Sent but not queued") + + self.assertTrue(result.success) + self.assertEqual( + [frame["type"] for frame in self.connection.messages], ["home.deliver"] + ) + self.assertEqual(self.queue.entries(), []) + + async def test_flush_restamps_stale_protocol_versions(self): + """A frame queued under an older plugin must not wedge the reconnect. + + T3's strict-lockstep decoder closes the socket on any frame carrying a + different protocolVersion, so a v3-era queued delivery would otherwise + turn one stale outbox entry into a reconnect loop that outlives the + upgrade. The flush restamps to the current version; the delivery + fields themselves are version-stable. + """ + stale = protocol_module.home_deliver( + thread_id=self.HOME, + text="Queued before the upgrade", + kind="cron", + label="Cron: nightly", + delivery_id_value="stale-v3-delivery", + ) + stale["protocolVersion"] = 3 + self.assertTrue(self.queue.append(stale)) + + await self.adapter._flush_home_queue() + + flushed = self.connection.messages + self.assertEqual(len(flushed), 1) + self.assertEqual(flushed[0]["protocolVersion"], protocol_module.PROTOCOL_VERSION) + self.assertEqual(flushed[0]["text"], "Queued before the upgrade") + # The queued copy is untouched — restamping happens on the wire only, + # and the entry still purges by deliveryId on ack. + self.assertEqual(self.queue.entries()[0]["protocolVersion"], 3) + + async def test_the_queue_flushes_in_fifo_order(self): + class DeadConnection: + connected = False + + async def send(self, message): + raise ConnectionError("T3 Code gateway is offline") + + self.adapter._connection = DeadConnection() + with self.assertLogs(adapter_module.logger, level="WARNING"): + for text in ("first", "second", "third"): + await self.adapter.send(self.HOME, text) + + self.adapter._connection = self.connection + await self.adapter._flush_home_queue() + + self.assertEqual( + [frame["text"] for frame in self.connection.messages], + ["first", "second", "third"], + ) + + async def test_connection_accepted_reconciles_the_home_designation(self): + """T3 owns the designation; a differing local value is overwritten.""" + with unittest.mock.patch.dict( + adapter_module.os.environ, + {home_module.HOME_CHANNEL_ENV: "stale-hand-edited-thread"}, + ), unittest.mock.patch.object( + adapter_module, "save_home_thread_id" + ) as save: + await self.adapter._handle_connection_accepted( + { + "type": "connection.accepted", + "protocolVersion": 4, + "requestId": "hello-1", + "instanceId": "instance", + "nickname": "Hermes", + "homeThreadId": "authoritative-thread", + } + ) + save.assert_called_once_with("authoritative-thread") + + async def test_an_accepted_frame_without_a_home_thread_changes_nothing(self): + """Resolving the home thread must never fail a handshake.""" + with unittest.mock.patch.object( + adapter_module, "save_home_thread_id" + ) as save: + await self.adapter._handle_connection_accepted( + { + "type": "connection.accepted", + "protocolVersion": 4, + "requestId": "hello-1", + "instanceId": "instance", + "nickname": "Hermes", + } + ) + save.assert_not_called() + self.assertEqual(adapter_module.home_thread_id(), self.HOME) + + async def test_a_nameless_ack_is_a_correlated_protocol_error(self): + await self.adapter._handle_server_frame( + {"type": "home.deliver.ack", "protocolVersion": 4, "requestId": "ack-1"} + ) + reply = self.connection.messages[-1] + self.assertEqual(reply["type"], "protocol.error") + self.assertEqual(reply["code"], "unsupported-message") + + +class InboundAttachmentTests(unittest.IsolatedAsyncioTestCase): + """v4 turn attachments: base64 on the frame → temp files → media_urls.""" + + async def asyncSetUp(self): + self.adapter = adapter_module.T3PlatformAdapter( + PlatformConfig( + extra={ + "url": "wss://t3.example/api/hermes-gateway/ws", + "instance_id": "instance", + "credential": "credential", + } + ) + ) + self.connection = FakeConnection() + self.adapter._connection = self.connection + self.adapter._gateway_interactive_turns_enabled = True + + async def _ensure(self, thread_id: str) -> str: + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": f"ensure-{thread_id}", + "threadId": thread_id, + } + ) + return self.adapter._sessions[thread_id] + + async def test_turn_attachments_land_as_local_files_on_the_message_event(self): + import base64 + + session_id = await self._ensure("thread-attach") + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-attach", + "threadId": "thread-attach", + "sessionId": session_id, + "turnId": "turn-attach", + "text": "Describe this image", + "attachments": [ + { + "name": "photo.png", + "mimeType": "image/png", + "sizeBytes": 9, + "data": base64.b64encode(b"PNG bytes").decode("ascii"), + }, + { + "name": "notes.txt", + "mimeType": "text/plain", + "sizeBytes": 5, + "data": base64.b64encode(b"hello").decode("ascii"), + }, + ], + } + ) + + event = self.adapter.messages[-1] + self.assertEqual(event.text, "Describe this image") + # Aligned pairs, exactly the shape Hermes' enrichment pipeline reads. + self.assertEqual(event.media_types, ["image/png", "text/plain"]) + self.assertEqual(len(event.media_urls), 2) + for path, payload in zip(event.media_urls, [b"PNG bytes", b"hello"]): + self.addCleanup( + lambda p=path: pathlib.Path(p).unlink(missing_ok=True) + ) + self.assertEqual(pathlib.Path(path).read_bytes(), payload) + # Secure perms: owner-only file in an owner-only directory. + self.assertEqual(pathlib.Path(path).stat().st_mode & 0o777, 0o600) + self.assertEqual( + pathlib.Path(path).parent.stat().st_mode & 0o777, 0o700 + ) + # The extension survives — Hermes routes files by suffix in several + # places (audio-vs-document, the text-document allowlist). + self.assertTrue(event.media_urls[0].endswith(".png")) + self.assertTrue(event.media_urls[1].endswith(".txt")) + + async def test_a_hostile_attachment_name_cannot_escape_the_temp_directory(self): + import base64 + + session_id = await self._ensure("thread-hostile") + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-hostile", + "threadId": "thread-hostile", + "sessionId": session_id, + "turnId": "turn-hostile", + "text": "Look at this", + "attachments": [ + { + "name": "../../etc/passwd", + "mimeType": "text/plain", + "sizeBytes": 4, + "data": base64.b64encode(b"evil").decode("ascii"), + } + ], + } + ) + event = self.adapter.messages[-1] + path = pathlib.Path(event.media_urls[0]) + self.addCleanup(lambda: path.unlink(missing_ok=True)) + self.assertTrue( + path.parent.name.startswith("hermes-t3-attachments-"), + path, + ) + self.assertNotIn("..", path.name) + + async def test_a_turn_without_attachments_carries_no_media(self): + session_id = await self._ensure("thread-plain") + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-plain", + "threadId": "thread-plain", + "sessionId": session_id, + "turnId": "turn-plain", + "text": "Just text", + } + ) + event = self.adapter.messages[-1] + self.assertEqual(event.media_urls, []) + self.assertEqual(event.media_types, []) + + async def test_a_malformed_attachment_errors_before_any_turn_starts(self): + session_id = await self._ensure("thread-bad-attach") + frames_before = len(self.connection.messages) + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-bad", + "threadId": "thread-bad-attach", + "sessionId": session_id, + "turnId": "turn-bad", + "text": "With a broken file", + "attachments": [{"name": "x.bin", "data": "!!! not base64 !!!"}], + } + ) + frames = self.connection.messages[frames_before:] + self.assertEqual([frame["type"] for frame in frames], ["protocol.error"]) + self.assertEqual(frames[0]["requestId"], "start-bad") + # No half-started turn to clean up, and nothing reached Hermes. + self.assertNotIn("thread-bad-attach", self.adapter._active_turns) + self.assertEqual( + [event.text for event in self.adapter.messages + if getattr(event, "message_id", "") == "start-bad"], + [], + ) + + async def test_steer_attachments_ride_the_injected_text_as_path_notes(self): + import base64 + + session_id = await self._ensure("thread-steer-attach") + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": "start-steer-attach", + "threadId": "thread-steer-attach", + "sessionId": session_id, + "turnId": "turn-steer-attach", + "text": "Start", + } + ) + + async def accept_steer(_event): + return "⏩ Steer queued — arrives after the next tool call" + + self.adapter._message_handler = accept_steer + await self.adapter._handle_server_frame( + { + "type": "turn.steer", + "protocolVersion": 4, + "requestId": "steer-attach", + "threadId": "thread-steer-attach", + "sessionId": session_id, + "turnId": "turn-steer-attach", + "text": "Use this file", + "attachments": [ + { + "name": "data.csv", + "mimeType": "text/csv", + "sizeBytes": 3, + "data": base64.b64encode(b"a,b").decode("ascii"), + } + ], + } + ) + steer_event = self.adapter.messages[-1] + # Hermes' /steer handler injects only text between tool iterations + # (`gateway/run.py:11254`), so the file rides the command as a path + # note the mid-turn agent can open with its tools. + self.assertTrue(steer_event.text.startswith("/steer Use this file\n")) + self.assertIn("[The user attached a file (text/csv): ", steer_event.text) + path = steer_event.text.rsplit(": ", 1)[1].rstrip("]") + self.addCleanup(lambda: pathlib.Path(path).unlink(missing_ok=True)) + self.assertEqual(pathlib.Path(path).read_bytes(), b"a,b") + + +class MediaDeliveryTests(unittest.IsolatedAsyncioTestCase): + """Outbound `media.deliver`: turn scoping plus the durable ack lifecycle.""" + + HOME = "home-thread" + + async def asyncSetUp(self): + self.adapter = adapter_module.T3PlatformAdapter( + PlatformConfig( + extra={ + "url": "wss://t3.example/api/hermes-gateway/ws", + "instance_id": "instance", + "credential": "credential", + } + ) + ) + self.connection = FakeConnection() + self.adapter._connection = self.connection + self.adapter._gateway_interactive_turns_enabled = True + + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl" + self.queue = home_module.HomeDeliveryQueue(path=queue_file) + self.adapter._home_queue = self.queue + + self.chart = pathlib.Path(self._tmp.name) / "chart.png" + self.chart.write_bytes(b"\x89PNG fake bytes") + + environment = unittest.mock.patch.dict( + adapter_module.os.environ, + {home_module.HOME_CHANNEL_ENV: self.HOME}, + ) + environment.start() + self.addCleanup(environment.stop) + + for name in ("_gateway_session_key", "_session_user_id"): + patch = unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, name, staticmethod(lambda: "") + ) + patch.start() + self.addCleanup(patch.stop) + + async def _start_turn(self, thread_id: str, turn_id: str) -> str: + await self.adapter._handle_server_frame( + { + "type": "session.ensure", + "protocolVersion": 4, + "requestId": f"ensure-{thread_id}", + "threadId": thread_id, + } + ) + session_id = self.adapter._sessions[thread_id] + await self.adapter._handle_server_frame( + { + "type": "turn.start", + "protocolVersion": 4, + "requestId": f"start-{thread_id}", + "threadId": thread_id, + "sessionId": session_id, + "turnId": turn_id, + "text": "Start", + } + ) + return session_id + + async def test_turn_media_is_delivered_turn_scoped(self): + await self._start_turn("thread-media", "turn-media") + frames_before = len(self.connection.messages) + + result = await self.adapter.send_image_file( + "thread-media", str(self.chart), caption="A chart" + ) + + frames = self.connection.messages[frames_before:] + self.assertEqual([frame["type"] for frame in frames], ["media.deliver"]) + delivery = frames[0] + self.assertEqual(delivery["protocolVersion"], 4) + self.assertEqual(delivery["threadId"], "thread-media") + self.assertEqual(delivery["turnId"], "turn-media") + self.assertEqual(delivery["name"], "chart.png") + self.assertEqual(delivery["mimeType"], "image/png") + self.assertEqual(delivery["caption"], "A chart") + self.assertTrue(result.success) + self.assertEqual(result.message_id, delivery["deliveryId"]) + # Media never touches the turn machinery: the turn is still live and + # no turn/item frame was emitted for the file. + self.assertIn("thread-media", self.adapter._active_turns) + + async def test_reply_media_arriving_just_after_completion_keeps_its_turn(self): + """The base adapter sends a reply's text BEFORE its media files + (`gateway/platforms/base.py:5326` then `:5383+`), and the notify-marked + text completes the T3 turn — so a reply's chart routinely arrives + moments after its turn closed and must still land turn-scoped.""" + session_id = await self._start_turn("thread-late-media", "turn-late") + with unittest.mock.patch.object( + adapter_module.T3PlatformAdapter, + "_gateway_session_key", + staticmethod(lambda: session_id), + ): + await self.adapter.send( + "thread-late-media", "Here is the chart", metadata={"notify": True} + ) + self.assertNotIn("thread-late-media", self.adapter._active_turns) + frames_before = len(self.connection.messages) + + result = await self.adapter.send_image_file( + "thread-late-media", str(self.chart) + ) + + self.assertTrue(result.success) + delivery = self.connection.messages[frames_before:][0] + self.assertEqual(delivery["type"], "media.deliver") + self.assertEqual(delivery["turnId"], "turn-late") + + async def test_live_repro_reply_media_lands_with_no_session_key_bound(self): + """The 2026-07-27 18:47:06 gateway.log repro, end to end. + + An ordinary (non-home) thread asks for an image. Upstream's delivery + pipeline sends the reply's notify-marked TEXT — completing the T3 turn + through the real completion path — and 36ms later dispatches the file. + + The session context is modelled as it ACTUALLY is at that moment: + UNAVAILABLE. `HERMES_SESSION_KEY` is bound inside + `_handle_message_with_agent` and cleared in its own `finally` + (`gateway/run.py:12972` → `:14626`), while this whole delivery block + runs one frame further out in + `BasePlatformAdapter._process_message_background`, after the handler + returned — and `clear_session_vars` sets `""` rather than resetting, so + the `os.environ` fallback is suppressed too. Every send here reads `""`. + + That is why the file was dropped with "no active T3 turn": the text + path never consults the key when a live turn exists, but the media path + required it to match. The class default `_gateway_session_key` stub + (`lambda: ""`) is exactly this state — no per-test patch. + """ + thread = "3667b0a1-c1db-4216-8e72-2f62a3ff87e2" + await self._start_turn(thread, "turn-live-repro") + + # The reply's final text. notify=True is what upstream stamps via + # `_mark_notify_metadata`, and it completes the turn for real. + text_result = await self.adapter.send( + thread, + "Here's the image you asked for.", + metadata={"thread_id": thread, "notify": True}, + ) + self.assertTrue(text_result.success) + self.assertNotIn(thread, self.adapter._active_turns) + completed = [ + frame + for frame in self.connection.messages + if frame["type"] == "turn.completed" + ] + self.assertEqual([frame["turnId"] for frame in completed], ["turn-live-repro"]) + frames_before = len(self.connection.messages) + + # ~36ms later: the same reply's image, same metadata dict. + result = await self.adapter.send_image_file( + thread, + str(self.chart), + caption=None, + metadata={"thread_id": thread, "notify": True}, + ) + + self.assertTrue(result.success) + frames = self.connection.messages[frames_before:] + self.assertEqual([frame["type"] for frame in frames], ["media.deliver"]) + delivery = frames[0] + # Scoped to its own turn, in its own thread — not exiled to Home. + self.assertEqual(delivery["threadId"], thread) + self.assertEqual(delivery["turnId"], "turn-live-repro") + self.assertEqual(delivery["name"], "chart.png") + # The completed turn is not resurrected by claiming its media. + self.assertNotIn(thread, self.adapter._active_turns) + + async def test_an_image_only_reply_completes_its_turn(self): + """Live repro 2026-07-27 21:26: "send it one more time" → image, no text. + + Upstream notify-marks every send of a reply's final delivery batch — + text AND media (`_mark_notify_metadata`, base.py:5220) — but a reply + that is only an image produces no text send, so the media send is the + only carrier of the completion signal. Without honoring it, the turn + sat "Working" until the two-minute liveness timeout. + """ + thread = "thread-image-only-reply" + await self._start_turn(thread, "turn-image-only") + frames_before = len(self.connection.messages) + + result = await self.adapter.send_image_file( + thread, + str(self.chart), + caption=None, + metadata={"thread_id": thread, "notify": True}, + ) + + self.assertTrue(result.success) + frames = self.connection.messages[frames_before:] + # `_complete_turn` also republishes connection.status; the contract + # here is the ORDER media -> completed, not the exact frame set. + types = [frame["type"] for frame in frames] + self.assertEqual(types[:2], ["media.deliver", "turn.completed"]) + self.assertEqual(frames[0]["turnId"], "turn-image-only") + self.assertEqual(frames[1]["turnId"], "turn-image-only") + self.assertNotIn(thread, self.adapter._active_turns) + + async def test_trailing_media_does_not_recomplete_a_closed_turn(self): + """The text-then-media ordering must emit exactly one turn.completed. + + The text completes the turn; the file's own notify mark must not + re-complete the `_recent_turns` entry it scopes to — T3 already + folded the turn, and a second terminal frame names a turn its + conflict gate would reject. + """ + thread = "thread-text-then-media" + await self._start_turn(thread, "turn-text-media") + await self.adapter.send( + thread, "Here it is.", metadata={"thread_id": thread, "notify": True} + ) + frames_before = len(self.connection.messages) + + result = await self.adapter.send_image_file( + thread, + str(self.chart), + caption=None, + metadata={"thread_id": thread, "notify": True}, + ) + + self.assertTrue(result.success) + frames = self.connection.messages[frames_before:] + self.assertEqual([frame["type"] for frame in frames], ["media.deliver"]) + + async def test_media_long_after_a_turn_closed_does_not_claim_it(self): + """Recency is what bounds the reach-back, so an old turn must not claim. + + Without the window, `_recent_turns` would keep a thread's last turn + claimable forever and an unrelated later delivery would be sequenced + into an answer the user finished reading long ago. + """ + thread = "thread-stale-reachback" + await self._start_turn(thread, "turn-stale") + await self.adapter.send(thread, "Done.", metadata={"notify": True}) + self.assertNotIn(thread, self.adapter._active_turns) + + stale = self.adapter._recent_turns[thread] + stale.completed_at -= adapter_module._RECENT_TURN_MEDIA_WINDOW_SECONDS + 1 + frames_before = len(self.connection.messages) + + with self.assertLogs(adapter_module.logger, level="INFO"): + result = await self.adapter.send_document(thread, str(self.chart)) + + self.assertTrue(result.success) + delivery = self.connection.messages[frames_before:][0] + self.assertNotIn("turnId", delivery) + self.assertEqual(delivery["threadId"], self.HOME) + + async def test_a_cron_delivery_never_claims_a_just_closed_turn(self): + """Provenance still overrides recency inside the window. + + A positively-classified proactive send — cron here — is refused the + completed turn even microseconds after it closed, and takes the + turnless home route with its badge intact. This is the guard that + keeps the recency window from re-opening the defect class the + session-key gate was built for. + """ + thread = "thread-cron-collision" + await self._start_turn(thread, "turn-cron-collision") + await self.adapter.send(thread, "All set.", metadata={"notify": True}) + self.assertIsNotNone(self.adapter._recent_turns[thread].completed_at) + frames_before = len(self.connection.messages) + + with self.assertLogs(adapter_module.logger, level="INFO"): + result = await self.adapter.send_document( + thread, + str(self.chart), + caption="Cronjob Response: nightly\n-------------\n\nChart attached.", + ) + + self.assertTrue(result.success) + delivery = self.connection.messages[frames_before:][0] + self.assertNotIn("turnId", delivery) + self.assertEqual(delivery["threadId"], self.HOME) + self.assertEqual(delivery["kind"], "cron") + self.assertEqual(delivery["label"], "Cron: nightly") + + async def test_a_live_turn_still_outranks_a_completed_one(self): + """The user asked again; the new turn owns the thread, not the old one.""" + thread = "thread-relay" + await self._start_turn(thread, "turn-first") + await self.adapter.send(thread, "First answer.", metadata={"notify": True}) + await self._start_turn(thread, "turn-second") + frames_before = len(self.connection.messages) + + result = await self.adapter.send_image_file(thread, str(self.chart)) + + self.assertTrue(result.success) + delivery = self.connection.messages[frames_before:][0] + self.assertEqual(delivery["turnId"], "turn-second") + + async def test_proactive_media_to_home_is_turnless_with_provenance(self): + frames_before = len(self.connection.messages) + + result = await self.adapter.send_document( + self.HOME, + str(self.chart), + caption=( + "Cronjob Response: nightly\n(job_id: nightly)\n" + "-------------\n\nDone." + ), + ) + + frames = self.connection.messages[frames_before:] + self.assertEqual([frame["type"] for frame in frames], ["media.deliver"]) + delivery = frames[0] + self.assertNotIn("turnId", delivery) + self.assertEqual(delivery["kind"], "cron") + self.assertEqual(delivery["label"], "Cron: nightly") + self.assertTrue(result.success) + self.assertEqual(self.adapter._active_turns, {}) + + async def test_unscopeable_media_falls_back_to_home_instead_of_dropping(self): + """"Send media to any thread unprompted" still lands — in Home. + + The thread route stays out of scope: the frame goes out turnless, so + T3 re-resolves the home thread server-side and can write nowhere else. + But the file is NOT dropped. Upstream's only response to a failed + media send is a log line, so returning an error silently loses an + artifact Hermes already spent a generation call producing. + """ + with self.assertLogs(adapter_module.logger, level="INFO"): + result = await self.adapter.send_document( + "some-other-thread", str(self.chart) + ) + + self.assertTrue(result.success) + frames = self.connection.messages + self.assertEqual([frame["type"] for frame in frames], ["media.deliver"]) + delivery = frames[0] + # Home-addressed and turnless: it renders as a badged notification, + # never as a reply inside the thread that could not take it. + self.assertEqual(delivery["threadId"], self.HOME) + self.assertNotIn("turnId", delivery) + self.assertEqual(delivery["label"], "Hermes") + self.assertEqual( + [entry["deliveryId"] for entry in self.queue.entries()], + [result.message_id], + ) + + async def test_media_with_no_home_designated_still_errors(self): + """With nowhere to fall back to, the original error stands.""" + with unittest.mock.patch.dict( + adapter_module.os.environ, {home_module.HOME_CHANNEL_ENV: ""} + ): + result = await self.adapter.send_document( + "some-other-thread", str(self.chart) + ) + self.assertFalse(result.success) + self.assertEqual(result.error, "no active T3 turn") + self.assertEqual(self.connection.messages, []) + self.assertEqual(self.queue.entries(), []) + + async def test_media_is_queued_before_it_is_sent_and_purged_only_on_ack(self): + result = await self.adapter.send_video(self.HOME, str(self.chart)) + delivery_id = result.message_id + self.assertEqual( + [entry["deliveryId"] for entry in self.queue.entries()], [delivery_id] + ) + + # A home.deliver.ack for some OTHER delivery purges nothing. + await self.adapter._handle_server_frame( + { + "type": "media.deliver.ack", + "protocolVersion": 4, + "deliveryId": "unrelated", + } + ) + self.assertEqual(len(self.queue.entries()), 1) + + await self.adapter._handle_server_frame( + { + "type": "media.deliver.ack", + "protocolVersion": 4, + "deliveryId": delivery_id, + } + ) + self.assertEqual(self.queue.entries(), []) + + async def test_queued_media_survives_a_dead_socket_and_flushes_on_reconnect(self): + class DeadConnection: + connected = False + + async def send(self, message): + raise ConnectionError("T3 Code gateway is offline") + + self.adapter._connection = DeadConnection() + with self.assertLogs(adapter_module.logger, level="WARNING"): + offline = await self.adapter.send_document(self.HOME, str(self.chart)) + # Reported successful: durably queued, WILL arrive. + self.assertTrue(offline.success) + self.assertEqual(len(self.queue.entries()), 1) + + self.adapter._connection = self.connection + await self.adapter._handle_connection_accepted( + { + "type": "connection.accepted", + "protocolVersion": 4, + "requestId": "hello-1", + "instanceId": "instance", + "nickname": "Hermes", + "homeThreadId": self.HOME, + } + ) + flushed = self.connection.messages + self.assertEqual([frame["type"] for frame in flushed], ["media.deliver"]) + self.assertEqual(flushed[0]["deliveryId"], offline.message_id) + self.assertEqual(flushed[0]["name"], "chart.png") + # Still queued — only the ack purges it. + self.assertEqual(len(self.queue.entries()), 1) + + await self.adapter._handle_server_frame( + { + "type": "media.deliver.ack", + "protocolVersion": 4, + "deliveryId": offline.message_id, + } + ) + self.assertEqual(self.queue.entries(), []) + + async def test_an_unreadable_file_fails_the_send_and_queues_nothing(self): + """A frame T3 would reject forever must never enter the outbox.""" + await self._start_turn("thread-bad-file", "turn-bad-file") + with self.assertLogs(adapter_module.logger, level="WARNING"): + result = await self.adapter.send_image_file( + "thread-bad-file", str(pathlib.Path(self._tmp.name) / "gone.png") + ) + self.assertFalse(result.success) + self.assertEqual(self.queue.entries(), []) + self.assertNotIn( + "media.deliver", + [frame["type"] for frame in self.connection.messages], + ) + + async def test_media_that_is_neither_queued_nor_sent_reports_failure(self): + """An unpersisted delivery must not be reported as durable. + + Success here used to be unconditional on the queue write, so a full + disk plus a dead socket produced "delivered" for a file that exists + nowhere — and, on a notify-marked send, completed the turn on it. The + bytes are the only copy: Hermes' temp file is reaped and nothing can + replay a frame that was never written. + """ + + class DeadConnection: + connected = False + + async def send(self, message): + raise ConnectionError("T3 Code gateway is offline") + + thread = "thread-nowhere-to-go" + await self._start_turn(thread, "turn-nowhere") + self.adapter._connection = DeadConnection() + + with unittest.mock.patch.object( + self.queue, "append", return_value=False + ), self.assertLogs(adapter_module.logger, level="WARNING"): + result = await self.adapter.send_image_file( + thread, + str(self.chart), + metadata={"thread_id": thread, "notify": True}, + ) + + self.assertFalse(result.success) + self.assertIn("queued", result.error) + # The turn is NOT completed on media that went nowhere. + self.assertIn(thread, self.adapter._active_turns) + + async def test_media_that_reached_t3_is_honest_success_without_the_queue(self): + """The other branch: the live send held, so T3 has the file. + + The queue's only remaining job would be a replay T3 does not need, and + the ack simply finds nothing to purge. + """ + thread = "thread-sent-not-queued" + await self._start_turn(thread, "turn-sent-not-queued") + frames_before = len(self.connection.messages) + + with unittest.mock.patch.object(self.queue, "append", return_value=False): + result = await self.adapter.send_image_file(thread, str(self.chart)) + + self.assertTrue(result.success) + self.assertEqual( + [frame["type"] for frame in self.connection.messages[frames_before:]], + ["media.deliver"], + ) + + async def test_audio_rides_the_same_media_frame_instead_of_the_fallback(self): + """T3 renders audio as a download card — still strictly better than + the base class's "couldn't deliver the audio attachment" notice.""" + audio = pathlib.Path(self._tmp.name) / "reply.mp3" + audio.write_bytes(b"ID3 fake audio") + await self._start_turn("thread-audio", "turn-audio") + frames_before = len(self.connection.messages) + + result = await self.adapter.send_voice("thread-audio", str(audio)) + + self.assertTrue(result.success) + delivery = self.connection.messages[frames_before:][0] + self.assertEqual(delivery["type"], "media.deliver") + self.assertEqual(delivery["mimeType"], "audio/mpeg") + + +class EnvEnablementTests(unittest.TestCase): + """`home_channel` is the magic key that makes `get_home_channel` resolve.""" + + ENROLLED = { + "HERMES_T3_GATEWAY_URL": "wss://t3.example/api/hermes-gateway/ws", + "HERMES_T3_GATEWAY_INSTANCE_ID": "instance", + "HERMES_T3_GATEWAY_CREDENTIAL": "credential", + } + + def test_a_designated_home_seeds_the_magic_home_channel_key(self): + with unittest.mock.patch.dict( + adapter_module.os.environ, + {**self.ENROLLED, home_module.HOME_CHANNEL_ENV: "home-thread"}, + ): + seed = adapter_module.env_enablement() + # Core pops this key and promotes it to a real HomeChannel dataclass + # (gateway/config.py:2648-2660), reading only chat_id/name/thread_id. + # T3 threads are the addressing unit, so chat_id IS the thread id and + # thread_id stays unset. + self.assertEqual( + seed["home_channel"], {"chat_id": "home-thread", "name": "Home"} + ) + + def test_no_designation_yet_seeds_no_home_channel(self): + with unittest.mock.patch.dict( + adapter_module.os.environ, + {**self.ENROLLED, home_module.HOME_CHANNEL_ENV: ""}, + ): + seed = adapter_module.env_enablement() + # The pre-designation window — first connect, before any + # `connection.accepted`. This is exactly why the `/sethome` nudge + # suppression is still needed. + self.assertNotIn("home_channel", seed) + self.assertEqual(seed["instance_id"], "instance") + + def test_an_unenrolled_hermes_seeds_nothing_at_all(self): + with unittest.mock.patch.dict( + adapter_module.os.environ, + {**self.ENROLLED, "HERMES_T3_GATEWAY_CREDENTIAL": ""}, + ): + self.assertIsNone(adapter_module.env_enablement()) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/hermes-t3-gateway/tests/test_connection.py b/integrations/hermes-t3-gateway/tests/test_connection.py new file mode 100644 index 000000000000..0a74818b362c --- /dev/null +++ b/integrations/hermes-t3-gateway/tests/test_connection.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import json +import pathlib +import sys +import types +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PACKAGE = "hermes_t3_gateway_test" + +package = types.ModuleType(PACKAGE) +package.__path__ = [str(ROOT)] +sys.modules.setdefault(PACKAGE, package) + +for name in ("protocol", "connection"): + spec = importlib.util.spec_from_file_location( + f"{PACKAGE}.{name}", ROOT / f"{name}.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[f"{PACKAGE}.{name}"] = module + spec.loader.exec_module(module) + +connection = sys.modules[f"{PACKAGE}.connection"] + + +async def _immediate(value): + return value + + +class FakeSocket: + def __init__(self, response): + self.response = response + self.sent = [] + self.closed = False + + async def send(self, value): + self.sent.append(json.loads(value)) + + async def recv(self): + request_id = self.sent[0]["requestId"] + return json.dumps({**self.response, "requestId": request_id}) + + async def close(self): + self.closed = True + + +class ConnectionTests(unittest.IsolatedAsyncioTestCase): + def test_url_normalization(self): + self.assertEqual( + connection.websocket_url("https://t3.example"), + "wss://t3.example/api/hermes-gateway/ws", + ) + self.assertEqual( + connection.websocket_url("http://localhost:8484/"), + "ws://localhost:8484/api/hermes-gateway/ws", + ) + with self.assertRaises(ValueError): + connection.websocket_url("ftp://invalid.example") + + async def test_disconnect_cancels_and_drains_in_flight_handlers(self): + started = asyncio.Event() + cancelled = asyncio.Event() + never_finishes = asyncio.Event() + + async def on_message(_message): + started.set() + try: + await never_finishes.wait() + finally: + cancelled.set() + + conn = connection.T3GatewayConnection( + url="ws://t3.example/api/hermes-gateway/ws", + instance_id="provider-instance", + credential="secret", + hermes_version="0.19.0", + on_message=on_message, + ) + conn._spawn_handler({"type": "turn.start", "requestId": "turn-1"}) + await asyncio.wait_for(started.wait(), timeout=1) + + await conn.disconnect() + + self.assertTrue(cancelled.is_set()) + self.assertEqual(conn._handlers, set()) + + async def test_enrollment_handshake_returns_credential(self): + socket = FakeSocket( + { + "type": "connection.accepted", + "protocolVersion": 4, + "instanceId": "provider-instance", + "nickname": "Research", + "credential": "persistent-secret", + } + ) + accepted = await connection.authenticate_socket( + socket, + authentication={"type": "enrollment-token", "token": "once"}, + hermes_version="0.19.0", + ) + self.assertEqual(accepted["credential"], "persistent-secret") + self.assertEqual( + socket.sent[0]["authentication"], + {"type": "enrollment-token", "token": "once"}, + ) + + async def test_accepted_handshake_rejects_an_incompatible_protocol(self): + socket = FakeSocket( + { + "type": "connection.accepted", + # A v3 server: the version policy stays fail-closed across the + # v4 bump, so this must not be silently accepted. + "protocolVersion": 3, + "instanceId": "provider-instance", + "nickname": "Research", + } + ) + with self.assertRaisesRegex(RuntimeError, "incompatible version"): + await connection.authenticate_socket( + socket, + authentication={ + "type": "instance-credential", + "instanceId": "provider-instance", + "credential": "secret", + }, + hermes_version="0.19.0", + ) + + async def test_rejected_handshake_fails_closed(self): + socket = FakeSocket( + { + "type": "connection.rejected", + "code": "version-incompatible", + "message": "upgrade required", + "expectedProtocolVersion": 4, + } + ) + with self.assertRaises(connection.ConnectionRejected) as raised: + await connection.authenticate_socket( + socket, + authentication={ + "type": "instance-credential", + "instanceId": "provider-instance", + "credential": "secret", + }, + hermes_version="0.19.0", + ) + self.assertEqual(raised.exception.code, "version-incompatible") + + async def test_handshake_survives_a_ping_racing_the_reply(self): + """A ping may arrive before `connection.accepted`. + + The server starts probing liveness on its own schedule, so the first + frame after hello is not guaranteed to be the handshake reply. + Treating it as one tore down the freshly established connection and + reconnected in a loop — the plugin logged "unexpected requestId" while + the server logged missed pongs. + """ + class RacingSocket: + def __init__(self): + self.sent = [] + self._frames = None + + async def send(self, value): + self.sent.append(json.loads(value)) + + async def recv(self): + if self._frames is None: + hello_id = self.sent[0]["requestId"] + self._frames = iter( + [ + json.dumps( + { + "type": "ping", + "protocolVersion": 4, + "requestId": "server-ping-1", + } + ), + json.dumps( + { + "type": "connection.accepted", + "protocolVersion": 4, + "requestId": hello_id, + "instanceId": "provider-instance", + "nickname": "Hermes", + } + ), + ] + ) + return next(self._frames) + + socket = RacingSocket() + accepted = await connection.authenticate_socket( + socket, + authentication={ + "type": "instance-credential", + "instanceId": "provider-instance", + "credential": "secret", + }, + hermes_version="0.19.0", + ) + self.assertEqual(accepted["type"], "connection.accepted") + pongs = [f for f in socket.sent if f.get("type") == "pong"] + self.assertEqual(len(pongs), 1, "the racing ping must still be answered") + self.assertEqual(pongs[0]["requestId"], "server-ping-1") + + async def test_ping_is_answered_while_a_command_handler_is_blocked(self): + """A ping must not queue behind command dispatch. + + `_on_message` awaits Hermes: `turn.start` blocks for the whole agent + turn. If the real read loop awaited that before reading the next + frame, a ping arriving mid-turn would go unanswered for minutes and + T3 would close a healthy socket as half-open — which is what happened + in practice. This drives `_supervise` itself so the loop under test is + the one that ships. + """ + import asyncio + + blocked = asyncio.Event() + released = asyncio.Event() + + class BlockingSocket: + def __init__(self): + self.sent = [] + + async def send(self, value): + self.sent.append(json.loads(value)) + + async def recv(self): + return json.dumps( + { + "type": "connection.accepted", + "protocolVersion": 4, + "requestId": self.sent[0]["requestId"], + "instanceId": "provider-instance", + "nickname": "Hermes", + } + ) + + async def close(self): + return None + + def __aiter__(self): + async def frames(): + yield json.dumps({"type": "turn.start", "requestId": "turn-1"}) + yield json.dumps( + {"type": "ping", "protocolVersion": 4, "requestId": "ping-1"} + ) + await released.wait() + + return frames() + + socket = BlockingSocket() + + async def on_message(message): + # Stands in for Hermes running a turn: does not return while the + # test checks whether the pong went out regardless. + blocked.set() + await released.wait() + + conn = connection.T3GatewayConnection( + url="ws://t3.example/api/hermes-gateway/ws", + instance_id="provider-instance", + credential="secret", + hermes_version="0.19.0", + on_message=on_message, + ) + + original_open = connection._open_socket + connection._open_socket = lambda url: _immediate(socket) + try: + self.assertTrue(await conn.connect(timeout=2)) + await asyncio.wait_for(blocked.wait(), timeout=2) + # Let the read loop reach the queued ping while on_message is stuck. + for _ in range(10): + await asyncio.sleep(0) + pongs = [f for f in socket.sent if f.get("type") == "pong"] + self.assertEqual( + len(pongs), 1, "the ping must be answered while a command is blocked" + ) + self.assertEqual(pongs[0]["requestId"], "ping-1") + finally: + connection._open_socket = original_open + released.set() + await conn.disconnect() + + async def test_ping_is_answered_while_the_accepted_callback_flushes(self): + """Reconnect queue replay must not run ahead of the socket read loop.""" + import asyncio + + callback_started = asyncio.Event() + release_callback = asyncio.Event() + + class BlockingAcceptedSocket: + def __init__(self): + self.sent = [] + + async def send(self, value): + self.sent.append(json.loads(value)) + + async def recv(self): + return json.dumps( + { + "type": "connection.accepted", + "protocolVersion": 4, + "requestId": self.sent[0]["requestId"], + "instanceId": "provider-instance", + "nickname": "Hermes", + } + ) + + async def close(self): + return None + + def __aiter__(self): + async def frames(): + await callback_started.wait() + yield json.dumps( + { + "type": "ping", + "protocolVersion": 4, + "requestId": "ping-during-flush", + } + ) + await release_callback.wait() + + return frames() + + socket = BlockingAcceptedSocket() + + async def on_accepted(_message): + callback_started.set() + await release_callback.wait() + + conn = connection.T3GatewayConnection( + url="ws://t3.example/api/hermes-gateway/ws", + instance_id="provider-instance", + credential="secret", + hermes_version="0.19.0", + on_message=lambda _message: _immediate(None), + on_accepted=on_accepted, + ) + + original_open = connection._open_socket + connection._open_socket = lambda url: _immediate(socket) + try: + self.assertTrue(await conn.connect(timeout=2)) + await asyncio.wait_for(callback_started.wait(), timeout=2) + for _ in range(10): + await asyncio.sleep(0) + pongs = [frame for frame in socket.sent if frame.get("type") == "pong"] + self.assertEqual(len(pongs), 1) + self.assertEqual(pongs[0]["requestId"], "ping-during-flush") + finally: + connection._open_socket = original_open + release_callback.set() + await conn.disconnect() + + async def test_disconnect_cancels_handlers_before_notifying_disconnected(self): + handler_started = asyncio.Event() + handler_drained = asyncio.Event() + notifications = [] + + async def handler(_message): + handler_started.set() + try: + await asyncio.Future() + finally: + await asyncio.sleep(0) + handler_drained.set() + + async def on_state(connected, _reason): + if not connected: + notifications.append(handler_drained.is_set()) + + conn = connection.T3GatewayConnection( + url="ws://unused", + instance_id="provider-instance", + credential="secret", + hermes_version="0.19.0", + on_message=handler, + on_state=on_state, + ) + conn._spawn_handler({"type": "turn.start"}) + await handler_started.wait() + + await conn.disconnect() + + self.assertTrue(handler_drained.is_set()) + self.assertEqual(notifications, [True]) + self.assertEqual(conn._handlers, set()) + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/hermes-t3-gateway/tests/test_coreshim.py b/integrations/hermes-t3-gateway/tests/test_coreshim.py new file mode 100644 index 000000000000..149e4c6b7e0c --- /dev/null +++ b/integrations/hermes-t3-gateway/tests/test_coreshim.py @@ -0,0 +1,443 @@ +"""Tests for the in-process compensation of two upstream `send_message` bugs. + +The fake `tools.send_message_tool` below models the shape `coreshim` actually +depends on at Hermes v0.19.0 — the two coroutine signatures, the live-adapter +shortcut that drops `media_files` (Bug B, upstream line 711), the unconditional +omission warning (Bug A, upstream line 1108), and the media-only hard error +(upstream line 1101). It is deliberately a stand-in and not an import of the +real module: these tests must run with no Hermes installed. +""" + +from __future__ import annotations + +import enum +import asyncio +import importlib.util +import pathlib +import sys +import types +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PACKAGE = "hermes_t3_gateway_coreshim_test" + +package = types.ModuleType(PACKAGE) +package.__path__ = [str(ROOT)] +sys.modules.setdefault(PACKAGE, package) + +for _name in ("protocol", "connection", "home", "coreshim"): + _spec = importlib.util.spec_from_file_location( + f"{PACKAGE}.{_name}", ROOT / f"{_name}.py" + ) + assert _spec and _spec.loader + _module = importlib.util.module_from_spec(_spec) + sys.modules[f"{PACKAGE}.{_name}"] = _module + _spec.loader.exec_module(_module) + +coreshim = sys.modules[f"{PACKAGE}.coreshim"] +home = sys.modules[f"{PACKAGE}.home"] + + +class Platform(str, enum.Enum): + """Core passes an enum whose `.value` is the platform name.""" + + T3 = "t3" + TELEGRAM = "telegram" + + +# The nine platforms core hard-codes into the warning at upstream line 1112. +# The exact list is version-dependent, which is precisely why the shim matches +# on prefix — this fake spells it out so a prefix regression would be caught. +_SUPPORTED = ( + "telegram, discord, matrix, weixin, signal, yuanbao, feishu, whatsapp and slack" +) + + +def _fake_core() -> types.ModuleType: + """Build a module that reproduces the two defects faithfully.""" + module = types.ModuleType("tools.send_message_tool") + module.calls = [] + # Set by the test to simulate a co-resident gateway (Bug B's precondition). + module.live_adapter = None + # Warnings from unrelated causes, which the shim must never touch. + module.extra_warnings = [] + + async def _send_via_adapter( + platform, + pconfig, + chat_id, + chunk, + *, + thread_id=None, + media_files=None, + force_document=False, + ): + module.calls.append( + { + "fn": "_send_via_adapter", + "platform": platform, + "chat_id": chat_id, + "chunk": chunk, + "media_files": media_files, + "thread_id": thread_id, + } + ) + if module.live_adapter is not None: + # Bug B, upstream `tools/send_message_tool.py:711-732`: the live + # adapter is handed content and metadata only. `media_files` is + # dropped here, and the text was already stripped of its `MEDIA:` + # directives upstream at line 442, so nothing downstream can + # recover the attachments. + return await module.live_adapter(chat_id=chat_id, content=chunk) + return {"success": True, "message_id": "standalone-fallback"} + + async def _send_to_platform( + platform, + pconfig, + chat_id, + message, + thread_id=None, + media_files=None, + force_document=False, + ): + module.calls.append( + { + "fn": "_send_to_platform", + "platform": platform, + "message": message, + "media_files": media_files, + } + ) + name = platform.value if hasattr(platform, "value") else str(platform) + # Upstream line 1101-1107. + if media_files and not message.strip(): + return { + "error": ( + "send_message MEDIA delivery is currently only supported for " + f"{_SUPPORTED}; target {name} had only media attachments" + ) + } + # Upstream line 1108-1113: computed with no reference to what the + # sender below actually does. + warning = None + if media_files: + warning = ( + f"MEDIA attachments were omitted for {name}; native send_message " + f"media delivery is currently only supported for {_SUPPORTED}" + ) + # Resolved off the module, exactly as upstream's module-level call does + # — so a patched `_send_via_adapter` is genuinely reached from here, + # which is what makes the co-resident interception testable. + result = await module._send_via_adapter( + platform, + pconfig, + chat_id, + message, + thread_id=thread_id, + media_files=media_files, + force_document=force_document, + ) + # Upstream line 1154-1157. + if warning and isinstance(result, dict) and result.get("success"): + warnings = list(result.get("warnings", [])) + warnings.extend(module.extra_warnings) + warnings.append(warning) + result["warnings"] = warnings + return result + + module._send_via_adapter = _send_via_adapter + module._send_to_platform = _send_to_platform + return module + + +class ShimApplicationTests(unittest.TestCase): + def test_both_wrappers_attach_to_a_faithful_module(self): + module = _fake_core() + self.assertEqual( + coreshim.apply(module), + {"_send_via_adapter": True, "_send_to_platform": True}, + ) + + def test_applying_twice_does_not_stack_wrappers(self): + """`register()` can run more than once (e.g. discover_plugins(force=True)).""" + module = _fake_core() + coreshim.apply(module) + first = module._send_via_adapter + second_pass = coreshim.apply(module) + + self.assertEqual( + second_pass, {"_send_via_adapter": False, "_send_to_platform": False} + ) + self.assertIs(module._send_via_adapter, first) + # One layer deep, still the genuine original. + self.assertFalse( + hasattr(module._send_via_adapter.__wrapped__, "__wrapped__") + ) + + def test_wrapper_preserves_positional_only_and_keyword_only_kinds(self): + module = _fake_core() + calls = [] + + async def _send_via_adapter( + platform, pconfig, chat_id, chunk, /, *, thread_id=None, + media_files=None, force_document=False + ): + calls.append((platform, chat_id, chunk, thread_id)) + return {"success": True} + + module._send_via_adapter = _send_via_adapter + self.assertTrue(coreshim.apply(module)["_send_via_adapter"]) + + async def exercise(): + return await module._send_via_adapter( + Platform.T3, None, "home", "text", thread_id="thread" + ) + + self.assertEqual(asyncio.run(exercise()), {"success": True}) + self.assertEqual(calls, [(Platform.T3, "home", "text", "thread")]) + + +class FailOpenTests(unittest.TestCase): + """A shape mismatch must leave core exactly as it was, never raise.""" + + def _assert_untouched(self, module, applied, attribute): + self.assertFalse(applied[attribute]) + self.assertFalse(getattr(getattr(module, attribute, None), "_t3_gateway_shim", False)) + + def test_a_renamed_parameter_blocks_the_patch(self): + module = _fake_core() + + async def _renamed(platform, pconfig, chat_id, chunk, *, thread_id=None, attachments=None): + return {"success": True} + + module._send_via_adapter = _renamed + with self.assertLogs(coreshim.logger, level="WARNING") as logs: + applied = coreshim.apply(module) + + self._assert_untouched(module, applied, "_send_via_adapter") + self.assertIs(module._send_via_adapter, _renamed) + self.assertTrue(any("media_files" in line for line in logs.output)) + # The unrelated patch still lands — one mismatch does not disarm both. + self.assertTrue(applied["_send_to_platform"]) + + def test_a_missing_function_blocks_the_patch(self): + module = _fake_core() + del module._send_to_platform + with self.assertLogs(coreshim.logger, level="WARNING") as logs: + applied = coreshim.apply(module) + + self.assertFalse(applied["_send_to_platform"]) + self.assertFalse(hasattr(module, "_send_to_platform")) + self.assertTrue(any("is missing" in line for line in logs.output)) + + def test_a_sync_rewrite_blocks_the_patch(self): + """If upstream ever makes these sync, an async wrapper would break callers.""" + module = _fake_core() + + def _sync(platform, pconfig, chat_id, chunk, *, thread_id=None, media_files=None): + return {"success": True} + + module._send_via_adapter = _sync + with self.assertLogs(coreshim.logger, level="WARNING") as logs: + applied = coreshim.apply(module) + + self._assert_untouched(module, applied, "_send_via_adapter") + self.assertTrue(any("coroutine" in line for line in logs.output)) + + def test_an_unimportable_core_is_survivable(self): + """The real `apply()` with no Hermes on the path must not raise.""" + with unittest.mock.patch.dict(sys.modules, {}, clear=False): + sys.modules.pop("tools.send_message_tool", None) + with self.assertLogs(coreshim.logger, level="WARNING"): + applied = coreshim.apply() + self.assertEqual( + applied, {"_send_via_adapter": False, "_send_to_platform": False} + ) + + +class RoutingTests(unittest.IsolatedAsyncioTestCase): + """Behaviour of the patched functions, with `standalone_send` stubbed.""" + + def setUp(self): + self.module = _fake_core() + self.sends = [] + + async def _standalone_send( + pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False + ): + self.sends.append( + { + "chat_id": chat_id, + "message": message, + "media_files": media_files, + "thread_id": thread_id, + "force_document": force_document, + } + ) + return { + "success": True, + "message_id": "t3-delivery", + "media_count": len(media_files or []), + "acked_count": 1 + len(media_files or []), + "note": f"{len(media_files or [])} media file(s) delivered and acknowledged", + } + + patch = unittest.mock.patch.object(home, "standalone_send", _standalone_send) + patch.start() + self.addCleanup(patch.stop) + coreshim.apply(self.module) + + async def _live_adapter(*, chat_id, content): + # Whatever core hands the live adapter is all it ever sees. + return {"success": True, "message_id": "live-adapter"} + + self.module.live_adapter = _live_adapter + + async def test_co_resident_t3_media_bypasses_the_live_adapter(self): + """Bug B: the files must reach our sender, not the media-blind adapter.""" + result = await self.module._send_via_adapter( + Platform.T3, + None, + "home-thread", + "Here is the chart", + thread_id="thread-1", + media_files=[("/tmp/chart.png", False)], + ) + + self.assertEqual(result["message_id"], "t3-delivery") + self.assertEqual(len(self.sends), 1) + self.assertEqual(self.sends[0]["media_files"], [("/tmp/chart.png", False)]) + self.assertEqual(self.sends[0]["message"], "Here is the chart") + self.assertEqual(self.sends[0]["thread_id"], "thread-1") + # The original never ran, so the adapter never got a chance to drop it. + self.assertEqual(self.module.calls, []) + + async def test_a_text_only_t3_send_keeps_the_original_path(self): + result = await self.module._send_via_adapter( + Platform.T3, None, "home-thread", "No attachments here" + ) + + self.assertEqual(result["message_id"], "live-adapter") + self.assertEqual(self.sends, []) + self.assertEqual(len(self.module.calls), 1) + + async def test_another_platform_with_media_is_untouched(self): + result = await self.module._send_via_adapter( + Platform.TELEGRAM, + None, + "chat-1", + "Telegram body", + media_files=[("/tmp/chart.png", False)], + ) + + self.assertEqual(result["message_id"], "live-adapter") + self.assertEqual(self.sends, []) + self.assertEqual(len(self.module.calls), 1) + self.assertEqual( + self.module.calls[0]["media_files"], [("/tmp/chart.png", False)] + ) + + async def test_the_false_omission_warning_is_stripped_for_t3(self): + """Bug A: the warning is removed, and the rest of the result survives.""" + result = await self.module._send_to_platform( + Platform.T3, + None, + "home-thread", + "Here is the chart", + media_files=[("/tmp/chart.png", False)], + ) + + self.assertTrue(result["success"]) + self.assertNotIn("warnings", result) + self.assertEqual(result["media_count"], 1) + + async def test_an_unrelated_warning_is_preserved(self): + """Only the one known-false warning is removed, not the whole key.""" + self.module.extra_warnings = ["Something else happened"] + result = await self.module._send_to_platform( + Platform.T3, + None, + "home-thread", + "Here is the chart", + media_files=[("/tmp/chart.png", False)], + ) + + self.assertEqual(result["warnings"], ["Something else happened"]) + + async def test_the_warning_survives_for_a_platform_that_really_omits(self): + """Only `t3` is compensated; another platform's warning is truthful.""" + self.module.live_adapter = None + result = await self.module._send_to_platform( + Platform.TELEGRAM, + None, + "chat-1", + "Telegram body", + media_files=[("/tmp/chart.png", False)], + ) + + self.assertEqual(len(result["warnings"]), 1) + self.assertTrue( + result["warnings"][0].startswith("MEDIA attachments were omitted for telegram") + ) + + async def test_a_media_only_t3_send_is_rescued_from_the_hard_error(self): + """Upstream returns an error before routing; the shim sends instead.""" + result = await self.module._send_to_platform( + Platform.T3, None, "home-thread", "", media_files=[("/tmp/chart.png", False)] + ) + + self.assertTrue(result["success"]) + self.assertEqual(self.sends[0]["media_files"], [("/tmp/chart.png", False)]) + # The original router never ran, so its hard error never happened. + self.assertEqual(self.module.calls, []) + + async def test_a_media_only_send_on_another_platform_still_errors(self): + result = await self.module._send_to_platform( + Platform.TELEGRAM, None, "chat-1", "", media_files=[("/tmp/chart.png", False)] + ) + + self.assertIn("had only media attachments", result["error"]) + self.assertEqual(self.sends, []) + + async def test_a_text_only_send_is_byte_identical_through_the_wrapper(self): + """No media means the wrapper is a pure pass-through, both platforms.""" + for platform in (Platform.T3, Platform.TELEGRAM): + with self.subTest(platform=platform): + result = await self.module._send_to_platform( + platform, None, "chat-1", "Plain text" + ) + self.assertEqual( + result, {"success": True, "message_id": "live-adapter"} + ) + self.assertEqual(self.sends, []) + + +class WarningMatchTests(unittest.TestCase): + """The prefix match must be robust to upstream's changing platform list.""" + + def test_a_future_platform_list_still_matches(self): + result = { + "success": True, + "warnings": [ + "MEDIA attachments were omitted for t3; native send_message media " + "delivery is currently only supported for telegram, discord and " + "seventeen other platforms nobody has written yet" + ], + } + self.assertNotIn("warnings", coreshim._strip_false_warning(result)) + + def test_a_similar_warning_for_another_platform_is_not_matched(self): + warning = "MEDIA attachments were omitted for t3000; ..." + result = {"success": True, "warnings": [warning]} + self.assertEqual( + coreshim._strip_false_warning(result)["warnings"], [warning] + ) + + def test_a_non_dict_result_passes_through(self): + self.assertIsNone(coreshim._strip_false_warning(None)) + self.assertEqual(coreshim._strip_false_warning("nope"), "nope") + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/hermes-t3-gateway/tests/test_home.py b/integrations/hermes-t3-gateway/tests/test_home.py new file mode 100644 index 000000000000..87c15f6cdc0f --- /dev/null +++ b/integrations/hermes-t3-gateway/tests/test_home.py @@ -0,0 +1,842 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import json +import os +import pathlib +import sys +import tempfile +import types +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +PACKAGE = "hermes_t3_gateway_home_test" + +# `home.py` depends only on `protocol.py` at import time (`connection.py` is +# imported lazily inside the standalone sender), so this loader needs none of +# the fake `gateway.*` modules the adapter tests install. +package = types.ModuleType(PACKAGE) +package.__path__ = [str(ROOT)] +sys.modules.setdefault(PACKAGE, package) + +for _name in ("protocol", "connection", "home"): + _spec = importlib.util.spec_from_file_location( + f"{PACKAGE}.{_name}", ROOT / f"{_name}.py" + ) + assert _spec and _spec.loader + _module = importlib.util.module_from_spec(_spec) + sys.modules[f"{PACKAGE}.{_name}"] = _module + _spec.loader.exec_module(_module) + +home = sys.modules[f"{PACKAGE}.home"] +protocol = sys.modules[f"{PACKAGE}.protocol"] +connection = sys.modules[f"{PACKAGE}.connection"] + + +def make_delivery(text: str, thread_id: str = "home-thread", **kwargs): + return home.build_delivery(thread_id=thread_id, text=text, **kwargs) + + +class QueueTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.path = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl" + self.queue = home.HomeDeliveryQueue(path=self.path) + + def test_queue_replays_in_fifo_order(self): + """Deliveries flush oldest-first, so a Home transcript reads in order.""" + first = make_delivery("first") + second = make_delivery("second") + third = make_delivery("third") + for entry in (first, second, third): + self.assertTrue(self.queue.append(entry)) + + self.assertEqual( + [entry["text"] for entry in self.queue.entries()], + ["first", "second", "third"], + ) + + def test_an_entry_is_purged_only_by_its_own_ack(self): + """The durability guarantee: nothing leaves the queue without an ack. + + T3 acks only after a durable write, so purging on anything else — a + successful `send()`, a reconnect, a flush — would drop deliveries the + server never actually stored. + """ + first = make_delivery("first") + second = make_delivery("second") + self.queue.append(first) + self.queue.append(second) + + # Sending, flushing, or re-reading changes nothing. + self.assertEqual(len(self.queue.entries()), 2) + self.assertEqual(len(self.queue.entries()), 2) + + # An unknown id purges nothing at all. + self.assertFalse(self.queue.purge("never-sent")) + self.assertEqual(len(self.queue.entries()), 2) + + self.assertTrue(self.queue.purge(first["deliveryId"])) + self.assertEqual( + [entry["deliveryId"] for entry in self.queue.entries()], + [second["deliveryId"]], + ) + + # A duplicate ack (a replayed flush T3 deduped) is inert. + self.assertFalse(self.queue.purge(first["deliveryId"])) + self.assertEqual(len(self.queue.entries()), 1) + + def test_the_queue_is_capped_and_drops_the_oldest(self): + """A wedged queue must not grow without bound — and must not go stale. + + Dropping newest would make an over-full queue permanently swallow + current output; dropping oldest loses the least valuable entries and + keeps today's cron brief. + """ + queue = home.HomeDeliveryQueue(path=self.path, max_entries=3) + deliveries = [make_delivery(f"delivery-{index}") for index in range(5)] + with self.assertLogs(home.logger, level="WARNING") as captured: + for entry in deliveries: + queue.append(entry) + + self.assertTrue( + any("queue is full" in line for line in captured.output), + captured.output, + ) + self.assertEqual( + [entry["text"] for entry in queue.entries()], + ["delivery-2", "delivery-3", "delivery-4"], + ) + + def test_the_queue_is_bounded_by_encoded_bytes_as_well_as_entry_count(self): + first = make_delivery("first") + second = make_delivery("second") + third = make_delivery("third") + two_entry_bytes = len(self.queue._encode(first).encode()) + len( + self.queue._encode(second).encode() + ) + queue = home.HomeDeliveryQueue( + path=self.path, max_entries=10, max_bytes=two_entry_bytes + 4 + ) + + with self.assertLogs(home.logger, level="WARNING"): + for entry in (first, second, third): + self.assertTrue(queue.append(entry)) + + self.assertEqual( + [entry["text"] for entry in queue.entries()], ["second", "third"] + ) + + def test_appending_the_same_delivery_twice_is_idempotent(self): + """A retried standalone send must not double-queue its own payload.""" + entry = make_delivery("once") + self.assertTrue(self.queue.append(entry)) + self.assertTrue(self.queue.append(dict(entry))) + self.assertEqual(len(self.queue.entries()), 1) + + def test_a_torn_line_does_not_discard_the_whole_queue(self): + """A process killed mid-write must cost one entry, not the outbox.""" + good = make_delivery("survivor") + self.queue.append(good) + with self.path.open("a", encoding="utf-8") as handle: + handle.write('{"deliveryId": "truncated"') + + with self.assertLogs(home.logger, level="WARNING"): + self.assertEqual( + [entry["deliveryId"] for entry in self.queue.entries()], + [good["deliveryId"]], + ) + + def test_complete_interior_corruption_is_never_rewritten_from_a_partial_read(self): + first = make_delivery("before-corruption") + arriving = make_delivery("after-corruption") + self.queue.append(first) + with self.path.open("a", encoding="utf-8") as handle: + handle.write("not-json\n") + + with self.assertLogs(home.logger, level="WARNING"): + self.assertTrue(self.queue.append(arriving)) + + raw = self.path.read_text(encoding="utf-8") + self.assertIn("before-corruption", raw) + self.assertIn("not-json", raw) + self.assertIn("after-corruption", raw) + + def test_the_queue_survives_a_process_restart(self): + """Durability across a plugin restart is the entire point of the file.""" + entry = make_delivery("across-restart") + self.queue.append(entry) + + reopened = home.HomeDeliveryQueue(path=self.path) + self.assertEqual( + [item["deliveryId"] for item in reopened.entries()], + [entry["deliveryId"]], + ) + + def test_a_read_error_never_costs_the_entries_already_on_disk(self): + """The queue's worst failure mode, guarded. + + `append` normally rewrites the whole file to enforce the entry cap. A + read that failed used to report an empty queue, so that rewrite + replaced every unacked delivery on disk with the one being appended. + A transient EIO destroyed the outbox. The rewrite is skipped entirely + when the read fails: the cap goes briefly unenforced (recoverable on + the next successful read), the entries do not (not recoverable at all). + """ + first = make_delivery("already-queued") + second = make_delivery("also-queued") + self.queue.append(first) + self.queue.append(second) + arriving = make_delivery("arrives-during-the-outage") + + real_read_text = pathlib.Path.read_text + + def fail_reading_the_queue(path_self, *args, **kwargs): + if path_self == self.path: + raise OSError("simulated I/O error") + return real_read_text(path_self, *args, **kwargs) + + with unittest.mock.patch.object( + pathlib.Path, "read_text", fail_reading_the_queue + ), self.assertLogs(home.logger, level="WARNING"): + self.assertTrue(self.queue.append(arriving)) + + # Every pre-existing delivery survived, and the new one joined them. + self.assertEqual( + [entry["text"] for entry in self.queue.entries()], + ["already-queued", "also-queued", "arrives-during-the-outage"], + ) + + def test_a_read_error_leaves_an_acked_entry_for_a_deduped_replay(self): + """Purge only rewrites, so an unreadable queue means doing nothing.""" + entry = make_delivery("acked") + self.queue.append(entry) + + real_read_text = pathlib.Path.read_text + + def fail_reading_the_queue(path_self, *args, **kwargs): + if path_self == self.path: + raise OSError("simulated I/O error") + return real_read_text(path_self, *args, **kwargs) + + with unittest.mock.patch.object( + pathlib.Path, "read_text", fail_reading_the_queue + ), self.assertLogs(home.logger, level="WARNING"): + self.assertFalse(self.queue.purge(entry["deliveryId"])) + + self.assertEqual( + [item["deliveryId"] for item in self.queue.entries()], + [entry["deliveryId"]], + ) + + @unittest.skipIf(os.name != "posix", "POSIX file modes only") + def test_the_queue_is_readable_only_by_its_owner(self): + """Entries carry message text and base64 media — not other users' business.""" + self.queue.append(make_delivery("private")) + self.assertEqual(self.path.stat().st_mode & 0o777, 0o600) + self.assertEqual(self.path.parent.stat().st_mode & 0o777, 0o700) + + # And it stays private across the rewrite paths, not just on creation. + self.queue.append(make_delivery("still private")) + self.queue.purge(self.queue.entries()[0]["deliveryId"]) + self.assertEqual(self.path.stat().st_mode & 0o777, 0o600) + + @unittest.skipIf(os.name != "posix", "POSIX file modes only") + def test_a_preexisting_permissive_queue_is_repaired_on_rewrite(self): + self.queue.append(make_delivery("first")) + self.path.chmod(0o666) + self.queue.append(make_delivery("second")) + self.assertEqual(self.path.stat().st_mode & 0o777, 0o600) + + @unittest.skipIf(os.name != "posix", "POSIX symlinks only") + def test_a_queue_symlink_is_replaced_without_touching_its_target(self): + target = pathlib.Path(self._tmp.name) / "do-not-touch" + target.write_text("private target", encoding="utf-8") + self.path.parent.mkdir(parents=True) + self.path.symlink_to(target) + + self.assertTrue(self.queue.append(make_delivery("safe"))) + + self.assertFalse(self.path.is_symlink()) + self.assertEqual(target.read_text(encoding="utf-8"), "private target") + self.assertEqual([entry["text"] for entry in self.queue.entries()], ["safe"]) + + def test_the_queue_lives_under_the_active_hermes_home(self): + """Profile-scoped: a second profile must not replay the first's output.""" + with unittest.mock.patch.object( + home, "hermes_home", return_value=pathlib.Path("/tmp/profile-b") + ): + self.assertEqual( + home.queue_path(), + pathlib.Path("/tmp/profile-b/gateway/t3_home_delivery_queue.jsonl"), + ) + + +class ClassificationTests(unittest.TestCase): + def test_the_cron_job_id_metadata_is_the_strongest_signal(self): + kind, label, certain = home.classify_delivery( + "Anything at all", {"job_id": "daily-digest", "notify": True} + ) + self.assertEqual(kind, "cron") + self.assertEqual(label, "Cron: daily-digest") + self.assertTrue(certain) + + def test_the_cron_wrap_header_supplies_a_human_job_name(self): + content = ( + "Cronjob Response: Morning digest\n" + "(job_id: abc123)\n" + "-------------\n\n" + "Three things happened.\n" + ) + kind, label, certain = home.classify_delivery(content, None) + self.assertEqual(kind, "cron") + self.assertEqual(label, "Cron: Morning digest") + self.assertTrue(certain) + + def test_gateway_lifecycle_notices_land_quietly(self): + for content in ( + "♻️ Gateway online — Hermes is back and ready.", + "♻ Gateway restarted successfully. Your session continues.", + "⚠️ Gateway shutting down — Your current task will be interrupted.", + "⚠️ Gateway restarting — Your current task will be interrupted. " + "Send any message after restart and I'll try to resume where you " + "left off.", + ): + with self.subTest(content=content[:32]): + kind, label, certain = home.classify_delivery(content, None) + self.assertEqual(kind, "lifecycle") + self.assertEqual(label, "Gateway") + self.assertTrue(certain) + + def test_a_handoff_is_recognised_from_its_synthetic_session_identity(self): + kind, label, certain = home.classify_delivery( + "Picking up where the CLI left off.", + None, + session_user_id="system:handoff", + ) + self.assertEqual(kind, "handoff") + self.assertEqual(label, "Handoff") + self.assertTrue(certain) + + def test_an_unrecognised_send_defaults_to_an_uncertain_message(self): + """Worst case is a wrong badge — and, in the live-turn window, a send + that stays with the turn rather than being torn out of it.""" + kind, label, certain = home.classify_delivery("Just checking in.", None) + self.assertEqual(kind, "message") + self.assertEqual(label, "Hermes") + self.assertFalse(certain) + + +class FrameTests(unittest.TestCase): + def test_home_deliver_applies_every_wire_bound(self): + frame = protocol.home_deliver( + delivery_id_value="delivery-1", + thread_id="home-thread", + kind="cron", + label=" " + "L" * 400 + " ", + text="x" * (protocol.MAX_HOME_DELIVERY_TEXT_CHARS + 500), + created_at="2026-07-26T00:00:00Z", + ) + self.assertEqual(frame["type"], "home.deliver") + self.assertEqual(frame["protocolVersion"], 4) + self.assertEqual(frame["deliveryId"], "delivery-1") + self.assertEqual(frame["threadId"], "home-thread") + self.assertEqual(frame["kind"], "cron") + self.assertEqual(len(frame["label"]), protocol.MAX_HOME_DELIVERY_LABEL_CHARS) + self.assertEqual( + len(frame["text"]), protocol.MAX_HOME_DELIVERY_TEXT_CHARS + ) + self.assertEqual(frame["createdAt"], "2026-07-26T00:00:00Z") + + def test_home_deliver_never_emits_an_invalid_kind_or_empty_label(self): + frame = protocol.home_deliver( + delivery_id_value="delivery-2", + thread_id="home-thread", + kind="not-a-kind", + label=" ", + text="", + ) + # A misclassification must cost a badge, never a server rejection of a + # delivery the plugin has already queued. + self.assertEqual(frame["kind"], "other") + self.assertEqual(frame["label"], "Hermes") + self.assertTrue(len(frame["text"]) >= 1) + + def test_home_deliver_ack_is_an_accepted_server_command(self): + message = {"type": "home.deliver.ack", "protocolVersion": 4} + self.assertEqual(protocol.validate_server_frame(message), message) + + def test_build_media_delivery_reads_the_file_and_guesses_the_mime(self): + import base64 + + with tempfile.TemporaryDirectory() as tmp: + chart = pathlib.Path(tmp) / "chart.png" + chart.write_bytes(b"\x89PNG fake bytes") + frame = home.build_media_delivery( + thread_id="home-thread", + path=str(chart), + kind="cron", + label="Cron: nightly", + caption="Nightly chart", + ) + self.assertEqual(frame["type"], "media.deliver") + self.assertEqual(frame["kind"], "cron") + self.assertEqual(frame["name"], "chart.png") + self.assertEqual(frame["mimeType"], "image/png") + self.assertEqual(frame["sizeBytes"], len(b"\x89PNG fake bytes")) + self.assertEqual(base64.b64decode(frame["data"]), b"\x89PNG fake bytes") + self.assertEqual(frame["caption"], "Nightly chart") + self.assertTrue(frame["deliveryId"]) + # Self-contained on disk: the frame carries the bytes, not the path, + # so a queued copy survives the source temp file being reaped. + self.assertNotIn("path", frame) + + def test_build_media_delivery_fails_loudly_on_unreadable_or_oversized_files(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(OSError): + home.build_media_delivery( + thread_id="home-thread", + path=str(pathlib.Path(tmp) / "missing.png"), + ) + empty = pathlib.Path(tmp) / "empty.bin" + empty.write_bytes(b"") + # An empty or oversized payload must never reach the durable + # queue: T3 would reject it on every flush, forever. + with self.assertRaises(ValueError): + home.build_media_delivery( + thread_id="home-thread", path=str(empty) + ) + + def test_hello_declares_its_connection_role(self): + gateway = protocol.connection_hello( + hermes_version="0.19.0", + authentication={"type": "instance-credential"}, + ) + self.assertEqual(gateway["role"], "gateway") + delivery = protocol.connection_hello( + hermes_version="0.19.0", + authentication={"type": "instance-credential"}, + role="delivery", + ) + self.assertEqual(delivery["role"], "delivery") + # An unknown role must never silently become "delivery" — the safe + # default is the ordinary connection. + unknown = protocol.connection_hello( + hermes_version="0.19.0", + authentication={"type": "instance-credential"}, + role="nonsense", + ) + self.assertEqual(unknown["role"], "gateway") + + +class MockDeliveryServer: + """A T3 stand-in for the standalone sender's short-lived socket.""" + + def __init__(self, *, ack: bool = True): + self.ack = ack + self.sent: list[dict] = [] + self.closed = False + self._outbox: list[str] = [] + + async def send(self, raw): + message = json.loads(raw) + self.sent.append(message) + if message.get("type") == "connection.hello": + self._outbox.append( + json.dumps( + { + "type": "connection.accepted", + "protocolVersion": protocol.PROTOCOL_VERSION, + "requestId": message["requestId"], + "instanceId": "provider-instance", + "nickname": "Hermes", + "homeThreadId": "home-thread", + } + ) + ) + elif message.get("type") in {"home.deliver", "media.deliver"} and self.ack: + self._outbox.append( + json.dumps( + { + "type": ( + "media.deliver.ack" + if message["type"] == "media.deliver" + else "home.deliver.ack" + ), + "protocolVersion": protocol.PROTOCOL_VERSION, + "deliveryId": message["deliveryId"], + } + ) + ) + + async def recv(self): + if not self._outbox: + raise AssertionError("the mock server was polled with nothing to send") + return self._outbox.pop(0) + + async def close(self): + self.closed = True + + +class StandaloneSenderTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.queue_file = pathlib.Path(self._tmp.name) / "gateway" / "queue.jsonl" + patch = unittest.mock.patch.object( + home, "hermes_home", return_value=pathlib.Path(self._tmp.name) + ) + patch.start() + self.addCleanup(patch.stop) + self.environment = unittest.mock.patch.dict( + home.os.environ, + { + "HERMES_T3_GATEWAY_URL": "wss://t3.example/api/hermes-gateway/ws", + "HERMES_T3_GATEWAY_INSTANCE_ID": "provider-instance", + "HERMES_T3_GATEWAY_CREDENTIAL": "secret", + home.HOME_CHANNEL_ENV: "home-thread", + }, + ) + self.environment.start() + self.addCleanup(self.environment.stop) + + def _serve(self, server): + async def _open(_url): + return server + + return unittest.mock.patch.object(connection, "_open_socket", _open) + + async def test_standalone_send_hellos_as_delivery_then_acks_and_closes(self): + """The full out-of-process cron path. + + `role: "delivery"` is load-bearing: T3's broker registers a `gateway` + connection under generation fencing and displaces its predecessor, so a + cron dial-in announcing the default role would kick the live gateway + socket off its own instance mid-turn. + """ + server = MockDeliveryServer() + with self._serve(server): + result = await home.standalone_send( + None, + "home-thread", + "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.", + ) + + self.assertTrue(result["success"]) + self.assertEqual( + [message["type"] for message in server.sent], + ["connection.hello", "home.deliver"], + ) + hello, delivery = server.sent + self.assertEqual(hello["role"], "delivery") + self.assertEqual(hello["protocolVersion"], 4) + self.assertEqual( + hello["authentication"], + { + "type": "instance-credential", + "instanceId": "provider-instance", + "credential": "secret", + }, + ) + self.assertEqual(delivery["threadId"], "home-thread") + self.assertEqual(delivery["kind"], "cron") + self.assertEqual(delivery["label"], "Cron: nightly") + self.assertEqual(result["message_id"], delivery["deliveryId"]) + self.assertTrue(server.closed, "the delivery socket must not linger") + + # Acked, so nothing is left queued for the live gateway to replay. + self.assertEqual(home.HomeDeliveryQueue().entries(), []) + + async def test_delivery_ack_timeout_returns_ids_received_so_far(self): + class PartialAckServer(MockDeliveryServer): + async def recv(self): + if self._outbox: + return self._outbox.pop(0) + await asyncio.Future() + + frames = [ + home.build_delivery(thread_id="home-thread", text=text) + for text in ("first", "second") + ] + server = PartialAckServer() + # Suppress the second ack so recv blocks after the first one. + original_send = server.send + + async def send(value): + await original_send(value) + if len([m for m in server.sent if m.get("type") == "home.deliver"]) == 2: + server._outbox.pop() + + server.send = send + with self._serve(server): + acked = await home._deliver_over_short_lived_socket( + url="wss://t3.example/api/hermes-gateway/ws", + instance_id="provider-instance", + credential="secret", + frames=frames, + timeout=0.01, + ) + + self.assertEqual(acked, {frames[0]["deliveryId"]}) + self.assertTrue(server.closed) + + async def test_an_unreachable_t3_queues_rather_than_failing_the_cron_job(self): + """A cron job must not report failure for output that will arrive.""" + + async def _refuse(_url): + raise ConnectionRefusedError("T3 is down") + + with unittest.mock.patch.object(connection, "_open_socket", _refuse): + result = await home.standalone_send(None, "home-thread", "Nightly brief") + + self.assertTrue(result["success"]) + self.assertTrue(result["queued"]) + queued = home.HomeDeliveryQueue().entries() + self.assertEqual([entry["text"] for entry in queued], ["Nightly brief"]) + self.assertEqual(queued[0]["deliveryId"], result["message_id"]) + + async def test_an_unacknowledged_delivery_stays_queued(self): + """No ack, no purge — the live gateway retries it on the next connect.""" + server = MockDeliveryServer(ack=False) + with self._serve(server), unittest.mock.patch.object( + home, "_deliver_over_short_lived_socket", return_value=set() + ): + result = await home.standalone_send(None, "home-thread", "Unacked brief") + + self.assertTrue(result["success"]) + self.assertTrue(result["queued"]) + self.assertEqual( + [entry["text"] for entry in home.HomeDeliveryQueue().entries()], + ["Unacked brief"], + ) + + async def test_standalone_send_refuses_when_hermes_is_not_enrolled(self): + with unittest.mock.patch.dict( + home.os.environ, {"HERMES_T3_GATEWAY_CREDENTIAL": ""} + ): + result = await home.standalone_send(None, "home-thread", "Brief") + self.assertIn("not enrolled", result["error"]) + + async def test_standalone_send_falls_back_to_the_designated_home_thread(self): + server = MockDeliveryServer() + with self._serve(server): + result = await home.standalone_send(None, "", "Brief with no chat id") + self.assertTrue(result["success"]) + self.assertEqual(server.sent[1]["threadId"], "home-thread") + + async def test_standalone_send_delivers_media_files_as_media_frames(self): + """`deliver=t3` cron output with files rides the v4 media framing.""" + chart = pathlib.Path(self._tmp.name) / "chart.png" + chart.write_bytes(b"\x89PNG fake bytes") + server = MockDeliveryServer() + with self._serve(server): + result = await home.standalone_send( + None, + "home-thread", + "Cronjob Response: nightly\n(job_id: nightly)\n-------------\n\nDone.", + media_files=[(str(chart), False)], + ) + + self.assertTrue(result["success"]) + self.assertEqual( + [message["type"] for message in server.sent], + ["connection.hello", "home.deliver", "media.deliver"], + ) + media = server.sent[2] + # Media inherits the text's provenance so the chart gets the same + # badge as the brief it accompanies. + self.assertEqual(media["kind"], "cron") + self.assertEqual(media["label"], "Cron: nightly") + self.assertEqual(media["name"], "chart.png") + self.assertEqual(media["mimeType"], "image/png") + # Both frames were acked, so nothing stays queued. + self.assertEqual(home.HomeDeliveryQueue().entries(), []) + # Counter-evidence against core's unconditional "MEDIA attachments were + # omitted" warning: the accounting keys sit in the same result dict. + self.assertEqual(result["media_count"], 1) + self.assertEqual(result["acked_count"], 2) + self.assertEqual( + result["delivery_ids"], + [message["deliveryId"] for message in server.sent[1:]], + ) + self.assertIn("1 media file(s) delivered and acknowledged", result["note"]) + # `message_id` keeps pointing at the first frame — upstream reads it. + self.assertEqual(result["message_id"], server.sent[1]["deliveryId"]) + + async def test_a_text_only_send_reports_no_media(self): + """The accounting keys are always present; only the note is conditional.""" + server = MockDeliveryServer() + with self._serve(server): + result = await home.standalone_send(None, "home-thread", "Just text") + + self.assertEqual(result["media_count"], 0) + self.assertEqual(result["acked_count"], 1) + self.assertEqual(result["delivery_ids"], [result["message_id"]]) + self.assertNotIn("note", result) + + async def test_a_media_only_send_delivers_without_a_text_frame(self): + """`MEDIA:/tmp/x.png` with no prose is a normal send, not an error. + + Core rejects this outright for `t3` + (`tools/send_message_tool.py:1101-1107` @ v0.19.0); `coreshim` routes it + here instead, so the sender has to handle an empty message. + """ + chart = pathlib.Path(self._tmp.name) / "chart.png" + chart.write_bytes(b"\x89PNG fake bytes") + server = MockDeliveryServer() + with self._serve(server): + result = await home.standalone_send( + None, "home-thread", "", media_files=[(str(chart), False)] + ) + + self.assertTrue(result["success"]) + # No empty text frame rides along. + self.assertEqual( + [message["type"] for message in server.sent], + ["connection.hello", "media.deliver"], + ) + self.assertEqual(result["media_count"], 1) + self.assertEqual(result["acked_count"], 1) + self.assertIn("1 media file(s) delivered and acknowledged", result["note"]) + + async def test_media_counts_exclude_a_skipped_file(self): + """A file that could not be read is not counted as delivered.""" + good = pathlib.Path(self._tmp.name) / "good.png" + good.write_bytes(b"\x89PNG fake bytes") + missing = pathlib.Path(self._tmp.name) / "gone.png" + server = MockDeliveryServer() + with self._serve(server), self.assertLogs(home.logger, level="WARNING"): + result = await home.standalone_send( + None, + "home-thread", + "Two charts, one dead", + media_files=[(str(good), False), (str(missing), False)], + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["media_count"], 1) + self.assertIn("1 media file(s) delivered and acknowledged", result["note"]) + self.assertIn("skipped", result["detail"]) + + async def test_standalone_send_skips_an_unreadable_media_file(self): + """One bad file must not sink the brief — and must never be queued.""" + server = MockDeliveryServer() + with self._serve(server), self.assertLogs(home.logger, level="WARNING"): + result = await home.standalone_send( + None, + "home-thread", + "Brief with a dead attachment", + media_files=[(str(pathlib.Path(self._tmp.name) / "gone.png"), False)], + ) + + self.assertTrue(result["success"]) + self.assertIn("skipped", result["detail"]) + self.assertEqual( + [message["type"] for message in server.sent], + ["connection.hello", "home.deliver"], + ) + self.assertEqual(home.HomeDeliveryQueue().entries(), []) + + async def test_unacked_media_stays_queued_for_the_next_connect(self): + """The durable lifecycle applies to media exactly as it does to text.""" + chart = pathlib.Path(self._tmp.name) / "chart.png" + chart.write_bytes(b"\x89PNG fake bytes") + + async def _refuse(_url): + raise ConnectionRefusedError("T3 is down") + + with unittest.mock.patch.object(connection, "_open_socket", _refuse): + result = await home.standalone_send( + None, + "home-thread", + "Nightly brief", + media_files=[(str(chart), False)], + ) + + self.assertTrue(result["success"]) + self.assertTrue(result["queued"]) + queued = home.HomeDeliveryQueue().entries() + self.assertEqual( + [entry["type"] for entry in queued], ["home.deliver", "media.deliver"] + ) + # The queued media frame is self-contained: bytes, not a path. + self.assertEqual(queued[1]["name"], "chart.png") + self.assertTrue(queued[1]["data"]) + # Nothing was acked, so the note must not claim delivery — but it must + # still contradict "omitted", because the file is durably on its way. + self.assertEqual(result["media_count"], 1) + self.assertEqual(result["acked_count"], 0) + self.assertIn("1 media file(s) queued for delivery", result["note"]) + self.assertNotIn("acknowledged", result["note"]) + + async def test_a_mid_batch_queue_failure_cannot_report_durable_success(self): + """Every frame must be acked or queued; one durable text leg is not enough.""" + chart = pathlib.Path(self._tmp.name) / "chart.png" + chart.write_bytes(b"\x89PNG fake bytes") + + async def _refuse(_url): + raise ConnectionRefusedError("T3 is down") + + with unittest.mock.patch.object( + home.HomeDeliveryQueue, "append", side_effect=[True, False] + ) as append, unittest.mock.patch.object(connection, "_open_socket", _refuse): + result = await home.standalone_send( + None, + "home-thread", + "Nightly brief", + media_files=[(str(chart), False)], + ) + + self.assertEqual(append.call_count, 2, "queueing must never short-circuit the batch") + self.assertIn("not durably queued", result["error"]) + + +class DesignationTests(unittest.TestCase): + def test_saving_the_designation_mirrors_it_into_this_process(self): + """The running gateway must not need a restart to see a new home.""" + saved = {} + + config = types.ModuleType("hermes_cli.config") + config.save_env_value = lambda key, value: saved.__setitem__(key, value) + package = types.ModuleType("hermes_cli") + package.__path__ = [] + with unittest.mock.patch.dict( + sys.modules, {"hermes_cli": package, "hermes_cli.config": config} + ), unittest.mock.patch.dict(home.os.environ, {}, clear=False): + self.assertTrue(home.save_home_thread_id("thread-home")) + self.assertEqual(saved, {home.HOME_CHANNEL_ENV: "thread-home"}) + self.assertEqual(home.home_thread_id(), "thread-home") + + def test_an_unwritable_env_still_routes_for_this_process(self): + """A managed or read-only `.env` degrades; it must not break routing.""" + + def refuse(key, value): + raise PermissionError("managed .env") + + config = types.ModuleType("hermes_cli.config") + config.save_env_value = refuse + package = types.ModuleType("hermes_cli") + package.__path__ = [] + with unittest.mock.patch.dict( + sys.modules, {"hermes_cli": package, "hermes_cli.config": config} + ), unittest.mock.patch.dict( + home.os.environ, {}, clear=False + ), self.assertLogs(home.logger, level="WARNING"): + self.assertFalse(home.save_home_thread_id("thread-home")) + # Not durable, but routable: this gateway delivers to the right + # thread until it restarts, and re-reconciles on the next connect. + self.assertEqual(home.home_thread_id(), "thread-home") + + def test_an_empty_designation_is_ignored(self): + with unittest.mock.patch.dict( + home.os.environ, {home.HOME_CHANNEL_ENV: "keep-me"} + ): + self.assertFalse(home.save_home_thread_id(" ")) + self.assertEqual(home.home_thread_id(), "keep-me") + + +if __name__ == "__main__": + unittest.main() diff --git a/integrations/hermes-t3-gateway/tests/test_protocol.py b/integrations/hermes-t3-gateway/tests/test_protocol.py new file mode 100644 index 000000000000..4c5b58247ec9 --- /dev/null +++ b/integrations/hermes-t3-gateway/tests/test_protocol.py @@ -0,0 +1,556 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import sys +import types +import unittest +from contextlib import contextmanager + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "t3_gateway_protocol", ROOT / "protocol.py" +) +assert SPEC and SPEC.loader +protocol = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(protocol) + + +@contextmanager +def fake_hermes_config(loader): + """Install a stand-in `hermes_cli.config` for the duration of a test.""" + saved = { + name: sys.modules.get(name) for name in ("hermes_cli", "hermes_cli.config") + } + package = types.ModuleType("hermes_cli") + package.__path__ = [] + config = types.ModuleType("hermes_cli.config") + if loader is not None: + config.load_config_readonly = loader + package.config = config + sys.modules["hermes_cli"] = package + sys.modules["hermes_cli.config"] = config + try: + yield + finally: + for name, module in saved.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +@contextmanager +def fake_hermes_skills(skills_list=None, skill_view=None): + """Install a stand-in `tools.skills_tool` for the duration of a test.""" + saved = {name: sys.modules.get(name) for name in ("tools", "tools.skills_tool")} + package = types.ModuleType("tools") + package.__path__ = [] + skills_tool = types.ModuleType("tools.skills_tool") + if skills_list is not None: + skills_tool.skills_list = skills_list + if skill_view is not None: + skills_tool.skill_view = skill_view + package.skills_tool = skills_tool + sys.modules["tools"] = package + sys.modules["tools.skills_tool"] = skills_tool + try: + yield + finally: + for name, module in saved.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +class ProtocolTests(unittest.TestCase): + def test_hello_matches_v4_contract(self): + hello = protocol.connection_hello( + hermes_version="0.19.0", + authentication={"type": "enrollment-token", "token": "once"}, + hello_request_id="request-1", + model="gpt-5.6-terra", + ) + self.assertEqual(hello["type"], "connection.hello") + self.assertEqual(hello["requestId"], "request-1") + self.assertEqual(hello["protocolVersion"], 4) + # v4 pins `attachments` to the literal true — it is part of the + # contract, not a negotiated option. + self.assertTrue(hello["capabilities"]["attachments"]) + self.assertTrue(hello["capabilities"]["streaming"]) + self.assertEqual(hello["model"], "gpt-5.6-terra") + + def test_hello_normalizes_supported_roles_and_falls_back_to_gateway(self): + authentication = {"type": "instance-credential", "credential": "secret"} + delivery = protocol.connection_hello( + hermes_version="0.19.0", authentication=authentication, role=" DELIVERY " + ) + invalid = protocol.connection_hello( + hermes_version="0.19.0", authentication=authentication, role="admin" + ) + self.assertEqual(delivery["role"], "delivery") + self.assertEqual(invalid["role"], "gateway") + + def test_hello_reports_the_configured_hermes_model(self): + config = {"model": {"default": "gpt-5.6-terra"}} + + with fake_hermes_config(lambda: config): + hello = protocol.connection_hello( + hermes_version="0.19.0", + authentication={"type": "enrollment-token", "token": "once"}, + ) + + self.assertEqual(hello["model"], "gpt-5.6-terra") + # `load_config_readonly` returns the shared process-wide cache; the + # lookup must never mutate it. + self.assertEqual(config, {"model": {"default": "gpt-5.6-terra"}}) + + def test_hello_omits_model_when_hermes_cannot_report_one(self): + def missing_section(): + return {"agent": {}} + + def older_hermes(): + raise ImportError("load_config_readonly is unavailable") + + for loader in (missing_section, older_hermes, None): + with self.subTest(loader=getattr(loader, "__name__", "absent")): + with fake_hermes_config(loader): + hello = protocol.connection_hello( + hermes_version="0.19.0", + authentication={ + "type": "enrollment-token", + "token": "once", + }, + ) + # Omitted entirely — never null or empty. + self.assertNotIn("model", hello) + + def test_configured_model_ignores_blank_and_non_string_values(self): + for value in ("", " ", None, 5, {"default": "nested"}): + config = {"model": {"default": value}} + with ( + self.subTest(value=value), + fake_hermes_config(lambda config=config: config), + ): + self.assertIsNone(protocol.configured_model()) + + def test_server_frame_validation_is_closed(self): + with self.assertRaisesRegex(ValueError, "unsupported"): + protocol.validate_server_frame({"type": "made.up", "protocolVersion": 4}) + with self.assertRaisesRegex(ValueError, "version"): + # Protocol v3 peers must upgrade before sending runtime frames. + protocol.validate_server_frame({"type": "ping", "protocolVersion": 3}) + + def test_describe_frames_are_accepted_server_commands(self): + for frame_type in ("describe.request", "skill.body.request"): + with self.subTest(frame_type=frame_type): + message = {"type": frame_type, "protocolVersion": 4} + self.assertEqual(protocol.validate_server_frame(message), message) + + def test_handoff_results_and_protocol_errors_are_server_frames(self): + for frame_type in ("handoff.created", "protocol.error"): + with self.subTest(frame_type=frame_type): + message = {"type": frame_type, "protocolVersion": 4} + self.assertEqual(protocol.validate_server_frame(message), message) + + # ── describe.response ────────────────────────────────────────────── + + def test_describe_response_round_trips_every_reported_field(self): + skills = [ + {"name": "codex", "description": "Delegate coding.", "source": "agents"} + ] + response = protocol.describe_response( + request_id_value="describe-1", + hermes_version="0.19.0", + model="gpt-5.6-terra", + reasoning_effort="medium", + skills=skills, + ) + self.assertEqual(response["type"], "describe.response") + self.assertEqual(response["requestId"], "describe-1") + self.assertEqual(response["protocolVersion"], 4) + self.assertEqual(response["pluginVersion"], protocol.PLUGIN_VERSION) + self.assertEqual(response["hermesVersion"], "0.19.0") + self.assertEqual(response["model"], "gpt-5.6-terra") + self.assertEqual(response["reasoningEffort"], "medium") + self.assertEqual(response["skills"], skills) + self.assertTrue(response["capabilities"]["attachments"]) + self.assertTrue(response["describedAt"].endswith("Z")) + # Skill dicts are copied out: mutating the reply must not reach back + # into whatever the caller passed in. + response["skills"][0]["name"] = "mutated" + self.assertEqual(skills[0]["name"], "codex") + + def test_describe_reports_the_configured_reasoning_effort(self): + config = {"agent": {"reasoning_effort": "medium"}} + + with fake_hermes_config(lambda: config): + response = protocol.describe_response( + request_id_value="describe-2", + hermes_version="0.19.0", + skills=[], + ) + + self.assertEqual(response["reasoningEffort"], "medium") + # `load_config_readonly` returns the shared process-wide cache; the + # lookup must never mutate it. + self.assertEqual(config, {"agent": {"reasoning_effort": "medium"}}) + + def test_describe_omits_effort_when_hermes_cannot_report_one(self): + def missing_section(): + return {"model": {}} + + def older_hermes(): + raise ImportError("load_config_readonly is unavailable") + + for loader in (missing_section, older_hermes, None): + with self.subTest(loader=getattr(loader, "__name__", "absent")): + with fake_hermes_config(loader): + response = protocol.describe_response( + request_id_value="describe-3", + hermes_version="0.19.0", + skills=[], + ) + # Omitted entirely — never null or empty. + self.assertNotIn("reasoningEffort", response) + self.assertNotIn("model", response) + # The plugin-owned block is always present regardless. + self.assertEqual(response["pluginVersion"], protocol.PLUGIN_VERSION) + self.assertEqual(response["skills"], []) + + def test_configured_reasoning_effort_ignores_blank_and_non_string_values(self): + for value in ("", " ", None, 5, {"level": "high"}): + config = {"agent": {"reasoning_effort": value}} + with ( + self.subTest(value=value), + fake_hermes_config(lambda config=config: config), + ): + self.assertIsNone(protocol.configured_reasoning_effort()) + + # ── skills enumeration ───────────────────────────────────────────── + + def test_installed_skills_projects_only_documented_fields(self): + payload = json.dumps( + { + "success": True, + "skills": [ + { + "name": " codex ", + "description": " Delegate coding. ", + "category": "autonomous-ai-agents", + "secret": "must-not-cross", + }, + {"name": "bare"}, + ], + } + ) + with fake_hermes_skills(skills_list=lambda: payload): + self.assertEqual( + protocol.installed_skills(), + [ + { + "name": "codex", + "enabled": True, + "description": "Delegate coding.", + "source": "autonomous-ai-agents", + }, + {"name": "bare", "enabled": True}, + ], + ) + + def test_installed_skills_degrades_to_empty_on_every_failure(self): + def older_hermes(): + raise ImportError("skills_list is unavailable") + + def not_json(): + return "not json" + + def unsuccessful(): + return json.dumps({"success": False, "error": "boom"}) + + def malformed_entries(): + return json.dumps({"success": True, "skills": ["a string", {}, 5]}) + + for loader in (older_hermes, not_json, unsuccessful, malformed_entries, None): + with self.subTest(loader=getattr(loader, "__name__", "absent")): + with fake_hermes_skills(skills_list=loader): + self.assertEqual(protocol.installed_skills(), []) + + def test_describe_reports_an_empty_skill_list_when_hermes_has_none(self): + with fake_hermes_skills( + skills_list=lambda: json.dumps({"success": True, "skills": []}) + ): + response = protocol.describe_response( + request_id_value="describe-4", + hermes_version="0.19.0", + model="gpt-5.6-terra", + reasoning_effort="medium", + ) + # Always present: an empty list is the truthful answer, never omitted. + self.assertEqual(response["skills"], []) + + # ── skill.body.response ──────────────────────────────────────────── + + def test_skill_body_response_round_trips(self): + response = protocol.skill_body_response( + request_id_value="body-1", + skill_name="codex", + markdown="# Codex\n\nDelegate coding.", + ) + self.assertEqual(response["type"], "skill.body.response") + self.assertEqual(response["requestId"], "body-1") + self.assertEqual(response["protocolVersion"], 4) + self.assertEqual(response["skillName"], "codex") + self.assertEqual(response["markdown"], "# Codex\n\nDelegate coding.") + + def test_skill_body_response_sends_explicit_null_when_unavailable(self): + for markdown in (None, ""): + with self.subTest(markdown=markdown): + response = protocol.skill_body_response( + request_id_value="body-2", + skill_name="missing", + markdown=markdown, + ) + # Present but null — the caller asked about a named skill and + # must tell "nothing to show" from a dropped reply. + self.assertIn("markdown", response) + self.assertIsNone(response["markdown"]) + + def test_skill_body_reads_the_authored_markdown_without_preprocessing(self): + seen = {} + + def skill_view(name, preprocess=True, **kwargs): + seen["name"] = name + seen["preprocess"] = preprocess + return json.dumps({"success": True, "content": "# Codex\n"}) + + with fake_hermes_skills(skill_view=skill_view): + self.assertEqual(protocol.skill_body(" codex "), "# Codex\n") + self.assertEqual(seen["name"], "codex") + # T3 renders the skill for a human; Hermes' template/inline-shell + # rendering must not run. + self.assertFalse(seen["preprocess"]) + + def test_skill_body_truncates_a_pathological_body(self): + oversized = "x" * (protocol.MAX_SKILL_BODY_CHARS + 5_000) + with fake_hermes_skills( + skill_view=lambda *a, **kw: json.dumps( + {"success": True, "content": oversized} + ) + ): + body = protocol.skill_body("huge") + self.assertEqual(len(body), protocol.MAX_SKILL_BODY_CHARS) + + def test_skill_body_degrades_to_none_on_every_failure(self): + def older_hermes(*a, **kw): + raise ImportError("skill_view is unavailable") + + def not_json(*a, **kw): + return "not json" + + def unknown_skill(*a, **kw): + return json.dumps({"success": False, "error": "Skill 'x' not found."}) + + def blank_content(*a, **kw): + return json.dumps({"success": True, "content": " "}) + + for viewer in (older_hermes, not_json, unknown_skill, blank_content, None): + with self.subTest(viewer=getattr(viewer, "__name__", "absent")): + with fake_hermes_skills(skill_view=viewer): + self.assertIsNone(protocol.skill_body("codex")) + + # A blank request never reaches Hermes at all. + with fake_hermes_skills(skill_view=older_hermes): + self.assertIsNone(protocol.skill_body(" ")) + self.assertIsNone(protocol.skill_body(None)) + + def test_tool_types_map_to_canonical_items(self): + self.assertEqual( + protocol.canonical_tool_item_type("terminal"), "command_execution" + ) + self.assertEqual( + protocol.canonical_tool_item_type("apply_patch"), "file_change" + ) + self.assertEqual( + protocol.canonical_tool_item_type("custom_vendor_tool"), + "dynamic_tool_call", + ) + + def test_tool_data_never_forwards_arbitrary_args(self): + self.assertEqual( + protocol.canonical_tool_data( + "terminal", + {"command": "pytest", "cwd": "/repo", "credential": "secret"}, + ), + {"command": "pytest", "cwd": "/repo"}, + ) + self.assertIsNone( + protocol.canonical_tool_data( + "custom_vendor_tool", {"credential": "must-not-cross"} + ) + ) + + # ── media.deliver ────────────────────────────────────────────────── + + def test_media_deliver_encodes_the_payload_and_applies_every_wire_bound(self): + import base64 + + payload = b"\x89PNG fake bytes" + frame = protocol.media_deliver( + delivery_id_value="media-1", + thread_id="home-thread", + kind="cron", + label=" " + "L" * 400 + " ", + name="chart.png", + mime_type="image/png", + data=payload, + turn_id="turn-9", + caption="c" * (protocol.MAX_MEDIA_CAPTION_CHARS + 50), + created_at="2026-07-27T00:00:00Z", + ) + self.assertEqual(frame["type"], "media.deliver") + self.assertEqual(frame["protocolVersion"], 4) + self.assertEqual(frame["deliveryId"], "media-1") + self.assertEqual(frame["threadId"], "home-thread") + self.assertEqual(frame["turnId"], "turn-9") + self.assertEqual(frame["kind"], "cron") + self.assertEqual(len(frame["label"]), protocol.MAX_HOME_DELIVERY_LABEL_CHARS) + self.assertEqual(frame["name"], "chart.png") + self.assertEqual(frame["mimeType"], "image/png") + # `sizeBytes` and `data` are derived from the same bytes, so they can + # never disagree — and the payload round-trips exactly. + self.assertEqual(frame["sizeBytes"], len(payload)) + self.assertEqual(base64.b64decode(frame["data"]), payload) + self.assertEqual(len(frame["caption"]), protocol.MAX_MEDIA_CAPTION_CHARS) + self.assertEqual(frame["createdAt"], "2026-07-27T00:00:00Z") + + def test_media_deliver_omits_optional_fields_rather_than_sending_empty(self): + frame = protocol.media_deliver( + delivery_id_value="media-2", + thread_id="home-thread", + kind="message", + label="Hermes", + name="brief.pdf", + mime_type="application/pdf", + data=b"%PDF", + ) + self.assertNotIn("turnId", frame) + self.assertNotIn("caption", frame) + + def test_media_deliver_degrades_provenance_but_never_the_payload_shape(self): + frame = protocol.media_deliver( + delivery_id_value="media-3", + thread_id="home-thread", + kind="not-a-kind", + label=" ", + name=" ", + mime_type="", + data=b"x", + ) + # A misclassification must cost a badge, never a server rejection of a + # delivery the plugin has already queued. + self.assertEqual(frame["kind"], "other") + self.assertEqual(frame["label"], "Hermes") + self.assertEqual(frame["name"], "attachment.bin") + self.assertEqual(frame["mimeType"], "application/octet-stream") + + def test_media_deliver_requires_a_delivery_id(self): + with self.assertRaisesRegex(ValueError, "deliveryId"): + protocol.media_deliver( + delivery_id_value=" ", + thread_id="home-thread", + kind="message", + label="Hermes", + name="a.bin", + mime_type="application/octet-stream", + data=b"x", + ) + + def test_media_deliver_rejects_an_empty_or_oversized_payload(self): + # Truncation would corrupt the file, so unlike text these fail loudly + # instead of being clamped — and never reach the durable queue. + for data, pattern in ( + (b"", "non-empty"), + (b"x" * (protocol.MAX_MEDIA_BYTES + 1), "ceiling"), + ): + with self.subTest(size=len(data)): + with self.assertRaisesRegex(ValueError, pattern): + protocol.media_deliver( + delivery_id_value="media-4", + thread_id="home-thread", + kind="message", + label="Hermes", + name="big.bin", + mime_type="application/octet-stream", + data=data, + ) + + def test_media_deliver_ack_is_an_accepted_server_command(self): + message = {"type": "media.deliver.ack", "protocolVersion": 4} + self.assertEqual(protocol.validate_server_frame(message), message) + + # ── inbound turn attachments ─────────────────────────────────────── + + def test_turn_attachments_decode_base64_to_bytes(self): + import base64 + + message = { + "type": "turn.start", + "attachments": [ + { + "name": "notes.txt", + "mimeType": "text/plain", + "sizeBytes": 5, + "data": base64.b64encode(b"hello").decode("ascii"), + }, + {"name": "blob", "data": base64.b64encode(b"\x00\x01").decode()}, + ], + } + decoded = protocol.turn_attachments(message) + self.assertEqual( + decoded, + [ + {"name": "notes.txt", "mimeType": "text/plain", "data": b"hello"}, + # A missing MIME degrades to octet-stream, never empty. + { + "name": "blob", + "mimeType": "application/octet-stream", + "data": b"\x00\x01", + }, + ], + ) + + def test_a_frame_without_attachments_decodes_to_an_empty_list(self): + self.assertEqual(protocol.turn_attachments({"type": "turn.start"}), []) + + def test_malformed_turn_attachments_raise_rather_than_dropping_files(self): + # T3 validates against its schema before sending, so a bad entry here + # is version drift; silently losing a user's file is worse than a + # correlated protocol.error they can see. + for attachments in ( + "not-a-list", + [{"mimeType": "text/plain", "data": "aGk="}], # no name + [{"name": "x.txt"}], # no data + [{"name": "x.txt", "data": "!!! not base64 !!!"}], + [{"name": "x.txt", "data": ""}], + ): + with self.subTest(attachments=attachments): + with self.assertRaises(ValueError): + protocol.turn_attachments({"attachments": attachments}) + + def test_an_oversized_turn_attachment_is_rejected(self): + import base64 + + oversized = base64.b64encode( + b"x" * (protocol.MAX_MEDIA_BYTES + 1) + ).decode("ascii") + with self.assertRaisesRegex(ValueError, "ceiling"): + protocol.turn_attachments( + {"attachments": [{"name": "huge.bin", "data": oversized}]} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..c7ca823b8dfd 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -731,6 +731,28 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + hermesGatewayCreateEnrollment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:hermes-gateway:create-enrollment", + tag: WS_METHODS.hermesGatewayCreateEnrollment, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + hermesGatewayGetInstanceStatus: createEnvironmentRpcCommand(runtime, { + label: "environment-data:hermes-gateway:get-instance-status", + tag: WS_METHODS.hermesGatewayGetInstanceStatus, + }), + hermesGatewayRevokeInstance: createEnvironmentRpcCommand(runtime, { + label: "environment-data:hermes-gateway:revoke-instance", + tag: WS_METHODS.hermesGatewayRevokeInstance, + scheduler: configScheduler, + concurrency: configConcurrency, + }), + hermesGatewayRemoveInstance: createEnvironmentRpcCommand(runtime, { + label: "environment-data:hermes-gateway:remove-instance", + tag: WS_METHODS.hermesGatewayRemoveInstance, + scheduler: configScheduler, + concurrency: configConcurrency, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index e3922073455d..125848cb1e9b 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; -import { ProjectFaviconPath } from "./orchestration.ts"; +import { ChatFileAttachment, ProjectFaviconPath } from "./orchestration.ts"; const ASSET_PATH_MAX_LENGTH = 1024; @@ -12,6 +12,14 @@ export const AssetResource = Schema.Union([ }), Schema.TaggedStruct("attachment", { attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + /** + * Presentation hints for opaque non-image payloads. The server signs + * these into the asset capability and always serves hinted files as + * downloads, so caller-controlled MIME metadata cannot become executable + * same-origin content. + */ + fileName: Schema.optional(ChatFileAttachment.fields.name), + mimeType: Schema.optional(ChatFileAttachment.fields.mimeType), }), Schema.TaggedStruct("project-favicon", { cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), diff --git a/packages/contracts/src/hermesGateway.test.ts b/packages/contracts/src/hermesGateway.test.ts new file mode 100644 index 000000000000..6826620432c7 --- /dev/null +++ b/packages/contracts/src/hermesGateway.test.ts @@ -0,0 +1,674 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + DEFAULT_HERMES_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + HERMES_DRIVER_KIND, + PROVIDER_DISPLAY_NAMES, +} from "./model.ts"; +import { + HERMES_GATEWAY_PROTOCOL_VERSION, + HERMES_MEDIA_MAX_BYTES, + HermesGatewayCapabilities, + HermesGatewayConnectionHello, + HermesGatewayCreateEnrollmentInput, + HermesGatewayInstanceStatus, + HermesGatewayPluginToT3Message, + HermesGatewayResumeCursor, + HermesGatewayT3ToPluginMessage, +} from "./hermesGateway.ts"; +import { WS_METHODS } from "./rpc.ts"; +import { DEFAULT_SERVER_SETTINGS, HermesSettings } from "./settings.ts"; + +const decodeCreateEnrollment = Schema.decodeUnknownSync(HermesGatewayCreateEnrollmentInput); +const decodeCapabilities = Schema.decodeUnknownSync(HermesGatewayCapabilities); +const decodeInstanceStatus = Schema.decodeUnknownSync(HermesGatewayInstanceStatus); +const decodeHello = Schema.decodeUnknownSync(HermesGatewayConnectionHello); +const decodeResumeCursor = Schema.decodeUnknownSync(HermesGatewayResumeCursor); +const decodeT3Message = Schema.decodeUnknownSync(HermesGatewayT3ToPluginMessage); +const decodePluginMessage = Schema.decodeUnknownSync(HermesGatewayPluginToT3Message); +const decodeHermesSettings = Schema.decodeUnknownSync(HermesSettings); + +describe("Hermes gateway management contracts", () => { + it("decodes an enrollment request without deriving identity from the nickname", () => { + expect( + decodeCreateEnrollment({ + instanceId: "hermes-research", + nickname: " Research ", + connectorUrl: " https://t3.example.test:3774/hermes ", + }), + ).toEqual({ + instanceId: "hermes-research", + nickname: "Research", + connectorUrl: "https://t3.example.test:3774/hermes", + }); + }); + + it("rejects invalid provider ids and non-connector URL schemes", () => { + expect(() => + decodeCreateEnrollment({ + instanceId: "1-hermes", + nickname: "Research", + connectorUrl: "wss://t3.example.test/hermes", + }), + ).toThrow(); + expect(() => + decodeCreateEnrollment({ + instanceId: "hermes-research", + nickname: "Research", + connectorUrl: "ftp://t3.example.test/hermes", + }), + ).toThrow(); + }); + + it("represents connected and upgrade-required instances for the web UI", () => { + const connected = decodeInstanceStatus({ + instanceId: "hermes-research", + nickname: "Research", + status: "connected", + connectorUrl: "wss://t3.example.test/hermes", + lastConnectedAt: "2026-07-23T12:00:00.000Z", + pluginVersion: "0.2.0", + hermesVersion: "1.2.3", + model: "gpt-5.6-terra", + connectionGeneration: 3, + activeSessionCount: 2, + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + capabilities: { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: true, + }, + }); + expect(connected.status).toBe("connected"); + expect(connected.activeSessionCount).toBe(2); + expect(connected.model).toBe("gpt-5.6-terra"); + + // A plugin that predates the `model` field still produces a valid status; + // the picker falls back to the generic label rather than failing to decode. + const withoutModel = decodeInstanceStatus({ ...connected, model: null }); + expect(withoutModel.model).toBeNull(); + + const upgradeRequired = decodeInstanceStatus({ + ...connected, + status: "upgrade-required", + protocolVersion: 3, + capabilities: null, + }); + expect(upgradeRequired.protocolVersion).toBe(3); + expect(upgradeRequired.capabilities).toBeNull(); + }); +}); + +describe("Hermes gateway handshake", () => { + it("accepts one-time enrollment authentication", () => { + const hello = decodeHello({ + type: "connection.hello", + requestId: "hello-1", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + pluginVersion: "0.2.0", + hermesVersion: "1.2.3", + capabilities: { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: true, + }, + authentication: { + type: "enrollment-token", + token: "enroll-secret", + }, + }); + + expect(hello.authentication.type).toBe("enrollment-token"); + }); + + it("accepts persistent instance authentication", () => { + const hello = decodeHello({ + type: "connection.hello", + requestId: "hello-2", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + pluginVersion: "0.2.0", + hermesVersion: "1.2.3", + capabilities: { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: true, + }, + model: "gpt-5.6-terra", + authentication: { + type: "instance-credential", + instanceId: "hermes-research", + credential: "persistent-secret", + }, + }); + + expect(hello.authentication.type).toBe("instance-credential"); + expect(hello.model).toBe("gpt-5.6-terra"); + }); + + // The plugin ships separately from the server, so a plugin that predates the + // `model` field must still complete the handshake rather than failing the + // frame decoder. T3 falls back to the generic model label. + it("accepts a hello from a plugin that reports no model", () => { + const hello = decodeHello({ + type: "connection.hello", + requestId: "hello-no-model", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + pluginVersion: "0.2.0", + hermesVersion: "1.2.3", + capabilities: { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: true, + }, + authentication: { + type: "instance-credential", + instanceId: "hermes-research", + credential: "persistent-secret", + }, + }); + + expect(hello.model).toBeUndefined(); + }); + + it("decodes an other-version hello so the broker can reject it explicitly", () => { + // A v3 plugin (pre-media) must reach the broker's structured + // `version-incompatible` rejection rather than dying in the frame decoder. + const hello = decodeHello({ + type: "connection.hello", + requestId: "hello-other-version", + protocolVersion: 3, + pluginVersion: "0.3.0", + hermesVersion: "2.0.0", + capabilities: { + protocolVersion: 3, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, + }, + authentication: { + type: "enrollment-token", + token: "enroll-secret", + }, + }); + + expect(hello.protocolVersion).toBe(3); + expect(hello.capabilities.protocolVersion).toBe(3); + }); + + it("requires attachments as part of the v4 contract itself", () => { + // Not a negotiated option: a v4 plugin that cannot handle attachments is + // a v3 plugin, and belongs at the version gate instead. + expect(() => + decodeCapabilities({ + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: false, + }), + ).toThrow(); + }); +}); + +describe("T3 to Hermes messages", () => { + it("decodes session creation and opaque resume cursors", () => { + expect( + decodeT3Message({ + type: "session.ensure", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "ensure-1", + threadId: "thread-1", + resumeSessionId: "opaque/hermes/session/value", + }).type, + ).toBe("session.ensure"); + + expect( + decodeResumeCursor({ + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + sessionId: "opaque/hermes/session/value", + }).sessionId, + ).toBe("opaque/hermes/session/value"); + }); + + it("decodes start and steering as distinct turn operations", () => { + const context = { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "turn-command-1", + threadId: "thread-1", + sessionId: "session-1", + turnId: "turn-1", + text: "Keep the current turn running, but use this guidance.", + }; + + expect(decodeT3Message({ type: "turn.start", ...context }).type).toBe("turn.start"); + expect(decodeT3Message({ type: "turn.steer", ...context }).type).toBe("turn.steer"); + }); + + it("decodes interrupt, approval, structured input, stop, and ping", () => { + const turnContext = { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId: "thread-1", + sessionId: "session-1", + turnId: "turn-1", + }; + + expect( + decodeT3Message({ + type: "turn.interrupt", + requestId: "interrupt-1", + ...turnContext, + }).type, + ).toBe("turn.interrupt"); + expect( + decodeT3Message({ + type: "approval.respond", + requestId: "approval-1", + decision: "acceptForSession", + ...turnContext, + }).type, + ).toBe("approval.respond"); + expect( + decodeT3Message({ + type: "user-input.respond", + requestId: "question-1", + answers: { environment: "production" }, + ...turnContext, + }).type, + ).toBe("user-input.respond"); + expect( + decodeT3Message({ + type: "session.stop", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "stop-1", + threadId: "thread-1", + sessionId: "session-1", + }).type, + ).toBe("session.stop"); + expect( + decodeT3Message({ + type: "ping", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "ping-1", + sentAt: "2026-07-23T12:00:00.000Z", + }).type, + ).toBe("ping"); + }); + + it("rejects post-handshake frames from another protocol version", () => { + expect(() => + decodeT3Message({ + type: "ping", + // Protocol v1 peers must upgrade before sending post-handshake frames. + protocolVersion: 1, + requestId: "ping-1", + sentAt: "2026-07-23T12:00:00.000Z", + }), + ).toThrow(); + }); + + it("defaults an unstated connection role to gateway", () => { + // The field is about intent, not tolerance: a hello that says nothing is + // the ordinary live plugin, which must never be read as a throwaway + // delivery socket. + const frame = { + type: "connection.hello", + requestId: "hello-role-default", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + pluginVersion: "0.2.0", + hermesVersion: "1.2.3", + capabilities: { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + streaming: true, + activity: true, + approvals: true, + userInput: true, + attachments: true, + }, + authentication: { type: "instance-credential", instanceId: "hermes", credential: "secret" }, + } as const; + const hello = decodeHello(frame); + + expect(hello.role).toBe("gateway"); + expect(decodeHello({ ...frame, role: "delivery" }).role).toBe("delivery"); + }); + + it("carries the home thread designation on acceptance", () => { + const accepted = decodeT3Message({ + type: "connection.accepted", + requestId: "hello-1", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + instanceId: "hermes", + nickname: "Remote Hermes", + homeThreadId: "thread-home-1", + }); + + expect(accepted.type).toBe("connection.accepted"); + if (accepted.type === "connection.accepted") { + expect(accepted.homeThreadId).toBe("thread-home-1"); + } + + // Optional: a handshake whose home-thread resolution failed still accepts + // the plugin rather than refusing an authenticated connection. + const withoutHome = decodeT3Message({ + type: "connection.accepted", + requestId: "hello-2", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + instanceId: "hermes", + nickname: "Remote Hermes", + }); + expect(withoutHome.type).toBe("connection.accepted"); + }); +}); + +describe("Hermes home deliveries", () => { + const delivery = { + type: "home.deliver", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: "delivery-1", + threadId: "thread-home-1", + kind: "cron", + label: "Cron: daily-digest", + text: "Your digest is ready.", + createdAt: "2026-07-25T12:00:00.000Z", + } as const; + + it("decodes a delivery and its acknowledgement", () => { + const decoded = decodePluginMessage(delivery); + expect(decoded.type).toBe("home.deliver"); + if (decoded.type === "home.deliver") { + expect(decoded.kind).toBe("cron"); + expect(decoded.deliveryId).toBe("delivery-1"); + } + + const ack = decodeT3Message({ + type: "home.deliver.ack", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: "delivery-1", + }); + expect(ack.type).toBe("home.deliver.ack"); + }); + + it("requires a delivery id, since it is the dedupe key for retries", () => { + expect(() => decodePluginMessage({ ...delivery, deliveryId: "" })).toThrow(); + }); + + it("rejects an unknown delivery kind rather than guessing a badge", () => { + expect(() => decodePluginMessage({ ...delivery, kind: "surprise" })).toThrow(); + }); + + it("rejects multiline labels that could escape the rendered provenance quote", () => { + expect(() => decodePluginMessage({ ...delivery, label: "Cron\nInjected heading" })).toThrow(); + }); + + it("rejects empty delivery text", () => { + expect(() => decodePluginMessage({ ...delivery, text: "" })).toThrow(); + }); +}); + +describe("Hermes media deliveries", () => { + const media = { + type: "media.deliver", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: "media-1", + threadId: "thread-home-1", + kind: "cron", + label: "Cron: daily-digest", + name: "digest-chart.png", + mimeType: "image/png", + sizeBytes: 4, + data: "AAAA", + createdAt: "2026-07-27T12:00:00.000Z", + } as const; + + it("decodes turnless media, turn-scoped media, and the acknowledgement", () => { + const proactive = decodePluginMessage(media); + expect(proactive.type).toBe("media.deliver"); + if (proactive.type === "media.deliver") { + expect(proactive.turnId).toBeUndefined(); + expect(proactive.kind).toBe("cron"); + } + + const turnScoped = decodePluginMessage({ ...media, turnId: "turn-1", caption: "Today's run" }); + if (turnScoped.type === "media.deliver") { + expect(turnScoped.turnId).toBe("turn-1"); + expect(turnScoped.caption).toBe("Today's run"); + } + + const ack = decodeT3Message({ + type: "media.deliver.ack", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + deliveryId: "media-1", + }); + expect(ack.type).toBe("media.deliver.ack"); + }); + + it("requires a delivery id, since it is the dedupe key for retries", () => { + expect(() => decodePluginMessage({ ...media, deliveryId: "" })).toThrow(); + }); + + it("rejects empty payloads and zero-byte sizes", () => { + expect(() => decodePluginMessage({ ...media, data: "" })).toThrow(); + expect(() => decodePluginMessage({ ...media, sizeBytes: 0 })).toThrow(); + }); + + it("bounds the base64 payload at the frame ceiling", () => { + // One character past the 25MiB ceiling must fail at decode, + // before anything buffers or writes. + const overCeiling = "A".repeat(Math.ceil(HERMES_MEDIA_MAX_BYTES / 3) * 4 + 8); + expect(() => decodePluginMessage({ ...media, data: overCeiling })).toThrow(); + }); +}); + +describe("Hermes handoff thread correlation", () => { + it("decodes the public adapter request and its server response", () => { + const create = decodePluginMessage({ + type: "handoff.create", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "handoff-1", + parentThreadId: "home-thread", + name: "Hermes — release prep", + }); + expect(create.type).toBe("handoff.create"); + + const created = decodeT3Message({ + type: "handoff.created", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "handoff-1", + threadId: "hermes-handoff-created", + }); + expect(created.type).toBe("handoff.created"); + }); + + it("accepts a correlated server protocol error for the documented Home fallback", () => { + const error = decodeT3Message({ + type: "protocol.error", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "handoff-1", + code: "unsupported-message", + message: "Upgrade the T3 companion server.", + recoverable: true, + }); + expect(error.type).toBe("protocol.error"); + }); +}); + +describe("Hermes to T3 events", () => { + const turnContext = { + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + threadId: "thread-1", + sessionId: "session-1", + turnId: "turn-1", + }; + + it("decodes session readiness, turn start, streaming text, and completion", () => { + const legacyReady = decodePluginMessage({ + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "ensure-1", + threadId: "thread-1", + sessionId: "session-1", + resumed: false, + }); + expect(legacyReady.type).toBe("session.ready"); + const activeReady = decodePluginMessage({ + type: "session.ready", + protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION, + requestId: "ensure-2", + threadId: "thread-1", + sessionId: "session-1", + resumed: true, + activeTurnId: "turn-1", + }); + expect(activeReady.type).toBe("session.ready"); + if (activeReady.type !== "session.ready") { + throw new Error("expected session.ready"); + } + expect(activeReady.activeTurnId).toBe("turn-1"); + expect( + decodePluginMessage({ + type: "turn.started", + requestId: "turn-command-1", + ...turnContext, + }).type, + ).toBe("turn.started"); + expect( + decodePluginMessage({ + type: "content.delta", + streamKind: "assistant_text", + delta: "Hello", + ...turnContext, + }).type, + ).toBe("content.delta"); + const snapshot = decodePluginMessage({ + type: "content.snapshot", + streamKind: "assistant_text", + text: "", + itemId: "message-1", + contentIndex: 0, + ...turnContext, + }); + expect(snapshot.type).toBe("content.snapshot"); + if (snapshot.type !== "content.snapshot") { + throw new Error("expected content.snapshot"); + } + expect(snapshot.text).toBe(""); + expect( + decodePluginMessage({ + type: "turn.completed", + state: "completed", + ...turnContext, + }).type, + ).toBe("turn.completed"); + }); + + it("decodes activity lifecycle events with normalized and generic data", () => { + for (const type of ["item.started", "item.updated", "item.completed"] as const) { + expect( + decodePluginMessage({ + type, + itemId: "tool-1", + itemType: "mcp_tool_call", + status: type === "item.completed" ? "completed" : "inProgress", + title: "Search", + detail: "Looking up the requested information", + data: { providerKind: "hermes-native-event" }, + ...turnContext, + }).type, + ).toBe(type); + } + }); + + it("decodes approvals and structured user-input lifecycle events", () => { + expect( + decodePluginMessage({ + type: "request.opened", + requestId: "approval-1", + requestType: "command_execution_approval", + detail: "Run the command?", + args: { command: "git status" }, + ...turnContext, + }).type, + ).toBe("request.opened"); + expect( + decodePluginMessage({ + type: "request.resolved", + requestId: "approval-1", + requestType: "command_execution_approval", + decision: "accept", + ...turnContext, + }).type, + ).toBe("request.resolved"); + expect( + decodePluginMessage({ + type: "user-input.requested", + requestId: "question-1", + questions: [ + { + id: "environment", + header: "Target", + question: "Which environment?", + options: [ + { + label: "Staging", + description: "Deploy to the staging environment.", + }, + ], + }, + ], + ...turnContext, + }).type, + ).toBe("user-input.requested"); + expect( + decodePluginMessage({ + type: "user-input.resolved", + requestId: "question-1", + answers: { environment: "Staging" }, + ...turnContext, + }).type, + ).toBe("user-input.resolved"); + }); +}); + +describe("Hermes provider integration constants", () => { + it("exposes Hermes as a single opaque model in the normal provider picker", () => { + expect(DEFAULT_MODEL_BY_PROVIDER[HERMES_DRIVER_KIND]).toBe(DEFAULT_HERMES_MODEL); + expect(PROVIDER_DISPLAY_NAMES[HERMES_DRIVER_KIND]).toBe("Hermes Agent"); + }); + + it("keeps Hermes ACP enabled without companion configuration", () => { + expect(decodeHermesSettings({})).toEqual({ + enabled: true, + binaryPath: "hermes-acp", + customModels: [], + }); + expect(DEFAULT_SERVER_SETTINGS.providers.hermes).toEqual({ + enabled: true, + binaryPath: "hermes-acp", + customModels: [], + }); + }); + + it("registers the web-management RPC method names", () => { + expect(WS_METHODS.hermesGatewayCreateEnrollment).toBe("hermesGateway.createEnrollment"); + expect(WS_METHODS.hermesGatewayGetInstanceStatus).toBe("hermesGateway.getInstanceStatus"); + expect(WS_METHODS.hermesGatewayListInstances).toBe("hermesGateway.listInstances"); + expect(WS_METHODS.hermesGatewayRevokeInstance).toBe("hermesGateway.revokeInstance"); + }); +}); diff --git a/packages/contracts/src/hermesGateway.ts b/packages/contracts/src/hermesGateway.ts new file mode 100644 index 000000000000..211a02c65ab8 --- /dev/null +++ b/packages/contracts/src/hermesGateway.ts @@ -0,0 +1,908 @@ +/** + * Versioned contracts for the T3 Code gateway plugin hosted by Hermes. + * + * The web-management schemas are intentionally separate from the plugin wire + * protocol. Browser clients may receive one-time enrollment tokens, but never + * the persistent credential issued directly to the plugin after enrollment. + * + * @module hermesGateway + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "./baseSchemas.ts"; +import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestration.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; +import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts"; + +export const HERMES_GATEWAY_PROTOCOL_VERSION = 4 as const; + +/** + * Base64 payload ceiling for a single media frame, both directions. + * + * 25MiB of raw bytes is ~34MiB of base64; the schema bound is on the encoded + * string so an oversized frame fails at decode rather than after buffering. + * Deliberately no chunking protocol — a file that does not fit does not + * send, with a clear error. Chunking is the escape hatch if that ceiling + * ever genuinely hurts. + */ +export const HERMES_MEDIA_MAX_BYTES = 25 * 1024 * 1024; +const HERMES_MEDIA_MAX_BASE64_CHARS = Math.ceil(HERMES_MEDIA_MAX_BYTES / 3) * 4 + 4; + +export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION); +export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type; + +export const HermesGatewayRequestId = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewayRequestId"), +); +export type HermesGatewayRequestId = typeof HermesGatewayRequestId.Type; + +/** + * An opaque identifier owned entirely by Hermes. T3 persists and echoes it, + * but must not derive routing or other semantics from its contents. + */ +export const HermesGatewaySessionId = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewaySessionId"), +); +export type HermesGatewaySessionId = typeof HermesGatewaySessionId.Type; + +export const HermesGatewayResumeCursor = Schema.Struct({ + protocolVersion: HermesGatewayProtocolVersion, + sessionId: HermesGatewaySessionId, +}); +export type HermesGatewayResumeCursor = typeof HermesGatewayResumeCursor.Type; + +export const HermesGatewayItemId = TrimmedNonEmptyString.pipe(Schema.brand("HermesGatewayItemId")); +export type HermesGatewayItemId = typeof HermesGatewayItemId.Type; + +export const HermesGatewayEnrollmentToken = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewayEnrollmentToken"), +); +export type HermesGatewayEnrollmentToken = typeof HermesGatewayEnrollmentToken.Type; + +export const HermesGatewayCredential = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewayCredential"), +); +export type HermesGatewayCredential = typeof HermesGatewayCredential.Type; + +export const HermesGatewayNickname = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); +export type HermesGatewayNickname = typeof HermesGatewayNickname.Type; + +/** + * T3 accepts ordinary HTTP(S) URLs because the plugin command may normalize + * them to WebSocket URLs, as well as explicit WS(S) connector URLs. + */ +export const HermesGatewayConnectorUrl = TrimmedNonEmptyString.check( + Schema.isMaxLength(2_048), + Schema.isPattern(/^(?:https?|wss?):\/\/\S+$/i), +); +export type HermesGatewayConnectorUrl = typeof HermesGatewayConnectorUrl.Type; + +export const HermesGatewayCapabilities = Schema.Struct({ + protocolVersion: HermesGatewayProtocolVersion, + streaming: Schema.Boolean, + activity: Schema.Boolean, + approvals: Schema.Boolean, + userInput: Schema.Boolean, + // Literal by design: attachments are part of the v4 contract itself, not a + // negotiated option. A plugin speaking v4 must handle them; one that cannot + // is a v3 plugin and is rejected at the version gate. + attachments: Schema.Literal(true), +}); +export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type; + +/** + * Capability advertisement accepted at the initial handshake boundary. + * + * This deliberately permits capability shapes from a newer protocol so T3 can + * return a structured `version-incompatible` rejection instead of failing the + * WebSocket frame decoder. Accepted connections must subsequently validate + * this advertisement with `HermesGatewayCapabilities`. + */ +export const HermesGatewayHelloCapabilities = Schema.Struct({ + protocolVersion: PositiveInt, + streaming: Schema.Boolean, + activity: Schema.Boolean, + approvals: Schema.Boolean, + userInput: Schema.Boolean, + attachments: Schema.Boolean, +}); +export type HermesGatewayHelloCapabilities = typeof HermesGatewayHelloCapabilities.Type; + +export const HermesGatewayConnectionState = Schema.Literals([ + "offline", + "connecting", + "connected", + "upgrade-required", + "revoked", +]); +export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.Type; + +/** + * Public instance state used by settings and provider-picker surfaces. + * + * `protocolVersion` is not restricted to v4 here so the UI can report the + * unsupported version observed from a plugin that needs an upgrade. + */ +export const HermesGatewayInstanceStatus = Schema.Struct({ + instanceId: ProviderInstanceId, + nickname: HermesGatewayNickname, + status: HermesGatewayConnectionState, + connectorUrl: HermesGatewayConnectorUrl, + lastConnectedAt: Schema.NullOr(IsoDateTime), + pluginVersion: Schema.NullOr(TrimmedNonEmptyString), + hermesVersion: Schema.NullOr(TrimmedNonEmptyString), + /** + * The model the connected plugin reported at handshake, surfaced so the + * provider picker can name the model Hermes actually runs instead of a + * placeholder. Null when no plugin has connected yet, or when the connected + * plugin predates the `model` field on `connection.hello`. + */ + model: Schema.NullOr(TrimmedNonEmptyString), + /** + * Monotonic id of the underlying connection, or null while offline. + * + * Consumers must key "this is a different plugin process now" off this + * rather than off `status` transitioning through `offline`. A replacement — + * the old socket dying as a new one is accepted — publishes a single + * `connected` status, so a connectedness edge detector never fires and + * anything that must be re-established per connection (notably + * `session.ensure`) is silently skipped. + */ + connectionGeneration: Schema.NullOr(NonNegativeInt), + activeSessionCount: NonNegativeInt, + protocolVersion: Schema.NullOr(PositiveInt), + capabilities: Schema.NullOr(HermesGatewayCapabilities), +}); +export type HermesGatewayInstanceStatus = typeof HermesGatewayInstanceStatus.Type; + +export const HermesGatewayCreateEnrollmentInput = Schema.Struct({ + instanceId: ProviderInstanceId, + nickname: HermesGatewayNickname, + connectorUrl: HermesGatewayConnectorUrl, +}); +export type HermesGatewayCreateEnrollmentInput = typeof HermesGatewayCreateEnrollmentInput.Type; + +/** + * Returned exactly once to the web client. The long-lived plugin credential + * is intentionally absent and is delivered only over the authenticated + * enrollment socket. + */ +export const HermesGatewayEnrollmentResult = Schema.Struct({ + instanceId: ProviderInstanceId, + expiresAt: IsoDateTime, + connectorUrl: HermesGatewayConnectorUrl, + command: TrimmedNonEmptyString, + oneTimeToken: HermesGatewayEnrollmentToken, +}); +export type HermesGatewayEnrollmentResult = typeof HermesGatewayEnrollmentResult.Type; + +export const HermesGatewayListInstancesResult = Schema.Array(HermesGatewayInstanceStatus); +export type HermesGatewayListInstancesResult = typeof HermesGatewayListInstancesResult.Type; + +export const HermesGatewayGetInstanceStatusInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type HermesGatewayGetInstanceStatusInput = typeof HermesGatewayGetInstanceStatusInput.Type; + +export const HermesGatewayRenameInstanceInput = Schema.Struct({ + instanceId: ProviderInstanceId, + nickname: HermesGatewayNickname, +}); +export type HermesGatewayRenameInstanceInput = typeof HermesGatewayRenameInstanceInput.Type; + +export const HermesGatewayRenameInstanceResult = HermesGatewayInstanceStatus; +export type HermesGatewayRenameInstanceResult = typeof HermesGatewayRenameInstanceResult.Type; + +export const HermesGatewayRevokeInstanceInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type HermesGatewayRevokeInstanceInput = typeof HermesGatewayRevokeInstanceInput.Type; + +export const HermesGatewayRevokeInstanceResult = HermesGatewayInstanceStatus; +export type HermesGatewayRevokeInstanceResult = typeof HermesGatewayRevokeInstanceResult.Type; + +export const HermesGatewayRemoveInstanceInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type HermesGatewayRemoveInstanceInput = typeof HermesGatewayRemoveInstanceInput.Type; + +export const HermesGatewayRemoveInstanceResult = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type HermesGatewayRemoveInstanceResult = typeof HermesGatewayRemoveInstanceResult.Type; + +export const HermesGatewayManagementOperation = Schema.Literals([ + "create-enrollment", + "get-status", + "list-instances", + "rename-instance", + "revoke-instance", + "remove-instance", +]); +export type HermesGatewayManagementOperation = typeof HermesGatewayManagementOperation.Type; + +export const HermesGatewayManagementErrorCode = Schema.Literals([ + "instance-not-found", + "nickname-conflict", + "invalid-connector-url", + "instance-revoked", + "instance-removed", + "instance-not-revoked", + "persistence-failed", + "internal-error", +]); +export type HermesGatewayManagementErrorCode = typeof HermesGatewayManagementErrorCode.Type; + +export class HermesGatewayManagementError extends Schema.TaggedErrorClass()( + "HermesGatewayManagementError", + { + operation: HermesGatewayManagementOperation, + code: HermesGatewayManagementErrorCode, + message: TrimmedNonEmptyString, + instanceId: Schema.optional(ProviderInstanceId), + }, +) {} + +const HermesGatewayEnrollmentAuthentication = Schema.Struct({ + type: Schema.Literal("enrollment-token"), + token: HermesGatewayEnrollmentToken, +}); +export type HermesGatewayEnrollmentAuthentication = + typeof HermesGatewayEnrollmentAuthentication.Type; + +const HermesGatewayCredentialAuthentication = Schema.Struct({ + type: Schema.Literal("instance-credential"), + instanceId: ProviderInstanceId, + credential: HermesGatewayCredential, +}); +export type HermesGatewayCredentialAuthentication = + typeof HermesGatewayCredentialAuthentication.Type; + +export const HermesGatewayAuthentication = Schema.Union([ + HermesGatewayEnrollmentAuthentication, + HermesGatewayCredentialAuthentication, +]); +export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type; + +/** + * What a connecting socket intends to be. + * + * `gateway` is the instance's one live plugin connection: registered under + * generation fencing, pinged for liveness, and displacing any predecessor. + * `delivery` is a short-lived socket — an out-of-process cron run dialing in + * only to hand over a `home.deliver` and leave. Delivery connections are + * authenticated identically but are never registered as the primary + * connection, so they cannot kick a healthy gateway socket off its instance. + */ +export const HermesGatewayConnectionRole = Schema.Literals(["gateway", "delivery"]); +export type HermesGatewayConnectionRole = typeof HermesGatewayConnectionRole.Type; + +/** + * `protocolVersion` accepts any positive integer at the initial boundary so + * T3 can reject incompatible plugins with a structured upgrade response. + * Once accepted, all remaining frames use the literal current-version schema. + */ +export const HermesGatewayConnectionHello = Schema.Struct({ + type: Schema.Literal("connection.hello"), + requestId: HermesGatewayRequestId, + protocolVersion: PositiveInt, + pluginVersion: TrimmedNonEmptyString, + hermesVersion: TrimmedNonEmptyString, + capabilities: HermesGatewayHelloCapabilities, + authentication: HermesGatewayAuthentication, + /** + * The model Hermes is configured to run, reported so T3 can show something + * truthful in the picker instead of a placeholder. Read-only — Hermes owns + * model selection, and T3 declares `sessionModelSwitch: "unsupported"`. + * + * Optional so a plugin that predates this field still connects: an absent + * value degrades to the generic label rather than failing the handshake. + */ + model: Schema.optional(TrimmedNonEmptyString), + /** + * Defaults to `"gateway"` on decode so the field stays honest about intent + * rather than making every caller repeat the common case. v4 requires both + * sides updated regardless, so this default is ergonomics, not tolerance. + */ + role: HermesGatewayConnectionRole.pipe(Schema.withDecodingDefault(Effect.succeed("gateway"))), +}); +export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type; + +export const HermesGatewayConnectionAccepted = Schema.Struct({ + type: Schema.Literal("connection.accepted"), + requestId: HermesGatewayRequestId, + protocolVersion: HermesGatewayProtocolVersion, + instanceId: ProviderInstanceId, + nickname: HermesGatewayNickname, + credential: Schema.optional(HermesGatewayCredential), + /** + * The instance's durable home thread — where Hermes' proactive output lands + * when nothing named a destination. Sent on every successful handshake so + * the plugin reconciles its `T3_HOME_CHANNEL` cache each connect; T3's + * settings blob is the authoritative designation. + * + * Optional because resolving it must never fail a handshake: if the thread + * could not be created this connect, the plugin keeps whatever it had and + * reconciles on the next one. + */ + homeThreadId: Schema.optional(ThreadId), +}); +export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type; + +export const HermesGatewayConnectionRejectionCode = Schema.Literals([ + "invalid-authentication", + "enrollment-expired", + "instance-revoked", + "version-incompatible", + "internal-error", +]); +export type HermesGatewayConnectionRejectionCode = typeof HermesGatewayConnectionRejectionCode.Type; + +export const HermesGatewayConnectionRejected = Schema.Struct({ + type: Schema.Literal("connection.rejected"), + requestId: HermesGatewayRequestId, + code: HermesGatewayConnectionRejectionCode, + message: TrimmedNonEmptyString, + expectedProtocolVersion: HermesGatewayProtocolVersion, +}); +export type HermesGatewayConnectionRejected = typeof HermesGatewayConnectionRejected.Type; + +export const HermesGatewayConnectionStatus = Schema.Struct({ + type: Schema.Literal("connection.status"), + protocolVersion: HermesGatewayProtocolVersion, + activeSessionCount: NonNegativeInt, +}); +export type HermesGatewayConnectionStatus = typeof HermesGatewayConnectionStatus.Type; + +const HermesGatewaySessionContext = Schema.Struct({ + threadId: ThreadId, + sessionId: HermesGatewaySessionId, +}); + +const HermesGatewayTurnContext = Schema.Struct({ + ...HermesGatewaySessionContext.fields, + turnId: TurnId, +}); + +const HermesGatewayTurnText = Schema.String.check( + Schema.isMinLength(1), + Schema.isMaxLength(120_000), +); + +/** + * A file riding a turn frame toward the plugin. Inline base64 on the frame + * itself: no side-channel fetch (the plugin may be on another machine with + * no authenticated route back), no chunking. The adapter enforces the + * per-turn total; the schema bounds each file. + */ +export const HermesGatewayTurnAttachment = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: PositiveInt, + data: Schema.String.check( + Schema.isMinLength(1), + Schema.isMaxLength(HERMES_MEDIA_MAX_BASE64_CHARS), + ), +}); +export type HermesGatewayTurnAttachment = typeof HermesGatewayTurnAttachment.Type; + +export const HermesGatewaySessionEnsure = Schema.Struct({ + type: Schema.Literal("session.ensure"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + threadId: ThreadId, + resumeSessionId: Schema.optional(HermesGatewaySessionId), +}); +export type HermesGatewaySessionEnsure = typeof HermesGatewaySessionEnsure.Type; + +export const HermesGatewayTurnStart = Schema.Struct({ + type: Schema.Literal("turn.start"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewayTurnContext.fields, + text: HermesGatewayTurnText, + attachments: Schema.optional(Schema.Array(HermesGatewayTurnAttachment)), +}); +export type HermesGatewayTurnStart = typeof HermesGatewayTurnStart.Type; + +export const HermesGatewayTurnSteer = Schema.Struct({ + type: Schema.Literal("turn.steer"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewayTurnContext.fields, + text: HermesGatewayTurnText, + attachments: Schema.optional(Schema.Array(HermesGatewayTurnAttachment)), +}); +export type HermesGatewayTurnSteer = typeof HermesGatewayTurnSteer.Type; + +export const HermesGatewayTurnInterrupt = Schema.Struct({ + type: Schema.Literal("turn.interrupt"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewayTurnContext.fields, +}); +export type HermesGatewayTurnInterrupt = typeof HermesGatewayTurnInterrupt.Type; + +export const HermesGatewayApprovalResponse = Schema.Struct({ + type: Schema.Literal("approval.respond"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + requestId: HermesGatewayRequestId, + decision: ProviderApprovalDecision, +}); +export type HermesGatewayApprovalResponse = typeof HermesGatewayApprovalResponse.Type; + +export const HermesGatewayUserInputResponse = Schema.Struct({ + type: Schema.Literal("user-input.respond"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + requestId: HermesGatewayRequestId, + answers: ProviderUserInputAnswers, +}); +export type HermesGatewayUserInputResponse = typeof HermesGatewayUserInputResponse.Type; + +export const HermesGatewaySessionStop = Schema.Struct({ + type: Schema.Literal("session.stop"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewaySessionContext.fields, +}); +export type HermesGatewaySessionStop = typeof HermesGatewaySessionStop.Type; + +/** + * Ask a connected plugin to describe the agent it fronts — versions, model, + * reasoning effort, and installed skills. Backs the Agent page. + */ +export const HermesGatewayDescribeRequest = Schema.Struct({ + type: Schema.Literal("describe.request"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, +}); +export type HermesGatewayDescribeRequest = typeof HermesGatewayDescribeRequest.Type; + +/** Ask for one skill's markdown body. Fired on row expand, never eagerly. */ +export const HermesGatewaySkillBodyRequest = Schema.Struct({ + type: Schema.Literal("skill.body.request"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + skillName: TrimmedNonEmptyString, +}); +export type HermesGatewaySkillBodyRequest = typeof HermesGatewaySkillBodyRequest.Type; + +export const HermesGatewayPing = Schema.Struct({ + type: Schema.Literal("ping"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + sentAt: IsoDateTime, +}); +export type HermesGatewayPing = typeof HermesGatewayPing.Type; + +export const HermesGatewaySessionReady = Schema.Struct({ + type: Schema.Literal("session.ready"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + threadId: ThreadId, + sessionId: HermesGatewaySessionId, + resumed: Schema.Boolean, + activeTurnId: Schema.optional(TurnId), +}); +export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type; + +export const HermesGatewayTurnStarted = Schema.Struct({ + type: Schema.Literal("turn.started"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + ...HermesGatewayTurnContext.fields, +}); +export type HermesGatewayTurnStarted = typeof HermesGatewayTurnStarted.Type; + +export const HermesGatewayContentStreamKind = Schema.Literals([ + "assistant_text", + "reasoning_text", + "reasoning_summary_text", + "plan_text", + "command_output", + "unknown", +]); +export type HermesGatewayContentStreamKind = typeof HermesGatewayContentStreamKind.Type; + +export const HermesGatewayContentDelta = Schema.Struct({ + type: Schema.Literal("content.delta"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + itemId: Schema.optional(HermesGatewayItemId), + streamKind: HermesGatewayContentStreamKind, + delta: Schema.String, + contentIndex: Schema.optional(NonNegativeInt), +}); +export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type; + +export const HermesGatewayContentSnapshot = Schema.Struct({ + type: Schema.Literal("content.snapshot"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + itemId: Schema.optional(HermesGatewayItemId), + streamKind: HermesGatewayContentStreamKind, + text: Schema.String, + contentIndex: Schema.optional(NonNegativeInt), +}); +export type HermesGatewayContentSnapshot = typeof HermesGatewayContentSnapshot.Type; + +export const HermesGatewayItemStatus = Schema.Literals([ + "inProgress", + "completed", + "failed", + "declined", +]); +export type HermesGatewayItemStatus = typeof HermesGatewayItemStatus.Type; + +const HermesGatewayItemLifecycleFields = { + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + itemId: HermesGatewayItemId, + itemType: CanonicalItemType, + status: Schema.optional(HermesGatewayItemStatus), + title: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), + data: Schema.optional(Schema.Unknown), +}; + +export const HermesGatewayItemStarted = Schema.Struct({ + type: Schema.Literal("item.started"), + ...HermesGatewayItemLifecycleFields, +}); +export type HermesGatewayItemStarted = typeof HermesGatewayItemStarted.Type; + +export const HermesGatewayItemUpdated = Schema.Struct({ + type: Schema.Literal("item.updated"), + ...HermesGatewayItemLifecycleFields, +}); +export type HermesGatewayItemUpdated = typeof HermesGatewayItemUpdated.Type; + +export const HermesGatewayItemCompleted = Schema.Struct({ + type: Schema.Literal("item.completed"), + ...HermesGatewayItemLifecycleFields, +}); +export type HermesGatewayItemCompleted = typeof HermesGatewayItemCompleted.Type; + +const HermesGatewayInteractionContext = Schema.Struct({ + ...HermesGatewayTurnContext.fields, + requestId: HermesGatewayRequestId, +}); + +export const HermesGatewayRequestOpened = Schema.Struct({ + type: Schema.Literal("request.opened"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayInteractionContext.fields, + requestType: CanonicalRequestType, + detail: Schema.optional(TrimmedNonEmptyString), + args: Schema.optional(Schema.Unknown), +}); +export type HermesGatewayRequestOpened = typeof HermesGatewayRequestOpened.Type; + +export const HermesGatewayRequestResolved = Schema.Struct({ + type: Schema.Literal("request.resolved"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayInteractionContext.fields, + requestType: CanonicalRequestType, + decision: Schema.optional(TrimmedNonEmptyString), + resolution: Schema.optional(Schema.Unknown), +}); +export type HermesGatewayRequestResolved = typeof HermesGatewayRequestResolved.Type; + +export const HermesGatewayUserInputRequested = Schema.Struct({ + type: Schema.Literal("user-input.requested"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayInteractionContext.fields, + questions: Schema.Array(UserInputQuestion), +}); +export type HermesGatewayUserInputRequested = typeof HermesGatewayUserInputRequested.Type; + +export const HermesGatewayUserInputResolved = Schema.Struct({ + type: Schema.Literal("user-input.resolved"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayInteractionContext.fields, + answers: ProviderUserInputAnswers, +}); +export type HermesGatewayUserInputResolved = typeof HermesGatewayUserInputResolved.Type; + +export const HermesGatewayTurnCompletionState = Schema.Literals(["completed", "failed"]); +export type HermesGatewayTurnCompletionState = typeof HermesGatewayTurnCompletionState.Type; + +export const HermesGatewayTurnCompleted = Schema.Struct({ + type: Schema.Literal("turn.completed"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + state: HermesGatewayTurnCompletionState, + stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + usage: Schema.optional(Schema.Unknown), + errorMessage: Schema.optional(TrimmedNonEmptyString), +}); +export type HermesGatewayTurnCompleted = typeof HermesGatewayTurnCompleted.Type; + +export const HermesGatewayTurnAborted = Schema.Struct({ + type: Schema.Literal("turn.aborted"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewayTurnContext.fields, + reason: TrimmedNonEmptyString, +}); +export type HermesGatewayTurnAborted = typeof HermesGatewayTurnAborted.Type; + +export const HermesGatewaySessionExited = Schema.Struct({ + type: Schema.Literal("session.exited"), + protocolVersion: HermesGatewayProtocolVersion, + ...HermesGatewaySessionContext.fields, + reason: Schema.optional(TrimmedNonEmptyString), + recoverable: Schema.Boolean, +}); +export type HermesGatewaySessionExited = typeof HermesGatewaySessionExited.Type; + +/** + * One skill as the plugin reports it. + * + * `source` is Hermes' category, the closest thing its public skills surface + * publishes to an install source — there is no on-disk path in that surface, + * so T3 must not expect one. Optional fields are *omitted* by the plugin when + * unreadable rather than sent as null. + */ +export const HermesGatewayDescribedSkill = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(TrimmedNonEmptyString), + source: Schema.optional(TrimmedNonEmptyString), + enabled: Schema.Boolean, +}); +export type HermesGatewayDescribedSkill = typeof HermesGatewayDescribedSkill.Type; + +export const HermesGatewayDescribeResponse = Schema.Struct({ + type: Schema.Literal("describe.response"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + pluginVersion: TrimmedNonEmptyString, + hermesVersion: TrimmedNonEmptyString, + capabilities: HermesGatewayHelloCapabilities, + // Optional on the wire: the plugin omits what it could not read from Hermes + // so T3 falls back to its own generic labels instead of rendering an empty + // value as if it were reported. + model: Schema.optional(TrimmedNonEmptyString), + reasoningEffort: Schema.optional(TrimmedNonEmptyString), + skills: Schema.Array(HermesGatewayDescribedSkill), + describedAt: IsoDateTime, +}); +export type HermesGatewayDescribeResponse = typeof HermesGatewayDescribeResponse.Type; + +export const HermesGatewaySkillBodyResponse = Schema.Struct({ + type: Schema.Literal("skill.body.response"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + skillName: TrimmedNonEmptyString, + // Explicitly nullable, unlike the omit-on-failure fields above: the request + // named a skill, so the caller must be able to tell "nothing to show for + // this one" apart from a reply that never arrived. + markdown: Schema.NullOr(Schema.String), +}); +export type HermesGatewaySkillBodyResponse = typeof HermesGatewaySkillBodyResponse.Type; + +export const HermesGatewayPong = Schema.Struct({ + type: Schema.Literal("pong"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + sentAt: IsoDateTime, +}); +export type HermesGatewayPong = typeof HermesGatewayPong.Type; + +/** + * Plugin-minted, stable across retries. T3 dedupes on it, which is what makes + * the plugin's queue safe to flush more than once. + */ +export const HermesGatewayDeliveryId = TrimmedNonEmptyString.pipe( + Schema.brand("HermesGatewayDeliveryId"), +); +export type HermesGatewayDeliveryId = typeof HermesGatewayDeliveryId.Type; + +/** + * What produced a home delivery. Drives both the rendered badge and whether + * the delivery raises its hand: everything except `lifecycle` un-settles the + * thread and pushes; gateway online/shutdown notices land quietly. + * + * Classification is best-effort on the plugin side — Hermes' `adapter.send()` + * contract carries no structured provenance marker on every path — so a + * misclassification costs a wrong badge, never a lost delivery. + */ +export const HermesGatewayHomeDeliveryKind = Schema.Literals([ + "cron", + "message", + "lifecycle", + "handoff", + "other", +]); +export type HermesGatewayHomeDeliveryKind = typeof HermesGatewayHomeDeliveryKind.Type; + +/** + * Hermes-initiated delivery into the instance's home thread. + * + * Deliberately not a turn: there is no provider session, no turn id, and no + * request the delivery answers. A delivery may arrive while the home thread + * has a live user turn and must not disturb it. + */ +export const HermesGatewayHomeDeliver = Schema.Struct({ + type: Schema.Literal("home.deliver"), + protocolVersion: HermesGatewayProtocolVersion, + deliveryId: HermesGatewayDeliveryId, + threadId: ThreadId, + kind: HermesGatewayHomeDeliveryKind, + /** Human source label rendered as the badge — "Cron: daily-digest". */ + label: TrimmedNonEmptyString.check(Schema.isMaxLength(200), Schema.isPattern(/^[^\r\n]*$/)), + text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(120_000)), + /** + * When Hermes produced the content, not when it reached T3. These diverge + * whenever a queued delivery flushes after a reconnect. + */ + createdAt: IsoDateTime, +}); +export type HermesGatewayHomeDeliver = typeof HermesGatewayHomeDeliver.Type; + +/** + * Sent only after the delivery is durably written. The plugin purges its + * queued copy on this frame and nothing else, so acking early loses messages. + */ +export const HermesGatewayHomeDeliverAck = Schema.Struct({ + type: Schema.Literal("home.deliver.ack"), + protocolVersion: HermesGatewayProtocolVersion, + deliveryId: HermesGatewayDeliveryId, +}); +export type HermesGatewayHomeDeliverAck = typeof HermesGatewayHomeDeliverAck.Type; + +/** + * Hermes-initiated media (an image, video, PDF, or arbitrary file) delivered + * as its own message rather than folded into a streaming turn. + * + * Shaped like `home.deliver` on purpose: self-contained, idempotent on + * `deliveryId`, acked only after the bytes are durably written, so the + * plugin's queued copy survives every disconnect between send and ack. + * + * Scope is carried by which ids are present: + * - `turnId` set — media produced during a live turn; lands in that thread + * sequenced next to the turn's text. + * - `turnId` absent — proactive media (a cron job's chart, an artifact from + * an agent-initiated task). `threadId` is advisory the same way it is for + * `home.deliver`: the server re-resolves the instance's home thread and + * refuses to write anywhere else, so a confused plugin cannot spray files + * into arbitrary threads. `kind`/`label` provenance renders the same + * notification header a text delivery gets. + */ +export const HermesGatewayMediaDeliver = Schema.Struct({ + type: Schema.Literal("media.deliver"), + protocolVersion: HermesGatewayProtocolVersion, + deliveryId: HermesGatewayDeliveryId, + threadId: ThreadId, + turnId: Schema.optional(TurnId), + kind: HermesGatewayHomeDeliveryKind, + /** Human source label rendered as the badge — "Cron: daily-digest". */ + label: TrimmedNonEmptyString.check(Schema.isMaxLength(200), Schema.isPattern(/^[^\r\n]*$/)), + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: PositiveInt, + /** Optional caption rendered under the media in the same message row. */ + caption: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))), + data: Schema.String.check( + Schema.isMinLength(1), + Schema.isMaxLength(HERMES_MEDIA_MAX_BASE64_CHARS), + ), + /** When Hermes produced the media, not when it reached T3. */ + createdAt: IsoDateTime, +}); +export type HermesGatewayMediaDeliver = typeof HermesGatewayMediaDeliver.Type; + +/** + * Sent only after the media's bytes and its message row are durably written — + * the same pessimistic-ack contract as `home.deliver.ack`. + */ +export const HermesGatewayMediaDeliverAck = Schema.Struct({ + type: Schema.Literal("media.deliver.ack"), + protocolVersion: HermesGatewayProtocolVersion, + deliveryId: HermesGatewayDeliveryId, +}); +export type HermesGatewayMediaDeliverAck = typeof HermesGatewayMediaDeliverAck.Type; + +/** + * Hermes' public `BasePlatformAdapter.create_handoff_thread` callback asking + * T3 to create the fresh destination required by `/handoff`. + */ +export const HermesGatewayHandoffCreate = Schema.Struct({ + type: Schema.Literal("handoff.create"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + parentThreadId: ThreadId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(200)), +}); +export type HermesGatewayHandoffCreate = typeof HermesGatewayHandoffCreate.Type; + +/** Correlated result of `handoff.create`. */ +export const HermesGatewayHandoffCreated = Schema.Struct({ + type: Schema.Literal("handoff.created"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: HermesGatewayRequestId, + threadId: ThreadId, +}); +export type HermesGatewayHandoffCreated = typeof HermesGatewayHandoffCreated.Type; + +export const HermesGatewayProtocolErrorCode = Schema.Literals([ + "invalid-message", + "unsupported-message", + "session-not-found", + "turn-not-active", + "request-not-found", + "internal-error", +]); +export type HermesGatewayProtocolErrorCode = typeof HermesGatewayProtocolErrorCode.Type; + +export const HermesGatewayProtocolError = Schema.Struct({ + type: Schema.Literal("protocol.error"), + protocolVersion: HermesGatewayProtocolVersion, + requestId: Schema.optional(HermesGatewayRequestId), + code: HermesGatewayProtocolErrorCode, + message: TrimmedNonEmptyString, + recoverable: Schema.Boolean, +}); +export type HermesGatewayProtocolError = typeof HermesGatewayProtocolError.Type; + +export const HermesGatewayT3ToPluginMessage = Schema.Union([ + HermesGatewayConnectionAccepted, + HermesGatewayConnectionRejected, + HermesGatewaySessionEnsure, + HermesGatewayTurnStart, + HermesGatewayTurnSteer, + HermesGatewayTurnInterrupt, + HermesGatewayApprovalResponse, + HermesGatewayUserInputResponse, + HermesGatewaySessionStop, + HermesGatewayDescribeRequest, + HermesGatewaySkillBodyRequest, + HermesGatewayPing, + HermesGatewayHomeDeliverAck, + HermesGatewayMediaDeliverAck, + HermesGatewayHandoffCreated, + HermesGatewayProtocolError, +]); +export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type; + +export const HermesGatewayPluginToT3Message = Schema.Union([ + HermesGatewayConnectionHello, + HermesGatewayConnectionStatus, + HermesGatewaySessionReady, + HermesGatewayTurnStarted, + HermesGatewayContentDelta, + HermesGatewayContentSnapshot, + HermesGatewayItemStarted, + HermesGatewayItemUpdated, + HermesGatewayItemCompleted, + HermesGatewayRequestOpened, + HermesGatewayRequestResolved, + HermesGatewayUserInputRequested, + HermesGatewayUserInputResolved, + HermesGatewayTurnCompleted, + HermesGatewayTurnAborted, + HermesGatewaySessionExited, + HermesGatewayDescribeResponse, + HermesGatewaySkillBodyResponse, + HermesGatewayPong, + HermesGatewayProtocolError, + HermesGatewayHomeDeliver, + HermesGatewayMediaDeliver, + HermesGatewayHandoffCreate, +]); +export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type; + +export const HermesGatewayWireMessage = Schema.Union([ + HermesGatewayT3ToPluginMessage, + HermesGatewayPluginToT3Message, +]); +export type HermesGatewayWireMessage = typeof HermesGatewayWireMessage.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..8603657e1dcd 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -31,3 +31,5 @@ export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; export * from "./rpc.ts"; + +export * from "./hermesGateway.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 0e3f93108b58..661c26978699 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,11 +132,13 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const DROID_DRIVER_KIND = ProviderDriverKind.make("droid"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); -const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); +export const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); export const DEFAULT_MODEL = "gpt-5.6-sol"; +/** Stable ACP model slug for synthetic Hermes Home threads. */ +export const DEFAULT_HERMES_MODEL = "default"; /** * Codex default-model preference, most preferred first. The provider snapshot @@ -156,6 +158,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial