From 24015eb73b216df3dbad38d6c88aca5e26b6267b Mon Sep 17 00:00:00 2001 From: Bear Huddleston Date: Thu, 3 Sep 2026 19:08:13 -0500 Subject: [PATCH 01/12] feat(contracts): add Tailcat transport and federation protocol contracts Co-Authored-By: Claude Fable 5.1 --- packages/contracts/src/auth.ts | 13 + packages/contracts/src/baseSchemas.ts | 8 +- packages/contracts/src/desktopBootstrap.ts | 2 + packages/contracts/src/environment.ts | 10 + packages/contracts/src/environmentHttp.ts | 186 ++++++++- packages/contracts/src/federation.ts | 397 +++++++++++++++++++ packages/contracts/src/index.ts | 2 + packages/contracts/src/ipc.ts | 23 ++ packages/contracts/src/rpc.ts | 185 +++++++++ packages/contracts/src/tailcat.ts | 240 +++++++++++ packages/shared/package.json | 4 + packages/shared/src/t3ConnectionCode.test.ts | 130 ++++++ packages/shared/src/t3ConnectionCode.ts | 170 ++++++++ 13 files changed, 1368 insertions(+), 2 deletions(-) create mode 100644 packages/contracts/src/federation.ts create mode 100644 packages/contracts/src/tailcat.ts create mode 100644 packages/shared/src/t3ConnectionCode.test.ts create mode 100644 packages/shared/src/t3ConnectionCode.ts diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index a11c894a6fb5..12188bed764c 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -86,6 +86,12 @@ export const AuthAccessReadScope = "access:read" as const; export const AuthAccessWriteScope = "access:write" as const; export const AuthRelayReadScope = "relay:read" as const; export const AuthRelayWriteScope = "relay:write" as const; +/** + * Marks a session issued to a federated peer environment. It carries no + * client capability by itself: federation endpoints look up the peer's + * granted federation scopes, and every client RPC requires another scope. + */ +export const AuthFederationPeerScope = "federation:peer" as const; export const AuthEnvironmentScope = Schema.Literals([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, @@ -95,6 +101,7 @@ export const AuthEnvironmentScope = Schema.Literals([ AuthAccessWriteScope, AuthRelayReadScope, AuthRelayWriteScope, + AuthFederationPeerScope, ]); export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type; export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope); @@ -192,6 +199,12 @@ export const AuthTokenExchangeRequest = Schema.Struct({ client_label: Schema.optionalKey(TrimmedNonEmptyString), client_device_type: Schema.optionalKey(AuthClientMetadataDeviceType), client_os: Schema.optionalKey(TrimmedNonEmptyString), + /** + * The client's Tailcat node public key. Only honored when the pairing + * credential was issued as a Tailcat connection code; the server then trusts + * that key at the transport layer for future connections. + */ + client_tailcat_node_key: Schema.optionalKey(TrimmedNonEmptyString), }).pipe(HttpApiSchema.asFormUrlEncoded()); export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index e55e0af7ca02..dc387eb2158c 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -151,7 +151,13 @@ export type ClientDeviceType = typeof ClientDeviceType.Type; export const ClientWebDeployment = Schema.Literals(["hosted", "server"]); export type ClientWebDeployment = typeof ClientWebDeployment.Type; -export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]); +export const ClientConnectionMethod = Schema.Literals([ + "direct", + "ssh", + "relay", + "tailcat", + "unknown", +]); export type ClientConnectionMethod = typeof ClientConnectionMethod.Type; export const ProviderItemId = makeEntityId("ProviderItemId"); diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index f4d1a0927861..621800b338ec 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -19,6 +19,8 @@ export const DesktopBackendBootstrap = Schema.Struct({ desktopTelemetryFd: Schema.optionalKey(PositiveInt), desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), + /** The bundled Tailcat executable the desktop app resolved for this backend. */ + tailcatBinaryPath: Schema.optionalKey(TrimmedNonEmptyString), }); export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index e9a3f1a1bb65..c4ee69c37acf 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -138,6 +138,16 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ desktop servers whose app predates the remote trigger, where clients must keep telling the user to update the app on that machine. */ desktopAppUpdate: Schema.optionalKey(Schema.Boolean), + /** Server can expose itself over Tailcat and manage trusted Tailcat peers. + Absent on older servers, where clients hide the Tailcat controls. */ + tailcatRemoteAccess: Schema.optionalKey(Schema.Boolean), + /** Server speaks the T3 federation protocol at this version. Absent on + older servers, where clients hide federation entirely. */ + federation: Schema.optionalKey( + Schema.Struct({ + protocolVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + }), + ), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index a895697e36b0..5d1aa1d99b9f 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -29,6 +29,7 @@ import { AuthSessionId, ThreadId, TrimmedNonEmptyString, + TurnId, } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { @@ -44,6 +45,30 @@ import { PullRequestOperationError, PullRequestUnavailableError, } from "./pullRequest.ts"; +import { + FederationArtifactFetchResponse, + FederationArtifactsResponse, + FederationChallengeRequest, + FederationChallengeResponse, + FederationError, + FederationHello, + FederationPairRequest, + FederationPairResponse, + FederationProjectsResponse, + FederationRun, + FederationRunEventsResponse, + FederationRunStartRequest, + FederationTokenRequest, + FederationTokenResponse, +} from "./federation.ts"; +import { + TailcatConnectionCodeResult, + TailcatCreateConnectionCodeInput, + TailcatRemoteAccessError, + TailcatRemoteAccessState, + TailcatSetRemoteAccessEnabledInput, + TailcatTrustedPeerIdInput, +} from "./tailcat.ts"; import { RelayCloudEnvironmentHealthRequest, RelayCloudMintCredentialRequest, @@ -614,9 +639,168 @@ export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") }), ) {} +const EnvironmentTailcatErrors = [ + TailcatRemoteAccessError, + EnvironmentScopeRequiredError, + EnvironmentInternalError, +] as const; + +const FederationTurnIdParams = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, +}); + +const FederationThreadParams = Schema.Struct({ + threadId: ThreadId, +}); + +const FederationEventsQuery = { + afterSequence: Schema.optional( + Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), + ), +}; + +/** + * Tailcat remote-access management for command-line clients. The desktop and + * web UIs drive the same service over RPC; the CLI has no socket, so it uses + * these with a short-lived administrative session. + */ +export class EnvironmentTailcatHttpApi extends HttpApiGroup.make("tailcat") + .add( + HttpApiEndpoint.get("remoteAccess", "/api/tailcat/remote-access", { + headers: OptionalBearerHeaders, + success: TailcatRemoteAccessState, + error: EnvironmentTailcatErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("setRemoteAccess", "/api/tailcat/remote-access", { + headers: OptionalBearerHeaders, + payload: TailcatSetRemoteAccessEnabledInput, + success: TailcatRemoteAccessState, + error: EnvironmentTailcatErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("createConnectionCode", "/api/tailcat/connection-code", { + headers: OptionalBearerHeaders, + payload: TailcatCreateConnectionCodeInput, + success: TailcatConnectionCodeResult, + error: EnvironmentTailcatErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("revokeTrustedPeer", "/api/tailcat/trusted-peers/revoke", { + headers: OptionalBearerHeaders, + payload: TailcatTrustedPeerIdInput, + success: TailcatRemoteAccessState, + error: EnvironmentTailcatErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) {} + +const FederationPublicErrors = [FederationError, EnvironmentInternalError] as const; +const FederationAuthenticatedErrors = [ + FederationError, + EnvironmentScopeRequiredError, + EnvironmentInternalError, +] as const; + +/** + * The peer-facing federation protocol. Pairing and authentication endpoints + * are unauthenticated by design (they establish the session); everything else + * requires a federation session and checks the peer's granted scopes. + */ +export class EnvironmentFederationHttpApi extends HttpApiGroup.make("federation") + .add( + HttpApiEndpoint.post("pair", "/api/federation/pair", { + payload: FederationPairRequest, + success: FederationPairResponse, + error: FederationPublicErrors, + }), + ) + .add( + HttpApiEndpoint.post("challenge", "/api/federation/challenge", { + payload: FederationChallengeRequest, + success: FederationChallengeResponse, + error: FederationPublicErrors, + }), + ) + .add( + HttpApiEndpoint.post("token", "/api/federation/token", { + payload: FederationTokenRequest, + success: FederationTokenResponse, + error: FederationPublicErrors, + }), + ) + .add( + HttpApiEndpoint.get("hello", "/api/federation/hello", { + headers: OptionalBearerHeaders, + success: FederationHello, + error: FederationAuthenticatedErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("projects", "/api/federation/projects", { + headers: OptionalBearerHeaders, + success: FederationProjectsResponse, + error: FederationAuthenticatedErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("startRun", "/api/federation/runs", { + headers: OptionalBearerHeaders, + payload: FederationRunStartRequest, + success: FederationRun, + error: FederationAuthenticatedErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("runStatus", "/api/federation/runs/:threadId", { + headers: OptionalBearerHeaders, + params: FederationThreadParams, + success: FederationRun, + error: FederationAuthenticatedErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("cancelRun", "/api/federation/runs/:threadId/cancel", { + headers: OptionalBearerHeaders, + params: FederationThreadParams, + success: FederationRun, + error: FederationAuthenticatedErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("runEvents", "/api/federation/runs/:threadId/events", { + headers: OptionalBearerHeaders, + params: FederationThreadParams, + payload: FederationEventsQuery, + success: FederationRunEventsResponse, + error: FederationAuthenticatedErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("runArtifacts", "/api/federation/runs/:threadId/artifacts", { + headers: OptionalBearerHeaders, + params: FederationThreadParams, + success: FederationArtifactsResponse, + error: FederationAuthenticatedErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("fetchArtifact", "/api/federation/runs/:threadId/artifacts/:turnId/diff", { + headers: OptionalBearerHeaders, + params: FederationTurnIdParams, + success: FederationArtifactFetchResponse, + error: FederationAuthenticatedErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) {} + export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) .add(EnvironmentPullRequestsHttpApi) - .add(EnvironmentConnectHttpApi) {} + .add(EnvironmentConnectHttpApi) + .add(EnvironmentTailcatHttpApi) + .add(EnvironmentFederationHttpApi) {} diff --git a/packages/contracts/src/federation.ts b/packages/contracts/src/federation.ts new file mode 100644 index 000000000000..b6c823207da2 --- /dev/null +++ b/packages/contracts/src/federation.ts @@ -0,0 +1,397 @@ +import * as Schema from "effect/Schema"; +import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +import { + EnvironmentId, + IsoDateTime, + PositiveInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "./baseSchemas.ts"; +import { ExecutionEnvironmentPlatform } from "./environment.ts"; +import { ModelSelection, RuntimeMode } from "./orchestration.ts"; +import { RepositoryIdentity } from "./environment.ts"; +import { TailcatAddress, TailcatNodeKey } from "./tailcat.ts"; +import { PortSchema } from "./baseSchemas.ts"; + +/** + * Federation lets two T3 servers coordinate explicit work across an + * authenticated peer channel. A peer never gets the local user's scopes: it + * gets a federation session whose subject names the peer, and every federation + * endpoint checks the scopes that peer was granted at pairing time. + */ +export const FEDERATION_PROTOCOL_VERSION = 1 as const; + +/** Versioned `t3c://peer/` peer code. */ +export const FEDERATION_PEER_CODE_VERSION = 1 as const; + +export const FederationScope = Schema.Literals([ + "environment.read", + "projects.read", + "runs.read", + "runs.start", + "runs.cancel", + "artifacts.read", +]); +export type FederationScope = typeof FederationScope.Type; +export const FederationScopes = Schema.Array(FederationScope); + +export const FEDERATION_DEFAULT_SCOPES = [ + "environment.read", + "projects.read", + "runs.read", +] as const satisfies ReadonlyArray; + +export const FEDERATION_ALL_SCOPES = [ + "environment.read", + "projects.read", + "runs.read", + "runs.start", + "runs.cancel", + "artifacts.read", +] as const satisfies ReadonlyArray; + +export const FederationCapability = Schema.Literals([ + "hello", + "projects.list", + "runs.start", + "runs.status", + "runs.cancel", + "runs.events", + "artifacts.describe", + "artifacts.fetch", +]); +export type FederationCapability = typeof FederationCapability.Type; + +export const FederationTransport = Schema.Struct({ + tailcat: Schema.Struct({ + address: TailcatAddress, + port: PortSchema, + }), +}); +export type FederationTransport = typeof FederationTransport.Type; + +/** Ed25519 public key in SPKI PEM form. */ +export const FederationPublicKey = TrimmedNonEmptyString; + +export const FederationPeerCodePayload = Schema.Struct({ + v: Schema.Literal(FEDERATION_PEER_CODE_VERSION), + kind: Schema.Literal("peer"), + protocolVersion: PositiveInt, + environmentId: EnvironmentId, + publicKey: FederationPublicKey, + label: TrimmedNonEmptyString, + transport: FederationTransport, + /** One-time, short-lived pairing credential. */ + token: TrimmedNonEmptyString, + /** Scopes the issuing environment offers to the peer that redeems this code. */ + scopes: FederationScopes, + expiresAt: IsoDateTime, +}); +export type FederationPeerCodePayload = typeof FederationPeerCodePayload.Type; + +export const FederationHello = Schema.Struct({ + protocolVersion: PositiveInt, + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, + serverVersion: TrimmedNonEmptyString, + platform: ExecutionEnvironmentPlatform, + capabilities: Schema.Array(FederationCapability), +}); +export type FederationHello = typeof FederationHello.Type; + +export const FederationPairRequest = Schema.Struct({ + token: TrimmedNonEmptyString, + protocolVersion: PositiveInt, + environmentId: EnvironmentId, + publicKey: FederationPublicKey, + label: TrimmedNonEmptyString, + serverVersion: TrimmedNonEmptyString, + capabilities: Schema.Array(FederationCapability), + transport: Schema.NullOr(FederationTransport), + /** Scopes this requester grants the issuer for reverse calls. */ + grantedScopes: FederationScopes, + /** The requester's Tailcat client key, so the issuer keeps admitting it after relocking. */ + tailcatNodeKey: Schema.optionalKey(TailcatNodeKey), +}); +export type FederationPairRequest = typeof FederationPairRequest.Type; + +export const FederationPairResponse = Schema.Struct({ + protocolVersion: PositiveInt, + environmentId: EnvironmentId, + publicKey: FederationPublicKey, + label: TrimmedNonEmptyString, + serverVersion: TrimmedNonEmptyString, + capabilities: Schema.Array(FederationCapability), + /** Scopes the issuer granted the requester. */ + grantedScopes: FederationScopes, + transport: Schema.NullOr(FederationTransport), + /** The issuer's Tailcat client key, for the requester's own allowlist. */ + tailcatNodeKey: Schema.optionalKey(TailcatNodeKey), +}); +export type FederationPairResponse = typeof FederationPairResponse.Type; + +export const FederationChallengeRequest = Schema.Struct({ + environmentId: EnvironmentId, +}); +export type FederationChallengeRequest = typeof FederationChallengeRequest.Type; + +export const FederationChallengeResponse = Schema.Struct({ + challenge: TrimmedNonEmptyString, + expiresAt: IsoDateTime, +}); +export type FederationChallengeResponse = typeof FederationChallengeResponse.Type; + +/** JWT `typ` for the signed challenge a peer presents to obtain a federation session. */ +export const FEDERATION_AUTH_JWT_TYP = "t3-federation-auth+jwt"; + +export const FederationTokenRequest = Schema.Struct({ + environmentId: EnvironmentId, + /** EdDSA JWT: iss = peer environment id, aud = this environment id, jti = challenge. */ + assertion: TrimmedNonEmptyString, +}); +export type FederationTokenRequest = typeof FederationTokenRequest.Type; + +export const FederationTokenResponse = Schema.Struct({ + accessToken: TrimmedNonEmptyString, + expiresAt: IsoDateTime, + scopes: FederationScopes, + protocolVersion: PositiveInt, +}); +export type FederationTokenResponse = typeof FederationTokenResponse.Type; + +export const FederationProjectSummary = Schema.Struct({ + id: ProjectId, + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.NullOr(RepositoryIdentity), + defaultModelSelection: Schema.NullOr(ModelSelection), +}); +export type FederationProjectSummary = typeof FederationProjectSummary.Type; + +export const FederationProjectsResponse = Schema.Struct({ + environmentId: EnvironmentId, + projects: Schema.Array(FederationProjectSummary), +}); +export type FederationProjectsResponse = typeof FederationProjectsResponse.Type; + +export const FederationRunStatus = Schema.Literals([ + "queued", + "running", + "completed", + "interrupted", + "error", +]); +export type FederationRunStatus = typeof FederationRunStatus.Type; + +export const FederationRunStartRequest = Schema.Struct({ + projectId: ProjectId, + prompt: TrimmedNonEmptyString, + title: Schema.optionalKey(TrimmedNonEmptyString), + runtimeMode: Schema.optionalKey(RuntimeMode), + modelSelection: Schema.optionalKey(ModelSelection), +}); +export type FederationRunStartRequest = typeof FederationRunStartRequest.Type; + +/** A run stays owned by the environment that executes it. */ +export const FederationRun = Schema.Struct({ + environmentId: EnvironmentId, + projectId: ProjectId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + title: TrimmedNonEmptyString, + status: FederationRunStatus, + runtimeMode: RuntimeMode, + modelSelection: ModelSelection, + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + /** The latest assistant text, truncated for display. */ + assistantPreview: Schema.NullOr(Schema.String), + turnCount: Schema.Int, +}); +export type FederationRun = typeof FederationRun.Type; + +export const FederationRunEvent = Schema.Struct({ + sequence: Schema.Int, + at: IsoDateTime, + type: TrimmedNonEmptyString, + summary: Schema.String, +}); +export type FederationRunEvent = typeof FederationRunEvent.Type; + +export const FederationRunEventsResponse = Schema.Struct({ + run: FederationRun, + events: Schema.Array(FederationRunEvent), + latestSequence: Schema.Int, +}); +export type FederationRunEventsResponse = typeof FederationRunEventsResponse.Type; + +export const FederationArtifactFile = Schema.Struct({ + path: TrimmedNonEmptyString, + status: TrimmedNonEmptyString, +}); +export type FederationArtifactFile = typeof FederationArtifactFile.Type; + +/** Stable origin identity for something a remote run produced. */ +export const FederationArtifactRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + turnId: TurnId, + kind: Schema.Literal("turn-diff"), + fromTurnCount: Schema.Int, + toTurnCount: Schema.Int, + files: Schema.Array(FederationArtifactFile), +}); +export type FederationArtifactRef = typeof FederationArtifactRef.Type; + +export const FederationArtifactsResponse = Schema.Struct({ + run: FederationRun, + artifacts: Schema.Array(FederationArtifactRef), +}); +export type FederationArtifactsResponse = typeof FederationArtifactsResponse.Type; + +export const FederationArtifactFetchResponse = Schema.Struct({ + ref: FederationArtifactRef, + contentType: Schema.Literal("text/x-diff"), + diff: Schema.String, + fetchedAt: IsoDateTime, +}); +export type FederationArtifactFetchResponse = typeof FederationArtifactFetchResponse.Type; + +export const FederationPeerStatus = Schema.Literals(["online", "offline", "unknown"]); +export type FederationPeerStatus = typeof FederationPeerStatus.Type; + +/** Local view of a paired environment, as shown in Settings. */ +export const FederationPeer = Schema.Struct({ + peerId: EnvironmentId, + label: TrimmedNonEmptyString, + publicKeyFingerprint: TrimmedNonEmptyString, + /** What this environment lets the peer do here. */ + grantedScopes: FederationScopes, + /** What the peer lets this environment do there. */ + allowedScopes: FederationScopes, + transport: Schema.NullOr(FederationTransport), + remoteServerVersion: Schema.NullOr(TrimmedNonEmptyString), + remoteProtocolVersion: Schema.NullOr(PositiveInt), + remoteCapabilities: Schema.Array(FederationCapability), + status: FederationPeerStatus, + lastSeenAt: Schema.NullOr(IsoDateTime), + lastError: Schema.NullOr(Schema.String), + createdAt: IsoDateTime, +}); +export type FederationPeer = typeof FederationPeer.Type; + +export const FederationSnapshot = Schema.Struct({ + environmentId: EnvironmentId, + publicKeyFingerprint: TrimmedNonEmptyString, + protocolVersion: PositiveInt, + peers: Schema.Array(FederationPeer), + updatedAt: IsoDateTime, +}); +export type FederationSnapshot = typeof FederationSnapshot.Type; + +export const FederationCreatePeerCodeInput = Schema.Struct({ + scopes: FederationScopes, + ttlSeconds: Schema.optionalKey(PositiveInt), +}); +export type FederationCreatePeerCodeInput = typeof FederationCreatePeerCodeInput.Type; + +export const FederationPeerCodeResult = Schema.Struct({ + code: TrimmedNonEmptyString, + payload: FederationPeerCodePayload, + expiresAt: IsoDateTime, +}); +export type FederationPeerCodeResult = typeof FederationPeerCodeResult.Type; + +export const FederationAddPeerInput = Schema.Struct({ + code: TrimmedNonEmptyString, + /** Scopes this environment grants the new peer for reverse calls. */ + grantedScopes: FederationScopes, +}); +export type FederationAddPeerInput = typeof FederationAddPeerInput.Type; + +export const FederationPeerIdInput = Schema.Struct({ + peerId: EnvironmentId, +}); +export type FederationPeerIdInput = typeof FederationPeerIdInput.Type; + +export const FederationStartRemoteRunInput = Schema.Struct({ + peerId: EnvironmentId, + projectId: ProjectId, + prompt: TrimmedNonEmptyString, + title: Schema.optionalKey(TrimmedNonEmptyString), + runtimeMode: Schema.optionalKey(RuntimeMode), +}); +export type FederationStartRemoteRunInput = typeof FederationStartRemoteRunInput.Type; + +export const FederationRemoteRunInput = Schema.Struct({ + peerId: EnvironmentId, + threadId: ThreadId, +}); +export type FederationRemoteRunInput = typeof FederationRemoteRunInput.Type; + +export const FederationRemoteArtifactInput = Schema.Struct({ + peerId: EnvironmentId, + threadId: ThreadId, + turnId: TurnId, +}); +export type FederationRemoteArtifactInput = typeof FederationRemoteArtifactInput.Type; + +/** A remote run this environment started, tracked locally with its origin. */ +export const FederationRemoteRun = Schema.Struct({ + peerId: EnvironmentId, + peerLabel: TrimmedNonEmptyString, + run: FederationRun, + events: Schema.Array(FederationRunEvent), + lastSyncedAt: Schema.NullOr(IsoDateTime), + syncError: Schema.NullOr(Schema.String), +}); +export type FederationRemoteRun = typeof FederationRemoteRun.Type; + +export const FederationRemoteRunsSnapshot = Schema.Struct({ + runs: Schema.Array(FederationRemoteRun), + updatedAt: IsoDateTime, +}); +export type FederationRemoteRunsSnapshot = typeof FederationRemoteRunsSnapshot.Type; + +export const FederationErrorCode = Schema.Literals([ + "code-invalid", + "code-expired", + "protocol-incompatible", + "peer-unknown", + "peer-revoked", + "peer-unreachable", + "peer-rejected", + "scope-denied", + "transport-unavailable", + "run-not-found", + "artifact-unavailable", + "internal", +]); +export type FederationErrorCode = typeof FederationErrorCode.Type; + +export class FederationError extends Schema.TaggedErrorClass()( + "FederationError", + { + code: FederationErrorCode, + message: Schema.String, + }, + { httpApiStatus: 400 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(FederationError)(this, { status: 400 }); + } +} + +/** + * Pairing links minted for federation peer codes carry this subject. Only + * `POST /api/federation/pair` may consume one, and the Tailcat listener treats + * it like a connection code for the duration of the pairing window. + */ +export const FEDERATION_PEER_CODE_PAIRING_SUBJECT = "federation-peer-code" as const; +export const FEDERATION_PEER_CODE_DEFAULT_TTL_SECONDS = 300; +export const FEDERATION_SESSION_SUBJECT_PREFIX = "federation:" as const; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index bb5ad7dbf5c3..57507bf1bd57 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -8,6 +8,8 @@ export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; export * from "./desktopAppActivation.ts"; export * from "./remoteAccess.ts"; +export * from "./tailcat.ts"; +export * from "./federation.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; export * from "./provider.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 798d5a777d5d..0524a1960454 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -96,6 +96,12 @@ import type { } from "./browserImport.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; +import type { + DesktopTailcatEnvironmentBootstrap, + DesktopTailcatEnvironmentEnsureInput, + TailcatConnectionDiagnostics, + TailcatRuntimeAvailability, +} from "./tailcat.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import type { ClientSettings, QuitConfirmationMode } from "./settings.ts"; import type { EditorId } from "./editor.ts"; @@ -1093,6 +1099,23 @@ export interface DesktopBridge { ) => Promise; onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise; + /** + * Tailcat transport, managed by the desktop main process like SSH. All + * optional: older desktop shells predate Tailcat, and web/mobile never have + * a process host. + */ + getTailcatRuntimeAvailability?: () => Promise; + ensureTailcatEnvironment?: ( + input: DesktopTailcatEnvironmentEnsureInput, + ) => Promise; + restartTailcatEnvironment?: (connectionId: string) => Promise; + disconnectTailcatEnvironment?: (connectionId: string) => Promise; + getTailcatConnectionDiagnostics?: ( + connectionId: string, + ) => Promise; + probeTailcatConnectionPath?: ( + connectionId: string, + ) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; setTailscaleServeEnabled: (input: { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9b2953a6009d..99f7489381a1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -13,6 +13,32 @@ import { } from "./providerSetup.ts"; import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; +import { + FederationAddPeerInput, + FederationArtifactFetchResponse, + FederationArtifactsResponse, + FederationCreatePeerCodeInput, + FederationError, + FederationPeer, + FederationPeerCodeResult, + FederationPeerIdInput, + FederationProjectsResponse, + FederationRemoteArtifactInput, + FederationRemoteRun, + FederationRemoteRunInput, + FederationRemoteRunsSnapshot, + FederationSnapshot, + FederationStartRemoteRunInput, +} from "./federation.ts"; +import { + TailcatConnectionCodeResult, + TailcatCreateConnectionCodeInput, + TailcatRemoteAccessError, + TailcatRemoteAccessState, + TailcatRenameTrustedPeerInput, + TailcatSetRemoteAccessEnabledInput, + TailcatTrustedPeerIdInput, +} from "./tailcat.ts"; import { AuthAccessStreamError, AuthAccessStreamEvent, @@ -348,6 +374,27 @@ export const WS_METHODS = { sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + // Tailcat remote access (this environment serving itself over Tailcat) + tailcatSubscribeRemoteAccess: "tailcat.subscribeRemoteAccess", + tailcatSetRemoteAccessEnabled: "tailcat.setRemoteAccessEnabled", + tailcatCreateConnectionCode: "tailcat.createConnectionCode", + tailcatRevokeTrustedPeer: "tailcat.revokeTrustedPeer", + tailcatRenameTrustedPeer: "tailcat.renameTrustedPeer", + tailcatRegenerateIdentity: "tailcat.regenerateIdentity", + + // Federation (explicit server-to-server coordination) + federationSubscribePeers: "federation.subscribePeers", + federationCreatePeerCode: "federation.createPeerCode", + federationAddPeer: "federation.addPeer", + federationRemovePeer: "federation.removePeer", + federationRefreshPeer: "federation.refreshPeer", + federationListRemoteProjects: "federation.listRemoteProjects", + federationStartRemoteRun: "federation.startRemoteRun", + federationCancelRemoteRun: "federation.cancelRemoteRun", + federationSubscribeRemoteRuns: "federation.subscribeRemoteRuns", + federationDescribeRemoteArtifacts: "federation.describeRemoteArtifacts", + federationFetchRemoteArtifact: "federation.fetchRemoteArtifact", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -1150,6 +1197,127 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso stream: true, }); +const TailcatRpcError = Schema.Union([TailcatRemoteAccessError, EnvironmentAuthorizationError]); + +export const WsTailcatSubscribeRemoteAccessRpc = Rpc.make(WS_METHODS.tailcatSubscribeRemoteAccess, { + payload: Schema.Struct({}), + success: TailcatRemoteAccessState, + error: TailcatRpcError, + stream: true, +}); + +export const WsTailcatSetRemoteAccessEnabledRpc = Rpc.make( + WS_METHODS.tailcatSetRemoteAccessEnabled, + { + payload: TailcatSetRemoteAccessEnabledInput, + success: TailcatRemoteAccessState, + error: TailcatRpcError, + }, +); + +export const WsTailcatCreateConnectionCodeRpc = Rpc.make(WS_METHODS.tailcatCreateConnectionCode, { + payload: TailcatCreateConnectionCodeInput, + success: TailcatConnectionCodeResult, + error: TailcatRpcError, +}); + +export const WsTailcatRevokeTrustedPeerRpc = Rpc.make(WS_METHODS.tailcatRevokeTrustedPeer, { + payload: TailcatTrustedPeerIdInput, + success: TailcatRemoteAccessState, + error: TailcatRpcError, +}); + +export const WsTailcatRenameTrustedPeerRpc = Rpc.make(WS_METHODS.tailcatRenameTrustedPeer, { + payload: TailcatRenameTrustedPeerInput, + success: TailcatRemoteAccessState, + error: TailcatRpcError, +}); + +export const WsTailcatRegenerateIdentityRpc = Rpc.make(WS_METHODS.tailcatRegenerateIdentity, { + payload: Schema.Struct({}), + success: TailcatRemoteAccessState, + error: TailcatRpcError, +}); + +const FederationRpcError = Schema.Union([FederationError, EnvironmentAuthorizationError]); + +export const WsFederationSubscribePeersRpc = Rpc.make(WS_METHODS.federationSubscribePeers, { + payload: Schema.Struct({}), + success: FederationSnapshot, + error: FederationRpcError, + stream: true, +}); + +export const WsFederationCreatePeerCodeRpc = Rpc.make(WS_METHODS.federationCreatePeerCode, { + payload: FederationCreatePeerCodeInput, + success: FederationPeerCodeResult, + error: FederationRpcError, +}); + +export const WsFederationAddPeerRpc = Rpc.make(WS_METHODS.federationAddPeer, { + payload: FederationAddPeerInput, + success: FederationPeer, + error: FederationRpcError, +}); + +export const WsFederationRemovePeerRpc = Rpc.make(WS_METHODS.federationRemovePeer, { + payload: FederationPeerIdInput, + success: Schema.Void, + error: FederationRpcError, +}); + +export const WsFederationRefreshPeerRpc = Rpc.make(WS_METHODS.federationRefreshPeer, { + payload: FederationPeerIdInput, + success: FederationPeer, + error: FederationRpcError, +}); + +export const WsFederationListRemoteProjectsRpc = Rpc.make(WS_METHODS.federationListRemoteProjects, { + payload: FederationPeerIdInput, + success: FederationProjectsResponse, + error: FederationRpcError, +}); + +export const WsFederationStartRemoteRunRpc = Rpc.make(WS_METHODS.federationStartRemoteRun, { + payload: FederationStartRemoteRunInput, + success: FederationRemoteRun, + error: FederationRpcError, +}); + +export const WsFederationCancelRemoteRunRpc = Rpc.make(WS_METHODS.federationCancelRemoteRun, { + payload: FederationRemoteRunInput, + success: FederationRemoteRun, + error: FederationRpcError, +}); + +export const WsFederationSubscribeRemoteRunsRpc = Rpc.make( + WS_METHODS.federationSubscribeRemoteRuns, + { + payload: Schema.Struct({}), + success: FederationRemoteRunsSnapshot, + error: FederationRpcError, + stream: true, + }, +); + +export const WsFederationDescribeRemoteArtifactsRpc = Rpc.make( + WS_METHODS.federationDescribeRemoteArtifacts, + { + payload: FederationRemoteRunInput, + success: FederationArtifactsResponse, + error: FederationRpcError, + }, +); + +export const WsFederationFetchRemoteArtifactRpc = Rpc.make( + WS_METHODS.federationFetchRemoteArtifact, + { + payload: FederationRemoteArtifactInput, + success: FederationArtifactFetchResponse, + error: FederationRpcError, + }, +); + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, @@ -1267,4 +1435,21 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, + WsTailcatSubscribeRemoteAccessRpc, + WsTailcatSetRemoteAccessEnabledRpc, + WsTailcatCreateConnectionCodeRpc, + WsTailcatRevokeTrustedPeerRpc, + WsTailcatRenameTrustedPeerRpc, + WsTailcatRegenerateIdentityRpc, + WsFederationSubscribePeersRpc, + WsFederationCreatePeerCodeRpc, + WsFederationAddPeerRpc, + WsFederationRemovePeerRpc, + WsFederationRefreshPeerRpc, + WsFederationListRemoteProjectsRpc, + WsFederationStartRemoteRunRpc, + WsFederationCancelRemoteRunRpc, + WsFederationSubscribeRemoteRunsRpc, + WsFederationDescribeRemoteArtifactsRpc, + WsFederationFetchRemoteArtifactRpc, ); diff --git a/packages/contracts/src/tailcat.ts b/packages/contracts/src/tailcat.ts new file mode 100644 index 000000000000..e7e0560608ff --- /dev/null +++ b/packages/contracts/src/tailcat.ts @@ -0,0 +1,240 @@ +import * as Schema from "effect/Schema"; +import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +import { + AuthSessionId, + EnvironmentId, + IsoDateTime, + PortSchema, + PositiveInt, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +/** + * Tailcat is a transport: WireGuard tunnels with NAT traversal and DERP relay + * fallback, driven by the `tailcat` CLI. Everything here describes how T3 + * wraps it. None of it replaces T3 pairing, sessions, or scopes. + */ + +/** Versioned `t3c://tailcat/` connection code. */ +export const TAILCAT_CONNECTION_CODE_VERSION = 1 as const; + +/** + * Pairing links minted for Tailcat connection codes carry this subject. The + * server relaxes its Tailcat allowlist only while such a link is active, and + * only a token exchange that consumed one of them may register a trusted peer. + */ +export const TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT = "tailcat-connection-code" as const; + +/** Default lifetime of a connection code's one-time pairing credential. */ +export const TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS = 300; + +/** A tailcat address: `tc` + base64url(CBOR(server key, disco key, DERP region)). */ +export const TailcatAddress = TrimmedNonEmptyString.check( + Schema.isPattern(/^tc[A-Za-z0-9_-]{16,}$/u), +); +export type TailcatAddress = typeof TailcatAddress.Type; + +/** A tailcat node public key in its text form. */ +export const TailcatNodeKey = TrimmedNonEmptyString.check( + Schema.isPattern(/^nodekey:[0-9a-f]{64}$/u), +); +export type TailcatNodeKey = typeof TailcatNodeKey.Type; + +export const TailcatConnectionCodePayload = Schema.Struct({ + v: Schema.Literal(TAILCAT_CONNECTION_CODE_VERSION), + transport: Schema.Literal("tailcat"), + address: TailcatAddress, + /** The T3 server's own listening port behind the tunnel. */ + port: PortSchema, + environmentId: Schema.optionalKey(EnvironmentId), + name: Schema.optionalKey(TrimmedNonEmptyString), + serverVersion: Schema.optionalKey(TrimmedNonEmptyString), + /** One-time, short-lived T3 pairing credential. Never a reusable secret. */ + pairingToken: Schema.optionalKey(TrimmedNonEmptyString), + expiresAt: Schema.optionalKey(IsoDateTime), +}); +export type TailcatConnectionCodePayload = typeof TailcatConnectionCodePayload.Type; + +export const TailcatRuntimeSource = Schema.Literals(["bundled", "override", "system"]); +export type TailcatRuntimeSource = typeof TailcatRuntimeSource.Type; + +export const TailcatRuntimeInfo = Schema.Struct({ + executablePath: TrimmedNonEmptyString, + source: TailcatRuntimeSource, + version: TrimmedNonEmptyString, + pinnedVersion: TrimmedNonEmptyString, + compatible: Schema.Boolean, +}); +export type TailcatRuntimeInfo = typeof TailcatRuntimeInfo.Type; + +export const TailcatFailureCode = Schema.Literals([ + "binary-missing", + "binary-not-executable", + "version-incompatible", + "identity-failed", + "startup-failed", + "process-exited", + "timeout", + "address-invalid", + "port-in-use", + "remote-unavailable", + "unknown", +]); +export type TailcatFailureCode = typeof TailcatFailureCode.Type; + +export const TailcatFailure = Schema.Struct({ + code: TailcatFailureCode, + message: TrimmedNonEmptyString, + at: IsoDateTime, +}); +export type TailcatFailure = typeof TailcatFailure.Type; + +export const TailcatRuntimeAvailability = Schema.Union([ + Schema.Struct({ available: Schema.Literal(true), runtime: TailcatRuntimeInfo }), + Schema.Struct({ + available: Schema.Literal(false), + code: TailcatFailureCode, + message: TrimmedNonEmptyString, + }), +]); +export type TailcatRuntimeAvailability = typeof TailcatRuntimeAvailability.Type; + +export const TailcatTrustedPeer = Schema.Struct({ + id: TrimmedNonEmptyString, + nodeKey: TailcatNodeKey, + label: TrimmedNonEmptyString, + createdAt: IsoDateTime, + lastSeenAt: Schema.NullOr(IsoDateTime), + /** T3 sessions issued while this peer paired; revoked together with it. */ + sessionIds: Schema.Array(AuthSessionId), +}); +export type TailcatTrustedPeer = typeof TailcatTrustedPeer.Type; + +export const TailcatServeStatus = Schema.Literals([ + "disabled", + "starting", + "ready", + "restarting", + "error", + "unavailable", +]); +export type TailcatServeStatus = typeof TailcatServeStatus.Type; + +export const TailcatRemoteAccessState = Schema.Struct({ + enabled: Schema.Boolean, + status: TailcatServeStatus, + address: Schema.NullOr(TailcatAddress), + remotePort: Schema.NullOr(PortSchema), + /** True while a connection code is active and unknown peers may reach the listener. */ + pairingOpen: Schema.Boolean, + trustedPeers: Schema.Array(TailcatTrustedPeer), + runtime: Schema.NullOr(TailcatRuntimeInfo), + identityFingerprint: Schema.NullOr(TrimmedNonEmptyString), + lastError: Schema.NullOr(TailcatFailure), + updatedAt: IsoDateTime, +}); +export type TailcatRemoteAccessState = typeof TailcatRemoteAccessState.Type; + +export const TailcatSetRemoteAccessEnabledInput = Schema.Struct({ + enabled: Schema.Boolean, +}); +export type TailcatSetRemoteAccessEnabledInput = typeof TailcatSetRemoteAccessEnabledInput.Type; + +export const TailcatCreateConnectionCodeInput = Schema.Struct({ + label: Schema.optionalKey(TrimmedNonEmptyString), + ttlSeconds: Schema.optionalKey(PositiveInt), +}); +export type TailcatCreateConnectionCodeInput = typeof TailcatCreateConnectionCodeInput.Type; + +export const TailcatConnectionCodeResult = Schema.Struct({ + code: TrimmedNonEmptyString, + payload: TailcatConnectionCodePayload, + pairingLinkId: TrimmedNonEmptyString, + expiresAt: IsoDateTime, +}); +export type TailcatConnectionCodeResult = typeof TailcatConnectionCodeResult.Type; + +export const TailcatTrustedPeerIdInput = Schema.Struct({ + peerId: TrimmedNonEmptyString, +}); +export type TailcatTrustedPeerIdInput = typeof TailcatTrustedPeerIdInput.Type; + +export const TailcatRenameTrustedPeerInput = Schema.Struct({ + peerId: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, +}); +export type TailcatRenameTrustedPeerInput = typeof TailcatRenameTrustedPeerInput.Type; + +export class TailcatRemoteAccessError extends Schema.TaggedErrorClass()( + "TailcatRemoteAccessError", + { + code: TailcatFailureCode, + message: Schema.String, + }, + { httpApiStatus: 400 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(TailcatRemoteAccessError)(this, { status: 400 }); + } +} + +/** Which way packets flow between this client and the remote tailcat server. */ +export const TailcatPathKind = Schema.Literals(["direct", "relay", "unknown"]); +export type TailcatPathKind = typeof TailcatPathKind.Type; + +export const TailcatPathProbe = Schema.Struct({ + kind: TailcatPathKind, + via: Schema.NullOr(TrimmedNonEmptyString), + latencyMs: Schema.NullOr(Schema.Number), + measuredAt: IsoDateTime, +}); +export type TailcatPathProbe = typeof TailcatPathProbe.Type; + +export const TailcatForwardStatus = Schema.Literals(["starting", "ready", "failed", "stopped"]); +export type TailcatForwardStatus = typeof TailcatForwardStatus.Type; + +/** Client-side transport diagnostics for one saved Tailcat environment. */ +export const TailcatConnectionDiagnostics = Schema.Struct({ + connectionId: TrimmedNonEmptyString, + address: TailcatAddress, + remotePort: PortSchema, + status: TailcatForwardStatus, + localEndpoint: Schema.NullOr(TrimmedNonEmptyString), + pid: Schema.NullOr(Schema.Int), + runtime: Schema.NullOr(TailcatRuntimeInfo), + clientNodeKey: Schema.NullOr(TailcatNodeKey), + path: Schema.NullOr(TailcatPathProbe), + startedAt: Schema.NullOr(IsoDateTime), + restartCount: Schema.Int, + lastError: Schema.NullOr(TailcatFailure), + /** Bounded, redacted tail of the forwarder's output. */ + recentOutput: Schema.Array(Schema.String), +}); +export type TailcatConnectionDiagnostics = typeof TailcatConnectionDiagnostics.Type; + +export const DesktopTailcatEnvironmentEnsureInputSchema = Schema.Struct({ + connectionId: TrimmedNonEmptyString, + address: TailcatAddress, + remotePort: PortSchema, +}); +export type DesktopTailcatEnvironmentEnsureInput = + typeof DesktopTailcatEnvironmentEnsureInputSchema.Type; + +export const DesktopTailcatEnvironmentBootstrapSchema = Schema.Struct({ + connectionId: TrimmedNonEmptyString, + address: TailcatAddress, + remotePort: PortSchema, + localPort: PortSchema, + httpBaseUrl: TrimmedNonEmptyString, + wsBaseUrl: TrimmedNonEmptyString, + clientNodeKey: TailcatNodeKey, +}); +export type DesktopTailcatEnvironmentBootstrap = + typeof DesktopTailcatEnvironmentBootstrapSchema.Type; + +export const DesktopTailcatConnectionIdInputSchema = Schema.Struct({ + connectionId: TrimmedNonEmptyString, +}); +export type DesktopTailcatConnectionIdInput = typeof DesktopTailcatConnectionIdInputSchema.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index f6d17fa209cd..450e7181fb44 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -119,6 +119,10 @@ "types": "./src/remote.ts", "import": "./src/remote.ts" }, + "./t3ConnectionCode": { + "types": "./src/t3ConnectionCode.ts", + "import": "./src/t3ConnectionCode.ts" + }, "./relaySigning": { "types": "./src/relaySigning.ts", "import": "./src/relaySigning.ts" diff --git a/packages/shared/src/t3ConnectionCode.test.ts b/packages/shared/src/t3ConnectionCode.test.ts new file mode 100644 index 000000000000..30a069a4059d --- /dev/null +++ b/packages/shared/src/t3ConnectionCode.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + T3ConnectionCodeInvalidError, + decodeFederationPeerCode, + decodeTailcatConnectionCode, + encodeFederationPeerCode, + encodeTailcatConnectionCode, + isT3ConnectionCode, + peekT3ConnectionCodeKind, + redactT3ConnectionCode, +} from "./t3ConnectionCode.ts"; + +const ADDRESS = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; + +describe("t3ConnectionCode", () => { + it("round-trips a tailcat connection code", () => { + const code = encodeTailcatConnectionCode({ + v: 1, + transport: "tailcat", + address: ADDRESS, + port: 3773, + name: "gpu-box", + pairingToken: "one-time-secret", + expiresAt: "2026-09-03T12:00:00.000Z", + }); + expect(code.startsWith("t3c://tailcat/")).toBe(true); + expect(code).not.toContain("one-time-secret"); + expect(decodeTailcatConnectionCode(code)).toEqual({ + v: 1, + transport: "tailcat", + address: ADDRESS, + port: 3773, + name: "gpu-box", + pairingToken: "one-time-secret", + expiresAt: "2026-09-03T12:00:00.000Z", + }); + }); + + it("tolerates surrounding whitespace and case in the scheme", () => { + const code = encodeTailcatConnectionCode({ + v: 1, + transport: "tailcat", + address: ADDRESS, + port: 3773, + }); + expect(decodeTailcatConnectionCode(` ${code.replace("t3c://", "T3C://")}\n`).port).toBe(3773); + }); + + it("rejects text that is not a code with an actionable reason", () => { + expect(() => decodeTailcatConnectionCode("https://example.com/pair#token=x")).toThrowError( + T3ConnectionCodeInvalidError, + ); + try { + decodeTailcatConnectionCode("hello"); + } catch (error) { + expect(error).toBeInstanceOf(T3ConnectionCodeInvalidError); + expect((error as T3ConnectionCodeInvalidError).reason).toBe("not-a-code"); + } + }); + + it("rejects damaged payloads", () => { + try { + decodeTailcatConnectionCode("t3c://tailcat/not-base64!!"); + } catch (error) { + expect((error as T3ConnectionCodeInvalidError).reason).toBe("malformed-payload"); + } + const validPrefix = encodeTailcatConnectionCode({ + v: 1, + transport: "tailcat", + address: ADDRESS, + port: 3773, + }); + try { + decodeTailcatConnectionCode(validPrefix.slice(0, validPrefix.length - 12)); + } catch (error) { + expect((error as T3ConnectionCodeInvalidError).reason).toBe("malformed-payload"); + } + }); + + it("reports unsupported future versions distinctly", () => { + const payload = Buffer.from( + JSON.stringify({ v: 2, transport: "tailcat", address: ADDRESS, port: 3773 }), + ).toString("base64url"); + try { + decodeTailcatConnectionCode(`t3c://tailcat/${payload}`); + } catch (error) { + expect((error as T3ConnectionCodeInvalidError).reason).toBe("unsupported-version"); + } + }); + + it("rejects the wrong kind of code", () => { + const peer = encodeFederationPeerCode({ + v: 1, + kind: "peer", + protocolVersion: 1, + environmentId: "env-b" as never, + publicKey: "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----", + label: "gpu-box", + transport: { tailcat: { address: ADDRESS, port: 3773 } }, + token: "one-time", + scopes: ["environment.read", "projects.read"], + expiresAt: "2026-09-03T12:00:00.000Z", + }); + expect(peekT3ConnectionCodeKind(peer)).toBe("peer"); + expect(decodeFederationPeerCode(peer).scopes).toEqual(["environment.read", "projects.read"]); + try { + decodeTailcatConnectionCode(peer); + } catch (error) { + expect((error as T3ConnectionCodeInvalidError).reason).toBe("kind-mismatch"); + } + }); + + it("recognizes codes and redacts them for logs", () => { + const code = encodeTailcatConnectionCode({ + v: 1, + transport: "tailcat", + address: ADDRESS, + port: 3773, + pairingToken: "one-time-secret", + }); + expect(isT3ConnectionCode(code)).toBe(true); + expect(isT3ConnectionCode("tc123")).toBe(false); + const redacted = redactT3ConnectionCode(code); + expect(redacted.startsWith("t3c://tailcat/…")).toBe(true); + expect(redacted.length).toBeLessThan(40); + expect(peekT3ConnectionCodeKind("nope")).toBeNull(); + }); +}); diff --git a/packages/shared/src/t3ConnectionCode.ts b/packages/shared/src/t3ConnectionCode.ts new file mode 100644 index 000000000000..a60095877a9e --- /dev/null +++ b/packages/shared/src/t3ConnectionCode.ts @@ -0,0 +1,170 @@ +import { FederationPeerCodePayload, TailcatConnectionCodePayload } from "@t3tools/contracts"; +import * as Encoding from "effect/Encoding"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +/** + * T3-owned connection codes. A code is `t3c:///`. + * The payload is versioned per kind; parsers reject unknown versions rather + * than guessing. Codes are copyable text and QR content, so they are kept + * URL-safe and free of characters that break on paste. + */ +export const T3_CONNECTION_CODE_SCHEME = "t3c:"; + +export const T3ConnectionCodeKind = Schema.Literals(["tailcat", "peer"]); +export type T3ConnectionCodeKind = typeof T3ConnectionCodeKind.Type; + +export class T3ConnectionCodeInvalidError extends Schema.TaggedErrorClass()( + "T3ConnectionCodeInvalidError", + { + reason: Schema.Literals([ + "not-a-code", + "unknown-kind", + "malformed-payload", + "unsupported-version", + "kind-mismatch", + ]), + kind: Schema.optionalKey(Schema.String), + /** For kind mismatches: the kind the code actually is. */ + actual: Schema.optionalKey(Schema.String), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "not-a-code": + return "This is not a T3 connection code. Paste the full code, starting with t3c://."; + case "unknown-kind": + return `This T3 connection code kind (${this.kind ?? "unknown"}) is not supported by this app.`; + case "malformed-payload": + return "This T3 connection code is incomplete or damaged. Copy it again from the other machine."; + case "unsupported-version": + return "This T3 connection code was made by a newer version of T3 Code. Update this app to use it."; + case "kind-mismatch": + if (this.kind === "tailcat" && this.actual === "peer") { + return "This is a federation peer code, not a Tailcat connection code. Add it under Settings → Connections → Federation → Add peer."; + } + if (this.kind === "peer" && this.actual === "tailcat") { + return "This is a Tailcat connection code, not a federation peer code. Paste it under Add environment → Tailcat."; + } + return `This is a ${this.actual ?? "different kind of"} code, not a ${this.kind ?? "matching"} code.`; + } + } +} + +const TailcatCodeJson = Schema.fromJsonString(TailcatConnectionCodePayload); +const PeerCodeJson = Schema.fromJsonString(FederationPeerCodePayload); +const encodeTailcatCodeJson = Schema.encodeSync(TailcatCodeJson); +const encodePeerCodeJson = Schema.encodeSync(PeerCodeJson); +const decodeTailcatCodeJson = Schema.decodeResult(TailcatCodeJson); +const decodePeerCodeJson = Schema.decodeResult(PeerCodeJson); +const isCodeKind = Schema.is(T3ConnectionCodeKind); + +const VersionProbeJson = Schema.fromJsonString(Schema.Struct({ v: Schema.Unknown })); +const decodeVersionProbe = Schema.decodeResult(VersionProbeJson); + +interface SplitCode { + readonly kind: T3ConnectionCodeKind; + readonly payloadJson: string; +} + +function splitCode(raw: string): SplitCode { + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith(T3_CONNECTION_CODE_SCHEME)) { + throw new T3ConnectionCodeInvalidError({ reason: "not-a-code" }); + } + const rest = trimmed.slice(T3_CONNECTION_CODE_SCHEME.length).replace(/^\/\//u, ""); + const slash = rest.indexOf("/"); + if (slash <= 0) { + throw new T3ConnectionCodeInvalidError({ reason: "not-a-code" }); + } + const kind = rest.slice(0, slash).toLowerCase(); + const encodedPayload = rest.slice(slash + 1).replace(/[\s/]+$/u, ""); + if (!isCodeKind(kind)) { + throw new T3ConnectionCodeInvalidError({ reason: "unknown-kind", kind }); + } + const decoded = Encoding.decodeBase64UrlString(encodedPayload); + if (Result.isFailure(decoded)) { + throw new T3ConnectionCodeInvalidError({ + reason: "malformed-payload", + kind, + cause: decoded.failure, + }); + } + return { kind, payloadJson: decoded.success }; +} + +function failVersionOrPayload( + kind: T3ConnectionCodeKind, + payloadJson: string, + cause: unknown, +): never { + const probe = decodeVersionProbe(payloadJson); + if (Result.isSuccess(probe) && typeof probe.success.v === "number" && probe.success.v > 1) { + throw new T3ConnectionCodeInvalidError({ reason: "unsupported-version", kind, cause }); + } + throw new T3ConnectionCodeInvalidError({ reason: "malformed-payload", kind, cause }); +} + +export function isT3ConnectionCode(raw: string): boolean { + return raw.trim().toLowerCase().startsWith(T3_CONNECTION_CODE_SCHEME); +} + +/** Reads the code kind without validating the payload. Null for non-codes. */ +export function peekT3ConnectionCodeKind(raw: string): T3ConnectionCodeKind | null { + try { + return splitCode(raw).kind; + } catch { + return null; + } +} + +export function encodeTailcatConnectionCode(payload: TailcatConnectionCodePayload): string { + return `${T3_CONNECTION_CODE_SCHEME}//tailcat/${Encoding.encodeBase64Url(encodeTailcatCodeJson(payload))}`; +} + +export function decodeTailcatConnectionCode(raw: string): TailcatConnectionCodePayload { + const { kind, payloadJson } = splitCode(raw); + if (kind !== "tailcat") { + throw new T3ConnectionCodeInvalidError({ + reason: "kind-mismatch", + kind: "tailcat", + actual: kind, + }); + } + const decoded = decodeTailcatCodeJson(payloadJson); + if (Result.isFailure(decoded)) { + return failVersionOrPayload(kind, payloadJson, decoded.failure); + } + return decoded.success; +} + +export function encodeFederationPeerCode(payload: FederationPeerCodePayload): string { + return `${T3_CONNECTION_CODE_SCHEME}//peer/${Encoding.encodeBase64Url(encodePeerCodeJson(payload))}`; +} + +export function decodeFederationPeerCode(raw: string): FederationPeerCodePayload { + const { kind, payloadJson } = splitCode(raw); + if (kind !== "peer") { + throw new T3ConnectionCodeInvalidError({ reason: "kind-mismatch", kind: "peer", actual: kind }); + } + const decoded = decodePeerCodeJson(payloadJson); + if (Result.isFailure(decoded)) { + return failVersionOrPayload(kind, payloadJson, decoded.failure); + } + return decoded.success; +} + +/** + * Connection codes carry a one-time pairing credential. Logs, diagnostics and + * error messages must never include that part, so this renders a code with the + * secret stripped and the middle elided. + */ +export function redactT3ConnectionCode(raw: string): string { + const trimmed = raw.trim(); + if (!isT3ConnectionCode(trimmed)) { + return ""; + } + const kind = peekT3ConnectionCodeKind(trimmed) ?? "unknown"; + return `${T3_CONNECTION_CODE_SCHEME}//${kind}/…${trimmed.slice(-6)}`; +} From 28980c05a3875a7a3e63352b9d86aa7f96ea0ef5 Mon Sep 17 00:00:00 2001 From: Bear Huddleston Date: Thu, 3 Sep 2026 19:08:13 -0500 Subject: [PATCH 02/12] feat(tailcat): add the pinned Tailcat runtime package and manifest Co-Authored-By: Claude Fable 5.1 --- .gitignore | 2 + apps/desktop/package.json | 1 + apps/server/package.json | 1 + native/tailcat/LICENSE | 28 + native/tailcat/README.md | 93 +++ native/tailcat/manifest.json | 50 ++ packages/tailcat/package.json | 42 ++ packages/tailcat/src/address.test.ts | 55 ++ packages/tailcat/src/address.ts | 217 ++++++ packages/tailcat/src/backoff.test.ts | 20 + packages/tailcat/src/backoff.ts | 35 + packages/tailcat/src/errors.ts | 176 +++++ packages/tailcat/src/manifest.test.ts | 41 ++ packages/tailcat/src/manifest.ts | 62 ++ packages/tailcat/src/runtime.test.ts | 673 ++++++++++++++++++ packages/tailcat/src/runtime.ts | 953 ++++++++++++++++++++++++++ packages/tailcat/tsconfig.json | 7 + pnpm-lock.yaml | 31 + 18 files changed, 2487 insertions(+) create mode 100644 native/tailcat/LICENSE create mode 100644 native/tailcat/README.md create mode 100644 native/tailcat/manifest.json create mode 100644 packages/tailcat/package.json create mode 100644 packages/tailcat/src/address.test.ts create mode 100644 packages/tailcat/src/address.ts create mode 100644 packages/tailcat/src/backoff.test.ts create mode 100644 packages/tailcat/src/backoff.ts create mode 100644 packages/tailcat/src/errors.ts create mode 100644 packages/tailcat/src/manifest.test.ts create mode 100644 packages/tailcat/src/manifest.ts create mode 100644 packages/tailcat/src/runtime.test.ts create mode 100644 packages/tailcat/src/runtime.ts create mode 100644 packages/tailcat/tsconfig.json diff --git a/.gitignore b/.gitignore index 57262578a786..32bbc0ecf48c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ apps/mobile/.showcase/ artifacts/app-store/screenshots/ .github/pr-assets/ native/**/target/ +native/tailcat/dist/ +apps/desktop/prod-resources/tailcat/ node_modules/ .alchemy/ *.log diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cb587e152aaa..058f913a7a15 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/ssh": "workspace:*", + "@t3tools/tailcat": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "43.4.1", diff --git a/apps/server/package.json b/apps/server/package.json index ca74368348ea..3574b144afe8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -39,6 +39,7 @@ "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/tailcat": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "1.3.14", diff --git a/native/tailcat/LICENSE b/native/tailcat/LICENSE new file mode 100644 index 000000000000..ed6e4bb6d6ba --- /dev/null +++ b/native/tailcat/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2020 Tailscale Inc & contributors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/native/tailcat/README.md b/native/tailcat/README.md new file mode 100644 index 000000000000..3eba2969efa7 --- /dev/null +++ b/native/tailcat/README.md @@ -0,0 +1,93 @@ +# Tailcat runtime + +T3 Code bundles the upstream [Tailcat](https://github.com/tailscale/tailcat) CLI as the transport +behind Tailcat environments and federation. The binary is pinned by `manifest.json` in this +directory and verified by SHA-256 before it is staged into any artifact. Nothing downloads a +"latest" binary at runtime; the runtime only ever runs the executable this manifest describes. + +## Provenance + +- Upstream: `github.com/tailscale/tailcat`, tag `v` from `manifest.json`, which also pins + the commit that tag pointed at when the pin was taken (`source.commit`). +- Linux and Windows: the official release archives. `assets..sha256` is the digest of + the whole archive, computed from the downloaded bytes and cross-checked against the release's + `checksums.txt` when the pin is bumped. The fetch script refuses to open an archive whose digest + differs from the manifest. +- macOS: upstream publishes no macOS binaries. The fetch script clones the pinned tag, refuses to + build unless `HEAD` is `source.commit`, and compiles `source.package` with `CGO_ENABLED=0`, + `-trimpath`, `-buildvcs=false`, the upstream `build-tags.txt`, and the upstream `ldflags`. The + output is a function of the source and the Go toolchain (`source.goVersion`) alone, so the darwin + binaries cross-compiled by the Linux npm publisher match the ones built on the macOS runners. +- License: BSD-3-Clause. `LICENSE` in this directory is the upstream text (refreshed by `--update`), + and every staged runtime directory carries the upstream copy as `LICENSE.txt` next to the binary, + which satisfies the binary-redistribution clause for the desktop app and the npm package. + +## Staged layout + +`node scripts/fetch-tailcat.ts` writes `native/tailcat/dist//` (gitignored): + +| File | Purpose | +| ------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `tailcat` / `tailcat.exe` | the executable, mode 0755 | +| `provenance.json` | version, platform key, binary digest, and the archive URL and digest or the source commit and Go toolchain | +| `LICENSE.txt` | upstream BSD-3-Clause text | + +Platform keys are `linux-x64`, `linux-arm64`, `win32-x64`, `win32-arm64`, `darwin-arm64`, and +`darwin-x64`, the same vocabulary as `packages/tailcat/src/manifest.ts`. + +The manifest pins archive digests, not binary digests, so `provenance.json` is what makes a staged +binary checkable later: `--verify` confirms the provenance names the pinned version and archive +digest (or pinned commit) and that the binary still hashes to the digest recorded when it was +extracted. The packaging steps (`scripts/build-desktop-artifact.ts` and +`apps/server/scripts/cli.ts publish`) run the same check before copying a binary into an artifact, +so a stale `dist/` after a pin bump fails the build instead of shipping the previous version. + +Companion files carry extensions on purpose: on macOS the signing pass codesigns every +extension-less file under `Contents/`, which is how `tailcat` gets signed alongside the resource +monitor, and a bare `LICENSE` would be handed to codesign as well. + +## Local development + +```sh +node scripts/fetch-tailcat.ts # this machine, into native/tailcat/dist// +node scripts/fetch-tailcat.ts --platform linux-arm64 # another platform +node scripts/fetch-tailcat.ts --all # every pinned key; darwin needs Go +node scripts/fetch-tailcat.ts --verify # re-check what is staged; non-zero on drift +node scripts/fetch-tailcat.ts --verify --manifest-only # validate manifest.json only (CI runs this) +``` + +`vp run fetch:tailcat` runs the same script. Fetching is idempotent: a directory that still verifies +is left alone, so delete it to refetch. `--build-from-source` compiles any platform from the pinned +tag instead of downloading (implied for darwin), `--out ` redirects the output root, and +`--verbose` streams git and go output. + +The dev server and desktop app find the binary under `native/tailcat/dist/`. A system `tailcat` +on `PATH` also works, and `T3CODE_TAILCAT_BINARY=/path/to/tailcat` overrides both. + +## Where the binary ships + +- Desktop: `scripts/build-desktop-artifact.ts` copies `native/tailcat/dist//` for the + build's platform keys (both darwin keys for a universal app) into + `apps/desktop/prod-resources/tailcat/`, which electron-builder ships as + `resources/tailcat//`. Windows packaging rejects a payload without it. +- CLI (`npx t3`): `apps/server/scripts/cli.ts publish` stages every pinned platform key into + `apps/server/dist/tailcat//`, matching how the resource monitor ships in the one + platform-independent npm package. Release CI fetches all keys first (darwin is cross-compiled). + +## Upgrading + +1. `node scripts/fetch-tailcat.ts --update `. It downloads the new release archives and + computes their digests, cross-checks them against upstream's `checksums.txt`, resolves the tag's + commit with `git ls-remote`, reads the Go toolchain line from upstream's `go.mod`, refreshes + `LICENSE`, rewrites `manifest.json`, and prints a field-by-field summary of what changed. +2. Review the upstream changelog. Tailcat makes no CLI stability promises; check + `packages/tailcat/src/runtime.ts` for the flags and output T3 relies on (`--json`, `--key`, + `serve --allow`, `forward :`, `genkey --client`, `printpub`, `ping`). +3. Bump `TAILCAT_COMPATIBLE_RANGE` in `packages/tailcat/src/manifest.ts` if the major or minor + changed, then run the opt-in real-binary test: + `T3CODE_TAILCAT_E2E=1 vp test run packages/tailcat/src/runtime.e2e.test.ts`. +4. `node scripts/fetch-tailcat.ts --all` (or at least `--platform `) to restage + locally. Directories staged from the previous pin fail `--verify` and every packaging step until + they are refetched. +5. Commit `manifest.json` and `LICENSE`. CI validates the manifest on every pull request and fetches + the pinned version on every release build; no workflow edits are needed for a version bump. diff --git a/native/tailcat/manifest.json b/native/tailcat/manifest.json new file mode 100644 index 000000000000..9f7cd08cc028 --- /dev/null +++ b/native/tailcat/manifest.json @@ -0,0 +1,50 @@ +{ + "$comment": "Pinned Tailcat runtime. Update with `node scripts/fetch-tailcat.ts --update `; see native/tailcat/README.md.", + "name": "tailcat", + "repository": "https://github.com/tailscale/tailcat", + "license": "BSD-3-Clause", + "version": "0.5.0", + "releaseBaseUrl": "https://github.com/tailscale/tailcat/releases/download/v0.5.0", + "assets": { + "linux-x64": { + "file": "tailcat_0.5.0_linux_amd64.tar.gz", + "sha256": "62954edcabbf360171a921bb4446eb03ed3b95ad529a4884671abb902e232bf4", + "executable": "tailcat" + }, + "linux-arm64": { + "file": "tailcat_0.5.0_linux_arm64.tar.gz", + "sha256": "020be04b7136df7660c1aa5edf38b6bcf82854349383ed19c4b386290bc2ed4f", + "executable": "tailcat" + }, + "win32-x64": { + "file": "tailcat_0.5.0_windows_amd64.zip", + "sha256": "47c2a22eff596dc184642779b8ba9988ca554b0f177ee1188bc4913253b18430", + "executable": "tailcat.exe" + }, + "win32-arm64": { + "file": "tailcat_0.5.0_windows_arm64.zip", + "sha256": "27601f743dc8e2332b82022b0e1d54a8ba73c8e0eaa12e0ad0d810f026341c25", + "executable": "tailcat.exe" + } + }, + "source": { + "$comment": "macOS has no upstream binaries; darwin builds clone tag v, verify HEAD is `commit`, and compile with Go and the upstream build tags. `url`/`sha256` describe the matching GitHub source tarball.", + "url": "https://github.com/tailscale/tailcat/archive/refs/tags/v0.5.0.tar.gz", + "sha256": "a2177d257ac7a02d8ba0fdfcfa341113d97ea0cf7597dbb0fff851d8c341d8e9", + "commit": "cc6db4948e324e2c3434155a13d0d787ec9df7a4", + "goVersion": "1.27", + "package": "./cmd/tailcat", + "buildTagsFile": "build-tags.txt", + "ldflags": "-s -w -X main.version=v0.5.0" + }, + "darwinTargets": { + "darwin-arm64": { + "goarch": "arm64", + "executable": "tailcat" + }, + "darwin-x64": { + "goarch": "amd64", + "executable": "tailcat" + } + } +} diff --git a/packages/tailcat/package.json b/packages/tailcat/package.json new file mode 100644 index 000000000000..f3acd062b9dc --- /dev/null +++ b/packages/tailcat/package.json @@ -0,0 +1,42 @@ +{ + "name": "@t3tools/tailcat", + "private": true, + "type": "module", + "exports": { + "./address": { + "types": "./src/address.ts", + "import": "./src/address.ts" + }, + "./backoff": { + "types": "./src/backoff.ts", + "import": "./src/backoff.ts" + }, + "./errors": { + "types": "./src/errors.ts", + "import": "./src/errors.ts" + }, + "./manifest": { + "types": "./src/manifest.ts", + "import": "./src/manifest.ts" + }, + "./runtime": { + "types": "./src/runtime.ts", + "import": "./src/runtime.ts" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "vp test run" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/tailcat/src/address.test.ts b/packages/tailcat/src/address.test.ts new file mode 100644 index 000000000000..ccec7f3e30d9 --- /dev/null +++ b/packages/tailcat/src/address.test.ts @@ -0,0 +1,55 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeTailcatAddress, + isTailcatAddressSyntax, + isTailcatNodeKey, + tailcatKeyFingerprint, +} from "./address.ts"; + +// Captured from a real `tailcat serve` run (server key 7ea7…ff32, region 302). +const ADDRESS = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; + +describe("tailcat address", () => { + it("decodes the server key, disco key and region from a real address", () => { + const decoded = decodeTailcatAddress(ADDRESS); + expect(Result.isSuccess(decoded)).toBe(true); + if (Result.isSuccess(decoded)) { + expect(decoded.success.serverNodeKey).toBe( + "nodekey:7ea771638ceaf39423e9d30f1a4f7a7dc436759cd697597fdd012c430854ff32", + ); + expect(decoded.success.serverDiscoKey).toBe( + "discokey:d19e1dbe8b291b67b5b8165d20de7069c139d1de25d508a282938a69b38ee774", + ); + expect(decoded.success.regionId).toBe(302); + expect(decoded.success.hasEmbeddedRegions).toBe(false); + } + }); + + it("rejects malformed addresses without throwing", () => { + expect(Result.isFailure(decodeTailcatAddress("tcgarbage"))).toBe(true); + expect(Result.isFailure(decodeTailcatAddress("https://example.com"))).toBe(true); + expect(Result.isFailure(decodeTailcatAddress(`${ADDRESS}AAAA`))).toBe(true); + const truncated = decodeTailcatAddress(ADDRESS.slice(0, 40)); + expect(Result.isFailure(truncated)).toBe(true); + }); + + it("validates syntax and node keys", () => { + expect(isTailcatAddressSyntax(ADDRESS)).toBe(true); + expect(isTailcatAddressSyntax("tc")).toBe(false); + expect( + isTailcatNodeKey("nodekey:9ab555a4a588b75d2054adb683db82461bb6c707d43e8ba39439f8eb1e821503"), + ).toBe(true); + expect(isTailcatNodeKey("nodekey:zz")).toBe(false); + }); + + it("renders short fingerprints", () => { + expect( + tailcatKeyFingerprint( + "nodekey:9ab555a4a588b75d2054adb683db82461bb6c707d43e8ba39439f8eb1e821503", + ), + ).toBe("9ab5·55a4·1503"); + }); +}); diff --git a/packages/tailcat/src/address.ts b/packages/tailcat/src/address.ts new file mode 100644 index 000000000000..a23d93ba8199 --- /dev/null +++ b/packages/tailcat/src/address.ts @@ -0,0 +1,217 @@ +import type { TailcatAddress, TailcatNodeKey } from "@t3tools/contracts"; +import * as Encoding from "effect/Encoding"; +import * as Result from "effect/Result"; + +import { TailcatAddressInvalidError } from "./errors.ts"; + +/** + * Offline decoding of a tailcat address so the UI can validate a pasted code + * and show a key fingerprint before spawning anything. The wire format is + * `tc` + base64url(CBOR map) with single-character keys: + * p = server node public key (32 bytes), k = disco public key (32 bytes), + * i = DERP region id, r = embedded DERP regions. + * Only the fields T3 reads are decoded; everything else is skipped. + */ +export interface DecodedTailcatAddress { + readonly serverNodeKey: TailcatNodeKey; + readonly serverDiscoKey: string | null; + readonly regionId: number | null; + readonly hasEmbeddedRegions: boolean; +} + +const TAILCAT_ADDRESS_PATTERN = /^tc[A-Za-z0-9_-]{16,}$/u; + +export function isTailcatAddressSyntax(value: string): value is TailcatAddress { + return TAILCAT_ADDRESS_PATTERN.test(value.trim()); +} + +type CborValue = + | number + | bigint + | string + | Uint8Array + | boolean + | null + | undefined + | ReadonlyArray + | ReadonlyMap; + +class CborReader { + private offset = 0; + private readonly bytes: Uint8Array; + constructor(bytes: Uint8Array) { + this.bytes = bytes; + } + + private need(count: number): void { + if (this.offset + count > this.bytes.length) { + throw new RangeError("truncated CBOR"); + } + } + + private readUint(size: number): number | bigint { + this.need(size); + let value = 0n; + for (let index = 0; index < size; index += 1) { + value = (value << 8n) | BigInt(this.bytes[this.offset + index]!); + } + this.offset += size; + return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value; + } + + private readArgument(additional: number): number | bigint { + if (additional < 24) return additional; + if (additional === 24) return this.readUint(1); + if (additional === 25) return this.readUint(2); + if (additional === 26) return this.readUint(4); + if (additional === 27) return this.readUint(8); + throw new RangeError("unsupported CBOR argument"); + } + + private toLength(value: number | bigint): number { + if (typeof value === "bigint") { + throw new RangeError("CBOR length too large"); + } + return value; + } + + read(): CborValue { + this.need(1); + const initial = this.bytes[this.offset]!; + this.offset += 1; + const major = initial >> 5; + const additional = initial & 0x1f; + switch (major) { + case 0: + return this.readArgument(additional); + case 1: { + const argument = this.readArgument(additional); + return typeof argument === "bigint" ? -1n - argument : -1 - argument; + } + case 2: { + const length = this.toLength(this.readArgument(additional)); + this.need(length); + const slice = this.bytes.slice(this.offset, this.offset + length); + this.offset += length; + return slice; + } + case 3: { + const length = this.toLength(this.readArgument(additional)); + this.need(length); + const text = new TextDecoder().decode( + this.bytes.subarray(this.offset, this.offset + length), + ); + this.offset += length; + return text; + } + case 4: { + const length = this.toLength(this.readArgument(additional)); + const items: Array = []; + for (let index = 0; index < length; index += 1) { + items.push(this.read()); + } + return items; + } + case 5: { + const length = this.toLength(this.readArgument(additional)); + const map = new Map(); + for (let index = 0; index < length; index += 1) { + const key = this.read(); + const value = this.read(); + map.set(key, value); + } + return map; + } + case 6: { + // Tags are transparent for this decoder. + this.readArgument(additional); + return this.read(); + } + case 7: + switch (additional) { + case 20: + return false; + case 21: + return true; + case 22: + return null; + case 23: + return undefined; + default: + throw new RangeError("unsupported CBOR simple value"); + } + default: + throw new RangeError("unsupported CBOR major type"); + } + } + + get done(): boolean { + return this.offset >= this.bytes.length; + } +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function invalid(detail: string): TailcatAddressInvalidError { + return new TailcatAddressInvalidError({ detail }); +} + +export function decodeTailcatAddress( + raw: string, +): Result.Result { + const value = raw.trim(); + if (!isTailcatAddressSyntax(value)) { + return Result.fail( + invalid( + "This is not a tailcat address. It should start with tc followed by letters and digits.", + ), + ); + } + const decodedBytes = Encoding.decodeBase64Url(value.slice(2)); + if (Result.isFailure(decodedBytes)) { + return Result.fail(invalid("The tailcat address is not valid base64url text.")); + } + let map: CborValue; + try { + const reader = new CborReader(decodedBytes.success); + map = reader.read(); + if (!reader.done) { + return Result.fail(invalid("The tailcat address has trailing data.")); + } + } catch (cause) { + return Result.fail( + invalid( + `The tailcat address is damaged: ${cause instanceof Error ? cause.message : "bad CBOR"}.`, + ), + ); + } + if (!(map instanceof Map)) { + return Result.fail(invalid("The tailcat address does not contain connection info.")); + } + const serverPublic = map.get("p"); + if (!(serverPublic instanceof Uint8Array) || serverPublic.length !== 32) { + return Result.fail(invalid("The tailcat address is missing the server key.")); + } + const disco = map.get("k"); + const regionId = map.get("i"); + const regions = map.get("r"); + return Result.succeed({ + serverNodeKey: `nodekey:${hex(serverPublic)}` as TailcatNodeKey, + serverDiscoKey: + disco instanceof Uint8Array && disco.length === 32 ? `discokey:${hex(disco)}` : null, + regionId: typeof regionId === "number" ? regionId : null, + hasEmbeddedRegions: Array.isArray(regions) && regions.length > 0, + }); +} + +/** Short human-readable identity for a node key, for diagnostics and peer lists. */ +export function tailcatKeyFingerprint(nodeKey: string): string { + const hexPart = nodeKey.replace(/^nodekey:/u, ""); + return `${hexPart.slice(0, 4)}·${hexPart.slice(4, 8)}·${hexPart.slice(-4)}`; +} + +export function isTailcatNodeKey(value: string): value is TailcatNodeKey { + return /^nodekey:[0-9a-f]{64}$/u.test(value.trim()); +} diff --git a/packages/tailcat/src/backoff.test.ts b/packages/tailcat/src/backoff.test.ts new file mode 100644 index 000000000000..b313426e8117 --- /dev/null +++ b/packages/tailcat/src/backoff.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { TAILCAT_BACKOFF_MAX_MS, tailcatBackoffBaseMs, tailcatBackoffDelayMs } from "./backoff.ts"; + +describe("tailcat backoff", () => { + it("grows with each failure and caps", () => { + expect(tailcatBackoffBaseMs(0)).toBe(0); + expect(tailcatBackoffBaseMs(1)).toBe(1_000); + expect(tailcatBackoffBaseMs(3)).toBe(5_000); + expect(tailcatBackoffBaseMs(6)).toBe(TAILCAT_BACKOFF_MAX_MS); + expect(tailcatBackoffBaseMs(40)).toBe(TAILCAT_BACKOFF_MAX_MS); + }); + + it("jitters symmetrically within a quarter of the base", () => { + expect(tailcatBackoffDelayMs(2, 0)).toBe(1_500); + expect(tailcatBackoffDelayMs(2, 0.5)).toBe(2_000); + expect(tailcatBackoffDelayMs(2, 1)).toBe(2_500); + expect(tailcatBackoffDelayMs(0, 0.9)).toBe(0); + }); +}); diff --git a/packages/tailcat/src/backoff.ts b/packages/tailcat/src/backoff.ts new file mode 100644 index 000000000000..451c254556f5 --- /dev/null +++ b/packages/tailcat/src/backoff.ts @@ -0,0 +1,35 @@ +/** + * Reconnect backoff for tailcat forwarders. The schedule is deliberately slow: + * a remote machine that went to sleep does not come back faster because we + * knock harder, and a laptop on battery should not spin a WireGuard bootstrap + * every second. Jitter keeps many saved environments from retrying in lockstep. + */ +export const TAILCAT_BACKOFF_STEPS_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000] as const; +export const TAILCAT_BACKOFF_MAX_MS = 30_000; +export const TAILCAT_BACKOFF_JITTER_RATIO = 0.25; + +/** Base delay before jitter for a failure count (1 = first failure). */ +export function tailcatBackoffBaseMs(attempt: number): number { + if (attempt <= 0) { + return 0; + } + const index = Math.min(attempt, TAILCAT_BACKOFF_STEPS_MS.length) - 1; + return TAILCAT_BACKOFF_STEPS_MS[index] ?? TAILCAT_BACKOFF_MAX_MS; +} + +/** + * Applies symmetric jitter. `random` is a unit-interval sample so the function + * stays pure and tests can pin it. + */ +export function tailcatBackoffDelayMs(attempt: number, random: number): number { + const base = tailcatBackoffBaseMs(attempt); + if (base === 0) { + return 0; + } + const unit = Math.min(1, Math.max(0, random)); + const spread = base * TAILCAT_BACKOFF_JITTER_RATIO; + return Math.round(base - spread + unit * spread * 2); +} + +/** Connections that stayed healthy this long earn a fresh backoff ladder. */ +export const TAILCAT_BACKOFF_RESET_AFTER_MS = 60_000; diff --git a/packages/tailcat/src/errors.ts b/packages/tailcat/src/errors.ts new file mode 100644 index 000000000000..892facc8feb2 --- /dev/null +++ b/packages/tailcat/src/errors.ts @@ -0,0 +1,176 @@ +import type { TailcatFailureCode } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +/** + * Failure kinds the runtime can name. Every error maps to a `TailcatFailureCode` + * so UIs translate them into plain language without matching on message text, + * and carries a `detail` safe for logs: tailcat output never contains private + * keys, but it is still bounded and stripped of anything key-shaped first. + */ +const TailcatErrorFields = { + detail: Schema.String, +}; + +export class TailcatBinaryMissingError extends Schema.TaggedErrorClass()( + "TailcatBinaryMissingError", + { + ...TailcatErrorFields, + candidates: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return this.detail; + } +} + +export class TailcatBinaryNotExecutableError extends Schema.TaggedErrorClass()( + "TailcatBinaryNotExecutableError", + { + ...TailcatErrorFields, + path: Schema.String, + }, +) { + override get message(): string { + return this.detail; + } +} + +export class TailcatVersionIncompatibleError extends Schema.TaggedErrorClass()( + "TailcatVersionIncompatibleError", + { + ...TailcatErrorFields, + path: Schema.String, + version: Schema.String, + compatibleRange: Schema.String, + }, +) { + override get message(): string { + return this.detail; + } +} + +export class TailcatCommandError extends Schema.TaggedErrorClass()( + "TailcatCommandError", + { + ...TailcatErrorFields, + subcommand: Schema.String, + exitCode: Schema.NullOr(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return this.detail; + } +} + +export class TailcatAddressInvalidError extends Schema.TaggedErrorClass()( + "TailcatAddressInvalidError", + { + ...TailcatErrorFields, + }, +) { + override get message(): string { + return this.detail; + } +} + +export class TailcatPortInUseError extends Schema.TaggedErrorClass()( + "TailcatPortInUseError", + { + ...TailcatErrorFields, + port: Schema.Number, + }, +) { + override get message(): string { + return this.detail; + } +} + +export class TailcatStartupError extends Schema.TaggedErrorClass()( + "TailcatStartupError", + { + ...TailcatErrorFields, + subcommand: Schema.Literals(["serve", "forward"]), + exitCode: Schema.NullOr(Schema.Number), + recentOutput: Schema.Array(Schema.String), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return this.detail; + } +} + +export class TailcatTimeoutError extends Schema.TaggedErrorClass()( + "TailcatTimeoutError", + { + ...TailcatErrorFields, + subcommand: Schema.String, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return this.detail; + } +} + +export class TailcatProcessExitedError extends Schema.TaggedErrorClass()( + "TailcatProcessExitedError", + { + ...TailcatErrorFields, + subcommand: Schema.Literals(["serve", "forward"]), + exitCode: Schema.NullOr(Schema.Number), + recentOutput: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return this.detail; + } +} + +export const TailcatRuntimeError = Schema.Union([ + TailcatBinaryMissingError, + TailcatBinaryNotExecutableError, + TailcatVersionIncompatibleError, + TailcatCommandError, + TailcatAddressInvalidError, + TailcatPortInUseError, + TailcatStartupError, + TailcatTimeoutError, + TailcatProcessExitedError, +]); +export type TailcatRuntimeError = typeof TailcatRuntimeError.Type; +export const isTailcatRuntimeError = Schema.is(TailcatRuntimeError); + +/** The contracts-level failure code for any runtime error, for UIs and state snapshots. */ +export function tailcatFailureCode(error: TailcatRuntimeError): TailcatFailureCode { + switch (error._tag) { + case "TailcatBinaryMissingError": + return "binary-missing"; + case "TailcatBinaryNotExecutableError": + return "binary-not-executable"; + case "TailcatVersionIncompatibleError": + return "version-incompatible"; + case "TailcatAddressInvalidError": + return "address-invalid"; + case "TailcatPortInUseError": + return "port-in-use"; + case "TailcatStartupError": + return "startup-failed"; + case "TailcatTimeoutError": + return "timeout"; + case "TailcatProcessExitedError": + return "process-exited"; + case "TailcatCommandError": + return "unknown"; + } +} + +/** + * Strips anything that looks like private key material from a line of tailcat + * output. Tailcat does not print private keys, so this is defense in depth for + * output that ends up in diagnostics. + */ +export function redactTailcatOutputLine(line: string): string { + return line.replace(/privkey:[0-9a-f]+/giu, "privkey:"); +} diff --git a/packages/tailcat/src/manifest.test.ts b/packages/tailcat/src/manifest.test.ts new file mode 100644 index 000000000000..b45d62435a84 --- /dev/null +++ b/packages/tailcat/src/manifest.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + TAILCAT_PINNED_VERSION, + isCompatibleTailcatVersion, + normalizeTailcatVersion, + tailcatExecutableName, + tailcatManifest, + tailcatPlatformKey, +} from "./manifest.ts"; + +describe("tailcat manifest", () => { + it("pins a version with per-platform digests", () => { + expect(TAILCAT_PINNED_VERSION).toMatch(/^\d+\.\d+\.\d+$/u); + for (const asset of Object.values(tailcatManifest.assets)) { + expect(asset.file).toContain(TAILCAT_PINNED_VERSION); + expect(asset.sha256).toMatch(/^[0-9a-f]{64}$/u); + } + expect(tailcatManifest.source.sha256).toMatch(/^[0-9a-f]{64}$/u); + expect(tailcatManifest.source.url).toContain(`v${TAILCAT_PINNED_VERSION}`); + expect(tailcatManifest.releaseBaseUrl).toContain(`v${TAILCAT_PINNED_VERSION}`); + }); + + it("normalizes and checks versions", () => { + expect(normalizeTailcatVersion("v0.5.0\n")).toBe("0.5.0"); + expect(normalizeTailcatVersion("0.5.0-dirty")).toBe("0.5.0"); + expect(normalizeTailcatVersion("unknown")).toBeNull(); + expect(isCompatibleTailcatVersion(TAILCAT_PINNED_VERSION)).toBe(true); + expect(isCompatibleTailcatVersion("9.0.0")).toBe(false); + }); + + it("maps supported platforms", () => { + expect(tailcatPlatformKey("linux", "x64")).toBe("linux-x64"); + expect(tailcatPlatformKey("darwin", "arm64")).toBe("darwin-arm64"); + expect(tailcatPlatformKey("win32", "arm64")).toBe("win32-arm64"); + expect(tailcatPlatformKey("freebsd", "x64")).toBeUndefined(); + expect(tailcatPlatformKey("linux", "ia32" as NodeJS.Architecture)).toBeUndefined(); + expect(tailcatExecutableName("win32")).toBe("tailcat.exe"); + expect(tailcatExecutableName("darwin")).toBe("tailcat"); + }); +}); diff --git a/packages/tailcat/src/manifest.ts b/packages/tailcat/src/manifest.ts new file mode 100644 index 000000000000..85a81270216e --- /dev/null +++ b/packages/tailcat/src/manifest.ts @@ -0,0 +1,62 @@ +import { satisfiesSemverRange } from "@t3tools/shared/semver"; + +import manifestJson from "../../../native/tailcat/manifest.json" with { type: "json" }; + +/** + * The pinned upstream Tailcat release. `native/tailcat/manifest.json` is the + * single source of truth for the version and per-platform digests; this module + * only adds the compatibility policy the runtime enforces before it trusts a + * binary it did not stage itself (system installs, developer overrides). + */ +export const TAILCAT_PINNED_VERSION: string = manifestJson.version; + +/** + * Versions the runtime accepts. Tailcat makes no CLI stability promises, so the + * range is deliberately narrow: same minor as the pinned release. A system + * binary outside this range is reported as incompatible with an actionable + * message instead of producing mysterious flag or output mismatches. + */ +export const TAILCAT_COMPATIBLE_RANGE = `^${manifestJson.version}`; + +export type TailcatPlatformKey = + | "linux-x64" + | "linux-arm64" + | "win32-x64" + | "win32-arm64" + | "darwin-arm64" + | "darwin-x64"; + +export function tailcatPlatformKey( + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, +): TailcatPlatformKey | undefined { + if (architecture !== "arm64" && architecture !== "x64") { + return undefined; + } + switch (platform) { + case "linux": + return `linux-${architecture}`; + case "win32": + return `win32-${architecture}`; + case "darwin": + return `darwin-${architecture}`; + default: + return undefined; + } +} + +export function tailcatExecutableName(platform: NodeJS.Platform): string { + return platform === "win32" ? "tailcat.exe" : "tailcat"; +} + +/** Normalizes `tailcat version` output (`v0.5.0`, `0.5.0`, `v0.5.0-dirty`). */ +export function normalizeTailcatVersion(raw: string): string | null { + const match = /v?(\d+\.\d+\.\d+)/u.exec(raw.trim()); + return match?.[1] ?? null; +} + +export function isCompatibleTailcatVersion(version: string): boolean { + return satisfiesSemverRange(version, TAILCAT_COMPATIBLE_RANGE); +} + +export const tailcatManifest = manifestJson; diff --git a/packages/tailcat/src/runtime.test.ts b/packages/tailcat/src/runtime.test.ts new file mode 100644 index 000000000000..6be9143ebbba --- /dev/null +++ b/packages/tailcat/src/runtime.test.ts @@ -0,0 +1,673 @@ +import { assert, describe, expect, it } from "@effect/vitest"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { tailcatFailureCode } from "./errors.ts"; +import { TAILCAT_COMPATIBLE_RANGE, TAILCAT_PINNED_VERSION } from "./manifest.ts"; +import { + TAILCAT_PROCESS_STOP_GRACE, + TAILCAT_SERVE_READY_TIMEOUT, + type TailcatAllowPolicy, + type TailcatExecutableResolution, + TailcatRuntime, + layer as tailcatRuntimeLayer, +} from "./runtime.ts"; + +// Captured from a real `tailcat serve` run; the runtime decodes it before trusting it. +const ADDRESS = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; +const NODE_KEY = "nodekey:9ab555a4a588b75d2054adb683db82461bb6c707d43e8ba39439f8eb1e821503"; +const LISTEN_LINE = `{"listenAddr":"${ADDRESS}"}\n`; + +const OVERRIDE = "/home/dev/bin/tailcat"; +const BUNDLED = "/opt/t3/resources/tailcat/linux-x64/tailcat"; +const MISSING_BUNDLED = "/opt/t3/resources/tailcat/tailcat"; +const SYSTEM = "/usr/bin/tailcat"; +const SERVER_KEY = "/state/tailcat/server.key"; +const CLIENT_KEY = "/state/tailcat/client.key"; +const KILLED_EXIT_CODE = 143; +// The TestClock starts at the epoch, so `measuredAt` is fixed. +const EPOCH_ISO = "1970-01-01T00:00:00.000Z"; + +const encoder = new TextEncoder(); +const text = (content: string): Stream.Stream => Stream.make(encoder.encode(content)); + +/** One scripted tailcat process. Without `exitCode` it runs until killed. */ +interface FakeProcess { + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: number; + /** Runs once the process has been spawned, for tests that sequence on it. */ + readonly onSpawn?: Effect.Effect; +} + +interface SpawnRecord { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: ChildProcess.StandardCommand["options"]; + readonly kills: ReadonlyArray; +} + +interface FakeTailcat { + readonly spawns: ReadonlyArray; + readonly layer: Layer.Layer; +} + +const subcommandOf = (args: ReadonlyArray) => + args.find((arg) => !arg.startsWith("--")) ?? ""; + +const spawnFor = (tailcat: FakeTailcat, subcommand: string) => + tailcat.spawns.find((spawn) => subcommandOf(spawn.args) === subcommand); + +const versionProcess = (version = `v${TAILCAT_PINNED_VERSION}`): FakeProcess => ({ + stdout: text(`${version}\n`), + exitCode: 0, +}); + +/** + * A ChildProcessSpawner that plays scripted processes keyed by tailcat + * subcommand. Like the Node spawner, a process still running when its scope + * closes is killed, and killing settles its exit code. + */ +function fakeTailcat(processes: Readonly>): FakeTailcat { + const spawns: Array = []; + const spawner = ChildProcessSpawner.make( + Effect.fnUntraced(function* (command) { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die(new Error("tailcat is never spawned through a pipeline")); + } + const fake = processes[subcommandOf(command.args)]; + if (fake === undefined) { + return yield* Effect.die(new Error(`unexpected tailcat ${command.args.join(" ")}`)); + } + const exit = yield* Deferred.make(); + if (fake.exitCode !== undefined) { + yield* Deferred.succeed(exit, ChildProcessSpawner.ExitCode(fake.exitCode)); + } + const kills: Array = []; + const kill = (options?: ChildProcess.KillOptions) => + Effect.suspend(() => { + kills.push(options); + return Deferred.succeed(exit, ChildProcessSpawner.ExitCode(KILLED_EXIT_CODE)).pipe( + Effect.asVoid, + ); + }); + // A process that already exited has closed its pipes; a live one keeps them open. + const idle = fake.exitCode === undefined ? Stream.never : Stream.empty; + const stdout = fake.stdout ?? idle; + const stderr = fake.stderr ?? idle; + spawns.push({ + command: command.command, + args: command.args, + options: command.options, + kills, + }); + yield* Effect.addFinalizer(() => + Effect.flatMap(Deferred.isDone(exit), (done) => (done ? Effect.void : kill())), + ); + if (fake.onSpawn !== undefined) { + yield* fake.onSpawn; + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(4242), + stdin: Sink.drain, + stdout, + stderr, + all: Stream.merge(stdout, stderr), + exitCode: Deferred.await(exit), + isRunning: Effect.map(Deferred.isDone(exit), (done) => !done), + kill, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ); + return { spawns, layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner) }; +} + +interface FakeHost { + readonly resolution?: TailcatExecutableResolution; + /** Paths that exist on disk; everything else is missing. Defaults to the bundled binary. */ + readonly existing?: ReadonlyArray; + /** File modes reported by stat, for paths that should not look executable. */ + readonly modes?: Readonly>; + readonly environment?: NodeJS.ProcessEnv; + /** Whether something accepts connections on the forwarded loopback port. */ + readonly listening?: boolean; +} + +const bundledOnly: TailcatExecutableResolution = { + bundledCandidates: [BUNDLED], + allowSystem: false, +}; + +const fileInfo = (mode: number): FileSystem.File.Info => ({ + type: "File", + mtime: Option.none(), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0), + blksize: Option.none(), + blocks: Option.none(), +}); + +const notFound = (method: string, path: string) => + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method, + pathOrDescriptor: path, + }); + +function runtimeLayer(tailcat: FakeTailcat, host: FakeHost = {}) { + const existing = new Set(host.existing ?? [BUNDLED]); + const modes = host.modes ?? {}; + const fileSystem = FileSystem.layerNoop({ + exists: (path) => Effect.succeed(existing.has(path)), + stat: (path) => { + const mode = modes[path]; + return mode === undefined + ? Effect.fail(notFound("stat", path)) + : Effect.succeed(fileInfo(mode)); + }, + }); + const net = Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(host.listening ?? true), + reserveLoopbackPort: () => Effect.succeed(0), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }); + return tailcatRuntimeLayer({ resolution: host.resolution ?? bundledOnly }).pipe( + Layer.provide( + Layer.mergeAll( + tailcat.layer, + fileSystem, + Path.layer, + net, + Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessArchitecture, "x64"), + Layer.succeed(HostProcessEnvironment, host.environment ?? {}), + ), + ), + ); +} + +describe("TailcatRuntime.resolve", () => { + it.effect("prefers the developer override and caches the result", () => { + const tailcat = fakeTailcat({ version: versionProcess() }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + + const info = yield* runtime.resolve; + expect(info).toEqual({ + executablePath: OVERRIDE, + source: "override", + version: TAILCAT_PINNED_VERSION, + pinnedVersion: TAILCAT_PINNED_VERSION, + compatible: true, + }); + expect(tailcat.spawns.map((spawn) => [spawn.command, ...spawn.args])).toEqual([ + [OVERRIDE, "version"], + ]); + + yield* runtime.resolve; + expect(tailcat.spawns).toHaveLength(1); + yield* runtime.refresh; + expect(tailcat.spawns).toHaveLength(2); + }).pipe( + Effect.provide( + runtimeLayer(tailcat, { + resolution: { overridePath: OVERRIDE, bundledCandidates: [BUNDLED], allowSystem: true }, + existing: [OVERRIDE, BUNDLED, SYSTEM], + environment: { PATH: "/usr/local/bin:/usr/bin" }, + }), + ), + ); + }); + + it.effect("falls back to the first bundled candidate that exists", () => { + const tailcat = fakeTailcat({ version: versionProcess() }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const info = yield* runtime.resolve; + expect(info.source).toBe("bundled"); + expect(info.executablePath).toBe(BUNDLED); + expect(tailcat.spawns.map((spawn) => spawn.command)).toEqual([BUNDLED]); + }).pipe( + Effect.provide( + runtimeLayer(tailcat, { + resolution: { bundledCandidates: [MISSING_BUNDLED, BUNDLED], allowSystem: false }, + existing: [BUNDLED], + }), + ), + ); + }); + + it.effect("accepts a tailcat on PATH when system binaries are allowed", () => { + const tailcat = fakeTailcat({ version: versionProcess() }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const info = yield* runtime.resolve; + expect(info.source).toBe("system"); + expect(info.executablePath).toBe(SYSTEM); + expect(info.compatible).toBe(true); + }).pipe( + Effect.provide( + runtimeLayer(tailcat, { + resolution: { bundledCandidates: [MISSING_BUNDLED], allowSystem: true }, + existing: [SYSTEM], + environment: { PATH: "/usr/local/bin:/usr/bin" }, + }), + ), + ); + }); + + it.effect("fails with binary-missing when nothing is found and PATH is off limits", () => { + const tailcat = fakeTailcat({ version: versionProcess() }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.resolve.pipe(Effect.flip); + assert(error._tag === "TailcatBinaryMissingError"); + expect(error.candidates).toEqual([MISSING_BUNDLED]); + expect(tailcatFailureCode(error)).toBe("binary-missing"); + expect(tailcat.spawns).toHaveLength(0); + }).pipe( + Effect.provide( + runtimeLayer(tailcat, { + resolution: { bundledCandidates: [MISSING_BUNDLED], allowSystem: false }, + existing: [SYSTEM], + environment: { PATH: "/usr/local/bin:/usr/bin" }, + }), + ), + ); + }); + + it.effect("rejects a missing override without trying other candidates", () => { + const tailcat = fakeTailcat({ version: versionProcess() }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.resolve.pipe(Effect.flip); + assert(error._tag === "TailcatBinaryMissingError"); + expect(error.candidates).toEqual([OVERRIDE]); + expect(error.detail).toContain("T3CODE_TAILCAT_BINARY"); + expect(tailcat.spawns).toHaveLength(0); + }).pipe( + Effect.provide( + runtimeLayer(tailcat, { + resolution: { overridePath: OVERRIDE, bundledCandidates: [BUNDLED], allowSystem: false }, + existing: [BUNDLED], + }), + ), + ); + }); + + it.effect("rejects a binary that is not executable", () => { + const tailcat = fakeTailcat({ version: versionProcess() }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.resolve.pipe(Effect.flip); + assert(error._tag === "TailcatBinaryNotExecutableError"); + expect(error.path).toBe(BUNDLED); + expect(tailcatFailureCode(error)).toBe("binary-not-executable"); + expect(tailcat.spawns).toHaveLength(0); + }).pipe(Effect.provide(runtimeLayer(tailcat, { modes: { [BUNDLED]: 0o644 } }))); + }); + + it.effect("rejects a version outside the compatible range", () => { + const tailcat = fakeTailcat({ version: versionProcess("v9.0.0") }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.resolve.pipe(Effect.flip); + assert(error._tag === "TailcatVersionIncompatibleError"); + expect(error.path).toBe(BUNDLED); + expect(error.version).toBe("9.0.0"); + expect(error.compatibleRange).toBe(TAILCAT_COMPATIBLE_RANGE); + expect(tailcatFailureCode(error)).toBe("version-incompatible"); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("fails when tailcat version prints nothing usable", () => { + const tailcat = fakeTailcat({ + version: { stdout: text("tailcat: unknown command\n"), exitCode: 0 }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.resolve.pipe(Effect.flip); + assert(error._tag === "TailcatCommandError"); + expect(error.subcommand).toBe("version"); + expect(error.exitCode).toBe(0); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); +}); + +describe("TailcatRuntime.serve", () => { + const allow: TailcatAllowPolicy = { _tag: "keys", nodeKeys: [NODE_KEY] }; + const serveInput = { keyPath: SERVER_KEY, localPort: 3773, allow }; + + it.effect("publishes the address from the JSON listen line", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + serve: { stdout: text(LISTEN_LINE) }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const handle = yield* runtime.serve(serveInput); + expect(handle.address).toBe(ADDRESS); + expect(handle.localPort).toBe(3773); + expect(handle.allow).toEqual(allow); + expect(handle.pid).toBe(4242); + expect(yield* handle.isRunning).toBe(true); + + const serve = spawnFor(tailcat, "serve"); + assert(serve !== undefined); + expect(serve.command).toBe(BUNDLED); + expect(serve.args).toEqual([ + "--json", + `--key=${SERVER_KEY}`, + "serve", + `--allow=${NODE_KEY}`, + "3773", + ]); + expect(serve.options.stdin).toBe("ignore"); + expect(serve.options.killSignal).toBe("SIGTERM"); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("stop terminates the process and settles exit", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + serve: { stdout: text(LISTEN_LINE) }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const handle = yield* runtime.serve(serveInput); + + yield* handle.stop; + + const serve = spawnFor(tailcat, "serve"); + assert(serve !== undefined); + expect(serve.kills).toEqual([ + { killSignal: "SIGTERM", forceKillAfter: TAILCAT_PROCESS_STOP_GRACE }, + ]); + expect(yield* handle.exit).toEqual(Option.some(KILLED_EXIT_CODE)); + expect(yield* handle.isRunning).toBe(false); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("closing the owning scope kills the process", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + serve: { stdout: text(LISTEN_LINE) }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const scope = yield* Scope.make(); + const handle = yield* runtime.serve(serveInput).pipe(Scope.provide(scope)); + expect(yield* handle.isRunning).toBe(true); + + yield* Scope.close(scope, Exit.void); + + expect(spawnFor(tailcat, "serve")?.kills).toHaveLength(1); + expect(yield* handle.isRunning).toBe(false); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("fails with a startup error when the process exits before publishing", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + // stdout stays open so the exit, not an early EOF, decides the race. + serve: { + stdout: Stream.never, + stderr: text("tailcat: could not bootstrap: no route to DERP\n"), + exitCode: 1, + }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.serve(serveInput).pipe(Effect.flip); + assert(error._tag === "TailcatStartupError"); + expect(error.subcommand).toBe("serve"); + expect(error.exitCode).toBe(1); + expect(error.recentOutput).toEqual(["tailcat: could not bootstrap: no route to DERP"]); + expect(error.detail).toBe( + "Tailcat could not start serving: tailcat: could not bootstrap: no route to DERP", + ); + expect(tailcatFailureCode(error)).toBe("startup-failed"); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("reports a port conflict from the process output", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + serve: { + stdout: Stream.never, + stderr: text("listen tcp 127.0.0.1:3773: bind: address already in use\n"), + exitCode: 1, + }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.serve(serveInput).pipe(Effect.flip); + assert(error._tag === "TailcatPortInUseError"); + expect(error.port).toBe(3773); + expect(tailcatFailureCode(error)).toBe("port-in-use"); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("times out and kills a process that never publishes an address", () => + Effect.gen(function* () { + const spawned = yield* Deferred.make(); + const tailcat = fakeTailcat({ + version: versionProcess(), + serve: { onSpawn: Deferred.succeed(spawned, undefined).pipe(Effect.asVoid) }, + }); + const error = yield* Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const fiber = yield* runtime.serve(serveInput).pipe(Effect.forkScoped); + yield* Deferred.await(spawned); + yield* TestClock.adjust(TAILCAT_SERVE_READY_TIMEOUT); + return yield* Fiber.join(fiber).pipe(Effect.flip); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + + assert(error._tag === "TailcatTimeoutError", `${error._tag}: ${error.detail}`); + expect(error.subcommand).toBe("serve"); + expect(error.timeoutMs).toBe(30_000); + expect(tailcatFailureCode(error)).toBe("timeout"); + expect(spawnFor(tailcat, "serve")?.kills).toHaveLength(1); + }), + ); +}); + +describe("TailcatRuntime.forward", () => { + const forwardInput = { + keyPath: CLIENT_KEY, + address: ADDRESS, + remotePort: 3773, + localPort: 40123, + }; + + it.effect("forwards a reserved loopback port once the tunnel is ready", () => { + const tailcat = fakeTailcat({ version: versionProcess(), forward: {} }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const probed: Array = []; + const handle = yield* runtime.forward({ + ...forwardInput, + readiness: (endpoint) => + Effect.sync(() => { + probed.push(endpoint.httpBaseUrl); + }), + }); + expect(handle.address).toBe(ADDRESS); + expect(handle.remotePort).toBe(3773); + expect(handle.localPort).toBe(40123); + expect(handle.httpBaseUrl).toBe("http://127.0.0.1:40123/"); + expect(handle.wsBaseUrl).toBe("ws://127.0.0.1:40123/"); + expect(probed).toEqual(["http://127.0.0.1:40123/"]); + expect(yield* handle.isRunning).toBe(true); + + yield* runtime.forward({ ...forwardInput, keyPath: null, localPort: 40124 }); + + const forwards = tailcat.spawns.filter((spawn) => subcommandOf(spawn.args) === "forward"); + expect(forwards.map((spawn) => spawn.command)).toEqual([BUNDLED, BUNDLED]); + expect(forwards.map((spawn) => spawn.args)).toEqual([ + [`--key=${CLIENT_KEY}`, "forward", ADDRESS, "40123:3773"], + ["forward", ADDRESS, "40124:3773"], + ]); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("fails with a startup error when the forwarder exits early", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + forward: { stderr: text("forward: the remote machine rejected this client\n"), exitCode: 2 }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.forward(forwardInput).pipe(Effect.flip); + assert(error._tag === "TailcatStartupError"); + expect(error.subcommand).toBe("forward"); + expect(error.exitCode).toBe(2); + expect(error.detail).toBe( + "Tailcat could not start forwarding: forward: the remote machine rejected this client", + ); + }).pipe(Effect.provide(runtimeLayer(tailcat, { listening: false }))); + }); + + it.effect("kills the forwarder when the readiness probe fails", () => { + const tailcat = fakeTailcat({ version: versionProcess(), forward: {} }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime + .forward({ + ...forwardInput, + readiness: () => Effect.fail({ _tag: "ProbeFailed" as const }), + }) + .pipe(Effect.flip); + expect(error).toEqual({ _tag: "ProbeFailed" }); + expect(spawnFor(tailcat, "forward")?.kills).toEqual([ + { killSignal: "SIGTERM", forceKillAfter: TAILCAT_PROCESS_STOP_GRACE }, + ]); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("rejects an address that is not a tailcat code before spawning", () => { + const tailcat = fakeTailcat({ version: versionProcess() }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime + .forward({ ...forwardInput, address: "https://example.com" }) + .pipe(Effect.flip); + expect(error._tag).toBe("TailcatAddressInvalidError"); + expect(spawnFor(tailcat, "forward")).toBeUndefined(); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("times out and kills a forwarder the remote never answers through", () => + Effect.gen(function* () { + const spawned = yield* Deferred.make(); + const tailcat = fakeTailcat({ + version: versionProcess(), + forward: { onSpawn: Deferred.succeed(spawned, undefined).pipe(Effect.asVoid) }, + }); + const error = yield* Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const fiber = yield* runtime + .forward({ + ...forwardInput, + readiness: () => Effect.never, + readinessTimeout: "5 seconds", + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(spawned); + yield* TestClock.adjust("5 seconds"); + return yield* Fiber.join(fiber).pipe(Effect.flip); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + + assert(error._tag === "TailcatTimeoutError", `${error._tag}: ${error.detail}`); + expect(error.subcommand).toBe("forward"); + expect(error.timeoutMs).toBe(5_000); + expect(tailcatFailureCode(error)).toBe("timeout"); + expect(spawnFor(tailcat, "forward")?.kills).toHaveLength(1); + }), + ); +}); + +describe("TailcatRuntime.ping", () => { + it.effect("reports a direct path", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + ping: { stdout: text("pong in 280µs via 192.168.50.12:49590\n"), exitCode: 0 }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const probe = yield* runtime.ping({ keyPath: CLIENT_KEY, address: ADDRESS }); + expect(probe).toEqual({ + kind: "direct", + via: "192.168.50.12:49590", + latencyMs: 0.28, + measuredAt: EPOCH_ISO, + }); + expect(spawnFor(tailcat, "ping")?.args).toEqual([ + `--key=${CLIENT_KEY}`, + "ping", + "--timeout=8s", + ADDRESS, + ]); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("reports a relayed path", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + ping: { stdout: text("pong in 12.5ms via DERP(sfo)\n"), exitCode: 0 }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const probe = yield* runtime.ping({ keyPath: null, address: ADDRESS, timeout: "2 seconds" }); + expect(probe).toEqual({ kind: "relay", via: "sfo", latencyMs: 12.5, measuredAt: EPOCH_ISO }); + expect(spawnFor(tailcat, "ping")?.args).toEqual(["ping", "--timeout=2s", ADDRESS]); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); + + it.effect("fails when the remote does not answer", () => { + const tailcat = fakeTailcat({ + version: versionProcess(), + ping: { stderr: text("ping: no response\n"), exitCode: 1 }, + }); + return Effect.gen(function* () { + const runtime = yield* TailcatRuntime; + const error = yield* runtime.ping({ keyPath: null, address: ADDRESS }).pipe(Effect.flip); + assert(error._tag === "TailcatCommandError"); + expect(error.subcommand).toBe("ping"); + expect(error.exitCode).toBe(1); + }).pipe(Effect.provide(runtimeLayer(tailcat))); + }); +}); diff --git a/packages/tailcat/src/runtime.ts b/packages/tailcat/src/runtime.ts new file mode 100644 index 000000000000..0fb654acc6cd --- /dev/null +++ b/packages/tailcat/src/runtime.ts @@ -0,0 +1,953 @@ +import type { + TailcatAddress, + TailcatNodeKey, + TailcatPathProbe, + TailcatRuntimeInfo, + TailcatRuntimeSource, +} from "@t3tools/contracts"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as 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 Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { decodeTailcatAddress, isTailcatNodeKey } from "./address.ts"; +import { + TailcatAddressInvalidError, + TailcatBinaryMissingError, + TailcatBinaryNotExecutableError, + TailcatCommandError, + TailcatPortInUseError, + TailcatStartupError, + TailcatTimeoutError, + TailcatVersionIncompatibleError, + redactTailcatOutputLine, +} from "./errors.ts"; +import { + TAILCAT_COMPATIBLE_RANGE, + TAILCAT_PINNED_VERSION, + isCompatibleTailcatVersion, + normalizeTailcatVersion, + tailcatExecutableName, + tailcatPlatformKey, +} from "./manifest.ts"; + +/** + * TailcatRuntime owns every interaction with the `tailcat` executable: + * resolving which binary to run, checking its version, generating identities, + * serving a local port, forwarding to a remote server, and probing the path. + * Callers never see argv or child-process handles; they get typed handles whose + * lifetime is a Scope, so a closed scope always means a dead process. + */ + +export const TAILCAT_SERVE_READY_TIMEOUT = Duration.seconds(30); +export const TAILCAT_FORWARD_LISTEN_TIMEOUT = Duration.seconds(10); +export const TAILCAT_COMMAND_TIMEOUT = Duration.seconds(15); +export const TAILCAT_PING_DEFAULT_TIMEOUT = Duration.seconds(8); +export const TAILCAT_PROCESS_STOP_GRACE = Duration.seconds(2); +export const TAILCAT_RECENT_OUTPUT_LINES = 40; + +export type TailcatAllowPolicy = + | { readonly _tag: "all" } + | { readonly _tag: "none" } + | { readonly _tag: "keys"; readonly nodeKeys: ReadonlyArray }; + +export interface TailcatExecutableResolution { + /** Explicit developer override (`T3CODE_TAILCAT_BINARY`), checked first. */ + readonly overridePath?: string | undefined; + /** Bundled candidates in preference order (packaged resources, dev staging). */ + readonly bundledCandidates: ReadonlyArray; + /** Whether a `tailcat` found on PATH is acceptable. */ + readonly allowSystem: boolean; +} + +export interface TailcatProcessHandle { + readonly pid: number; + /** Resolves when the process exits, with its exit code when known. */ + readonly exit: Effect.Effect>; + readonly isRunning: Effect.Effect; + readonly recentOutput: Effect.Effect>; + /** Terminates the process. Closing the owning scope does the same. */ + readonly stop: Effect.Effect; +} + +export interface TailcatServeHandle extends TailcatProcessHandle { + readonly address: TailcatAddress; + readonly localPort: number; + readonly allow: TailcatAllowPolicy; +} + +export interface TailcatForwardHandle extends TailcatProcessHandle { + readonly address: TailcatAddress; + readonly remotePort: number; + readonly localPort: number; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; +} + +export type TailcatResolveError = + | TailcatBinaryMissingError + | TailcatBinaryNotExecutableError + | TailcatVersionIncompatibleError + | TailcatCommandError; + +export type TailcatServeError = + | TailcatResolveError + | TailcatStartupError + | TailcatTimeoutError + | TailcatPortInUseError; + +export type TailcatForwardError = + | TailcatResolveError + | TailcatAddressInvalidError + | TailcatStartupError + | TailcatTimeoutError + | TailcatPortInUseError; + +export type TailcatIdentityError = TailcatResolveError | TailcatAddressInvalidError; + +export type TailcatPingError = + | TailcatResolveError + | TailcatAddressInvalidError + | TailcatCommandError; + +export class TailcatRuntime extends Context.Service< + TailcatRuntime, + { + /** Resolves and version-checks the executable. Cached until `refresh`. */ + readonly resolve: Effect.Effect; + readonly refresh: Effect.Effect; + /** Creates a server identity file (0600) and returns its stable address. */ + readonly generateServerIdentity: (options: { + readonly keyPath: string; + }) => Effect.Effect<{ readonly address: TailcatAddress }, TailcatIdentityError>; + /** Creates a client identity file (0600) and returns its public node key. */ + readonly generateClientIdentity: (options: { + readonly keyPath: string; + }) => Effect.Effect<{ readonly nodeKey: TailcatNodeKey }, TailcatIdentityError>; + readonly readClientPublicKey: (options: { + readonly keyPath: string; + }) => Effect.Effect; + /** Exposes a local port; the process lives as long as the current Scope. */ + readonly serve: (options: { + readonly keyPath: string; + readonly localPort: number; + readonly allow: TailcatAllowPolicy; + }) => Effect.Effect; + /** + * Forwards a reserved loopback port to a remote port. Resolves once the + * local listener accepts and `readiness` (if given) succeeds through it. + */ + readonly forward: (options: { + readonly keyPath: string | null; + readonly address: string; + readonly remotePort: number; + readonly localPort: number; + readonly readiness?: (endpoint: { readonly httpBaseUrl: string }) => Effect.Effect; + readonly readinessTimeout?: Duration.Input; + }) => Effect.Effect; + /** One disco ping: direct path or relay, and latency. */ + readonly ping: (options: { + readonly keyPath: string | null; + readonly address: string; + readonly timeout?: Duration.Input; + }) => Effect.Effect; + } +>()("@t3tools/tailcat/runtime/TailcatRuntime") {} + +const ServeListenAddrJson = Schema.fromJsonString(Schema.Struct({ listenAddr: Schema.String })); +const decodeServeListenAddr = Schema.decodeUnknownOption(ServeListenAddrJson); + +interface OutputBuffer { + readonly push: (line: string) => Effect.Effect; + readonly lines: Effect.Effect>; +} + +const makeOutputBuffer = Effect.fn("TailcatRuntime.makeOutputBuffer")(function* () { + const ref = yield* Ref.make>([]); + return { + push: (line: string) => + Ref.update(ref, (lines) => { + const next = [...lines, redactTailcatOutputLine(line)]; + return next.length > TAILCAT_RECENT_OUTPUT_LINES + ? next.slice(next.length - TAILCAT_RECENT_OUTPUT_LINES) + : next; + }), + lines: Ref.get(ref), + } satisfies OutputBuffer; +}); + +const lineStream = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.map((line) => line.trimEnd()), + Stream.filter((line) => line.length > 0), + ); + +/** Parses Go duration text (`280µs`, `127.25ms`, `1.2s`) into milliseconds. */ +export function parseGoDurationMs(text: string): number | null { + const match = /^([0-9]+(?:\.[0-9]+)?)(ns|µs|us|ms|s|m|h)$/u.exec(text.trim()); + if (!match) { + return null; + } + const value = Number.parseFloat(match[1]!); + switch (match[2]) { + case "ns": + return value / 1_000_000; + case "µs": + case "us": + return value / 1_000; + case "ms": + return value; + case "s": + return value * 1_000; + case "m": + return value * 60_000; + case "h": + return value * 3_600_000; + default: + return null; + } +} + +/** Parses one `tailcat ping` pong line into a path probe. */ +export function parseTailcatPong(line: string, measuredAt: string): TailcatPathProbe | null { + const match = /^pong in (\S+) via (.+)$/u.exec(line.trim()); + if (!match) { + return null; + } + const via = match[2]!.trim(); + const latencyMs = parseGoDurationMs(match[1]!); + const relay = /^DERP\((.*)\)$/u.exec(via); + return { + kind: relay ? "relay" : "direct", + via: relay ? relay[1]!.trim() || null : via, + latencyMs, + measuredAt, + }; +} + +export function tailcatAllowFlag(policy: TailcatAllowPolicy): ReadonlyArray { + switch (policy._tag) { + case "all": + return []; + case "none": + return ["--allow=none"]; + case "keys": + return policy.nodeKeys.length === 0 + ? ["--allow=none"] + : [`--allow=${policy.nodeKeys.join(",")}`]; + } +} + +const isPortInUseOutput = (lines: ReadonlyArray): boolean => + lines.some((line) => + /address already in use|EADDRINUSE|Only one usage of each socket/iu.test(line), + ); + +const isNotFoundSpawnError = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + ((cause as { code?: unknown }).code === "ENOENT" || + (cause as { code?: unknown }).code === "EACCES"); + +export interface TailcatRuntimeLayerOptions { + readonly resolution: TailcatExecutableResolution; +} + +type RuntimeServices = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | NetService.NetService; + +export const make = Effect.fn("TailcatRuntime.make")(function* ( + options: TailcatRuntimeLayerOptions, +): Effect.fn.Return { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const net = yield* NetService.NetService; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + const environment = yield* HostProcessEnvironment; + const executableName = tailcatExecutableName(platform); + + const findOnPath = Effect.fn("TailcatRuntime.findOnPath")(function* () { + const pathEntries = (environment.PATH ?? environment.Path ?? "") + .split(platform === "win32" ? ";" : ":") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + const names = + platform === "win32" + ? [ + executableName, + ...(environment.PATHEXT ?? "") + .split(";") + .filter(Boolean) + .map((ext) => `tailcat${ext.toLowerCase()}`), + ] + : [executableName]; + for (const entry of pathEntries) { + for (const name of names) { + const candidate = path.join(entry, name); + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return Option.some(candidate); + } + } + } + return Option.none(); + }); + + const checkExecutable = Effect.fn("TailcatRuntime.checkExecutable")(function* ( + candidate: string, + ): Effect.fn.Return { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return false; + } + if (platform === "win32") { + return true; + } + const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); + if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) { + return yield* new TailcatBinaryNotExecutableError({ + path: candidate, + detail: `The Tailcat runtime at ${candidate} is not executable. Reinstall T3 Code or run chmod +x on it.`, + }); + } + return true; + }); + + const runCommand = Effect.fn("TailcatRuntime.runCommand")(function* (input: { + readonly executablePath: string; + readonly args: ReadonlyArray; + readonly subcommand: string; + readonly timeout?: Duration.Input; + }): Effect.fn.Return< + { readonly stdout: string; readonly stderr: string; readonly exitCode: number }, + TailcatCommandError + > { + const timeout = Duration.fromInputUnsafe(input.timeout ?? TAILCAT_COMMAND_TIMEOUT); + return yield* Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(input.executablePath, input.args, { + stdin: "ignore", + killSignal: "SIGTERM", + forceKillAfter: TAILCAT_PROCESS_STOP_GRACE, + }), + ); + const collect = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode }; + }).pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new TailcatCommandError({ + subcommand: input.subcommand, + exitCode: null, + detail: isNotFoundSpawnError(cause) + ? `The Tailcat runtime at ${input.executablePath} could not be started.` + : `tailcat ${input.subcommand} failed to run.`, + cause, + }), + ), + Effect.catchDefect((cause) => + Effect.fail( + new TailcatCommandError({ + subcommand: input.subcommand, + exitCode: null, + detail: `The Tailcat runtime at ${input.executablePath} could not be started.`, + cause, + }), + ), + ), + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => + Effect.fail( + new TailcatCommandError({ + subcommand: input.subcommand, + exitCode: null, + detail: `tailcat ${input.subcommand} did not finish within ${Duration.toMillis(timeout)}ms.`, + }), + ), + }), + ); + }); + + const readVersion = Effect.fn("TailcatRuntime.readVersion")(function* ( + executablePath: string, + ): Effect.fn.Return { + const result = yield* runCommand({ executablePath, args: ["version"], subcommand: "version" }); + const version = normalizeTailcatVersion(result.stdout); + if (result.exitCode !== 0 || version === null) { + return yield* new TailcatCommandError({ + subcommand: "version", + exitCode: result.exitCode, + detail: `The Tailcat runtime at ${executablePath} did not report a version.`, + }); + } + return version; + }); + + const resolveUncached = Effect.fn("TailcatRuntime.resolve")(function* (): Effect.fn.Return< + TailcatRuntimeInfo, + TailcatResolveError + > { + const candidates: Array<{ readonly path: string; readonly source: TailcatRuntimeSource }> = []; + const override = options.resolution.overridePath?.trim(); + if (override) { + candidates.push({ path: override, source: "override" }); + } + for (const candidate of options.resolution.bundledCandidates) { + candidates.push({ path: candidate, source: "bundled" }); + } + if (options.resolution.allowSystem) { + const onPath = yield* findOnPath(); + if (Option.isSome(onPath)) { + candidates.push({ path: onPath.value, source: "system" }); + } + } + + for (const candidate of candidates) { + if (!(yield* checkExecutable(candidate.path))) { + if (candidate.source === "override") { + return yield* new TailcatBinaryMissingError({ + candidates: [candidate.path], + detail: `T3CODE_TAILCAT_BINARY points at ${candidate.path}, which does not exist.`, + }); + } + continue; + } + const version = yield* readVersion(candidate.path); + const compatible = isCompatibleTailcatVersion(version); + if (!compatible) { + return yield* new TailcatVersionIncompatibleError({ + path: candidate.path, + version, + compatibleRange: TAILCAT_COMPATIBLE_RANGE, + detail: `Tailcat ${version} at ${candidate.path} is not compatible with this T3 Code build, which expects ${TAILCAT_COMPATIBLE_RANGE} (bundled ${TAILCAT_PINNED_VERSION}).`, + }); + } + return { + executablePath: candidate.path, + source: candidate.source, + version, + pinnedVersion: TAILCAT_PINNED_VERSION, + compatible, + }; + } + + const platformKey = tailcatPlatformKey(platform, architecture); + return yield* new TailcatBinaryMissingError({ + candidates: candidates.map((candidate) => candidate.path), + detail: + platformKey === undefined + ? `Tailcat is not available for ${platform}/${architecture}.` + : "The Tailcat runtime is not available. Reinstall T3 Code, or install tailcat and set T3CODE_TAILCAT_BINARY.", + }); + }); + + const cache = yield* SynchronizedRef.make>(Option.none()); + const resolve: TailcatRuntime["Service"]["resolve"] = SynchronizedRef.modifyEffect( + cache, + (cached) => + Option.isSome(cached) + ? Effect.succeed([cached.value, cached] as const) + : resolveUncached().pipe(Effect.map((info) => [info, Option.some(info)] as const)), + ); + const refresh: TailcatRuntime["Service"]["refresh"] = SynchronizedRef.set( + cache, + Option.none(), + ).pipe(Effect.andThen(resolve)); + + const requireAddress = (raw: string) => + Effect.gen(function* () { + const decoded = decodeTailcatAddress(raw); + if (Result.isFailure(decoded)) { + return yield* decoded.failure; + } + return raw.trim() as TailcatAddress; + }); + + const generateServerIdentity: TailcatRuntime["Service"]["generateServerIdentity"] = Effect.fn( + "TailcatRuntime.generateServerIdentity", + )(function* ({ keyPath }) { + const runtime = yield* resolve; + yield* fileSystem.makeDirectory(path.dirname(keyPath), { recursive: true }).pipe(Effect.ignore); + // A fixed region bakes the DERP bootstrap region into the address, so the + // address stays stable across restarts instead of changing with whichever + // relay happens to be nearest at each start. + const result = yield* runCommand({ + executablePath: runtime.executablePath, + args: ["genkey", `--key=${keyPath}`, "--fixed-region", "--force"], + subcommand: "genkey", + timeout: TAILCAT_SERVE_READY_TIMEOUT, + }); + const lines = result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + const address = lines.find((line) => line.startsWith("tc")); + if (result.exitCode !== 0 || address === undefined) { + return yield* new TailcatCommandError({ + subcommand: "genkey", + exitCode: result.exitCode, + detail: `Could not create a Tailcat identity: ${redactTailcatOutputLine(result.stderr.trim()) || "tailcat genkey failed"}.`, + }); + } + yield* fileSystem.chmod(keyPath, 0o600).pipe(Effect.ignore); + return { address: yield* requireAddress(address) }; + }); + + const generateClientIdentity: TailcatRuntime["Service"]["generateClientIdentity"] = Effect.fn( + "TailcatRuntime.generateClientIdentity", + )(function* ({ keyPath }) { + const runtime = yield* resolve; + yield* fileSystem.makeDirectory(path.dirname(keyPath), { recursive: true }).pipe(Effect.ignore); + const result = yield* runCommand({ + executablePath: runtime.executablePath, + args: ["genkey", "--client", `--key=${keyPath}`, "--force"], + subcommand: "genkey", + }); + const nodeKey = result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .find(isTailcatNodeKey); + if (result.exitCode !== 0 || nodeKey === undefined) { + return yield* new TailcatCommandError({ + subcommand: "genkey", + exitCode: result.exitCode, + detail: `Could not create a Tailcat client identity: ${redactTailcatOutputLine(result.stderr.trim()) || "tailcat genkey failed"}.`, + }); + } + yield* fileSystem.chmod(keyPath, 0o600).pipe(Effect.ignore); + return { nodeKey }; + }); + + const readClientPublicKey: TailcatRuntime["Service"]["readClientPublicKey"] = Effect.fn( + "TailcatRuntime.readClientPublicKey", + )(function* ({ keyPath }) { + const runtime = yield* resolve; + // `printpub` silently mints an ephemeral key when the file is missing, which + // would trust a key nobody holds. Refuse instead. + const exists = yield* fileSystem.exists(keyPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return yield* new TailcatCommandError({ + subcommand: "printpub", + exitCode: null, + detail: `The Tailcat client identity at ${keyPath} does not exist.`, + }); + } + const result = yield* runCommand({ + executablePath: runtime.executablePath, + args: [`--key=${keyPath}`, "printpub"], + subcommand: "printpub", + }); + const nodeKey = result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .find(isTailcatNodeKey); + if (result.exitCode !== 0 || nodeKey === undefined) { + return yield* new TailcatCommandError({ + subcommand: "printpub", + exitCode: result.exitCode, + detail: "Could not read the Tailcat client identity.", + }); + } + return nodeKey; + }); + + interface SpawnedProcess { + readonly handle: ChildProcessSpawner.ChildProcessHandle; + readonly output: OutputBuffer; + readonly exited: Deferred.Deferred>; + readonly stdoutLines: Stream.Stream; + } + + /** + * Spawns a long-running tailcat process bound to the current Scope. Stderr is + * drained into the bounded output buffer; stdout is exposed as a line stream + * for the caller to consume (serve's JSON), and also mirrored into the buffer. + */ + const spawnLongRunning = Effect.fn("TailcatRuntime.spawnLongRunning")(function* (input: { + readonly executablePath: string; + readonly args: ReadonlyArray; + readonly subcommand: "serve" | "forward"; + }): Effect.fn.Return { + const output = yield* makeOutputBuffer(); + const handle = yield* spawner + .spawn( + ChildProcess.make(input.executablePath, input.args, { + stdin: "ignore", + killSignal: "SIGTERM", + forceKillAfter: TAILCAT_PROCESS_STOP_GRACE, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new TailcatStartupError({ + subcommand: input.subcommand, + exitCode: null, + recentOutput: [], + detail: `Could not start tailcat ${input.subcommand}.`, + cause, + }), + ), + Effect.catchDefect((cause) => + Effect.fail( + new TailcatStartupError({ + subcommand: input.subcommand, + exitCode: null, + recentOutput: [], + detail: `Could not start tailcat ${input.subcommand}.`, + cause, + }), + ), + ), + ); + const exited = yield* Deferred.make>(); + yield* lineStream(handle.stderr).pipe( + Stream.runForEach(output.push), + Effect.ignore, + Effect.forkScoped, + ); + yield* handle.exitCode.pipe( + Effect.map((code) => Option.some(Number(code))), + Effect.orElseSucceed(() => Option.none()), + Effect.flatMap((code) => Deferred.succeed(exited, code)), + Effect.forkScoped, + ); + const stdoutLines = lineStream(handle.stdout).pipe( + Stream.tap(output.push), + Stream.catch(() => Stream.empty), + ); + return { handle, output, exited, stdoutLines }; + }); + + const processHandle = (spawned: SpawnedProcess): TailcatProcessHandle => ({ + pid: Number(spawned.handle.pid), + exit: Deferred.await(spawned.exited), + isRunning: spawned.handle.isRunning.pipe(Effect.orElseSucceed(() => false)), + recentOutput: spawned.output.lines, + stop: spawned.handle + .kill({ killSignal: "SIGTERM", forceKillAfter: TAILCAT_PROCESS_STOP_GRACE }) + .pipe(Effect.ignore), + }); + + const serve: TailcatRuntime["Service"]["serve"] = Effect.fn("TailcatRuntime.serve")(function* ({ + keyPath, + localPort, + allow, + }) { + const runtime = yield* resolve; + const args = [ + "--json", + `--key=${keyPath}`, + "serve", + ...tailcatAllowFlag(allow), + String(localPort), + ]; + const spawned = yield* spawnLongRunning({ + executablePath: runtime.executablePath, + args, + subcommand: "serve", + }); + const firstJsonLine = spawned.stdoutLines.pipe( + Stream.map((line) => decodeServeListenAddr(line)), + Stream.filter(Option.isSome), + Stream.map((line) => line.value.listenAddr), + Stream.runHead, + ); + const settled = yield* Effect.raceFirst( + firstJsonLine.pipe(Effect.map((address) => ({ _tag: "address" as const, address }))), + Deferred.await(spawned.exited).pipe( + Effect.map((exitCode) => ({ _tag: "exited" as const, exitCode })), + ), + ).pipe(Effect.timeoutOption(TAILCAT_SERVE_READY_TIMEOUT)); + // The race must settle before the kill: killing inside a `timeoutOrElse` + // fallback lets the exit branch win and reports a crash, not a timeout. + if (Option.isNone(settled)) { + yield* spawned.handle + .kill({ killSignal: "SIGTERM", forceKillAfter: TAILCAT_PROCESS_STOP_GRACE }) + .pipe(Effect.ignore); + return yield* new TailcatTimeoutError({ + subcommand: "serve", + timeoutMs: Duration.toMillis(TAILCAT_SERVE_READY_TIMEOUT), + detail: + "Tailcat did not publish an address in time. Check that this machine can reach the internet.", + }); + } + const ready = settled.value; + const recentOutput = yield* spawned.output.lines; + if (ready._tag === "exited" || Option.isNone(ready.address)) { + const exitCode = ready._tag === "exited" ? Option.getOrNull(ready.exitCode) : null; + if (isPortInUseOutput(recentOutput)) { + return yield* new TailcatPortInUseError({ + port: localPort, + detail: `Port ${localPort} is already in use.`, + }); + } + return yield* new TailcatStartupError({ + subcommand: "serve", + exitCode, + recentOutput, + detail: + recentOutput.at(-1) !== undefined + ? `Tailcat could not start serving: ${recentOutput.at(-1)}` + : "Tailcat exited before it published an address.", + }); + } + const address = yield* requireAddress(ready.address.value).pipe( + Effect.mapError( + (error) => + new TailcatStartupError({ + subcommand: "serve", + exitCode: null, + recentOutput, + detail: `Tailcat published an address T3 could not decode: ${error.detail}`, + }), + ), + ); + return { + ...processHandle(spawned), + address, + localPort, + allow, + } satisfies TailcatServeHandle; + }); + + const waitForLocalListener = Effect.fn("TailcatRuntime.waitForLocalListener")(function* ( + localPort: number, + ) { + yield* Effect.gen(function* () { + const listening = yield* net.hasListenerOnHost(localPort, "127.0.0.1"); + if (!listening) { + return yield* Effect.fail("not-listening" as const); + } + }).pipe( + Effect.retry( + Schedule.spaced(Duration.millis(50)).pipe( + Schedule.upTo({ duration: TAILCAT_FORWARD_LISTEN_TIMEOUT }), + ), + ), + ); + }); + + const forward: TailcatRuntime["Service"]["forward"] = Effect.fn("TailcatRuntime.forward")( + function* (input) { + const runtime = yield* resolve; + const address = yield* requireAddress(input.address); + const args = [ + ...(input.keyPath === null ? [] : [`--key=${input.keyPath}`]), + "forward", + address, + `${input.localPort}:${input.remotePort}`, + ]; + const spawned = yield* spawnLongRunning({ + executablePath: runtime.executablePath, + args, + subcommand: "forward", + }); + const httpBaseUrl = `http://127.0.0.1:${input.localPort}/`; + const wsBaseUrl = `ws://127.0.0.1:${input.localPort}/`; + const readinessTimeout = Duration.fromInputUnsafe( + input.readinessTimeout ?? TAILCAT_SERVE_READY_TIMEOUT, + ); + const becomeReady = Effect.gen(function* () { + yield* waitForLocalListener(input.localPort); + if (input.readiness !== undefined) { + yield* input.readiness({ httpBaseUrl }); + } + return { _tag: "ready" as const }; + }); + const settled = yield* Effect.raceFirst( + becomeReady, + Deferred.await(spawned.exited).pipe( + Effect.map((exitCode) => ({ _tag: "exited" as const, exitCode })), + ), + ).pipe( + Effect.catch((error) => + Effect.gen(function* () { + // A failed readiness probe or listen timeout must not leave a + // forwarder behind: the process is owned by this attempt. + yield* spawned.handle + .kill({ killSignal: "SIGTERM", forceKillAfter: TAILCAT_PROCESS_STOP_GRACE }) + .pipe(Effect.ignore); + if (error === "not-listening") { + const recentOutput = yield* spawned.output.lines; + return yield* new TailcatStartupError({ + subcommand: "forward", + exitCode: null, + recentOutput, + detail: "Tailcat did not open the local forwarding port.", + }); + } + return yield* Effect.fail(error); + }), + ), + Effect.timeoutOption(readinessTimeout), + ); + // The race must settle before the kill: killing inside a `timeoutOrElse` + // fallback lets the exit branch win and reports a crash, not a timeout. + if (Option.isNone(settled)) { + yield* spawned.handle + .kill({ killSignal: "SIGTERM", forceKillAfter: TAILCAT_PROCESS_STOP_GRACE }) + .pipe(Effect.ignore); + return yield* new TailcatTimeoutError({ + subcommand: "forward", + timeoutMs: Duration.toMillis(readinessTimeout), + detail: + "The remote machine did not answer through the Tailcat tunnel in time. It may be offline, or this device may not be trusted by it.", + }); + } + const outcome = settled.value; + if (outcome._tag === "exited") { + const recentOutput = yield* spawned.output.lines; + if (isPortInUseOutput(recentOutput)) { + return yield* new TailcatPortInUseError({ + port: input.localPort, + detail: `Local port ${input.localPort} is already in use.`, + }); + } + return yield* new TailcatStartupError({ + subcommand: "forward", + exitCode: Option.getOrNull(outcome.exitCode), + recentOutput, + detail: + recentOutput.at(-1) !== undefined + ? `Tailcat could not start forwarding: ${recentOutput.at(-1)}` + : "Tailcat exited before the local forwarding port was ready.", + }); + } + return { + ...processHandle(spawned), + address, + remotePort: input.remotePort, + localPort: input.localPort, + httpBaseUrl, + wsBaseUrl, + } satisfies TailcatForwardHandle; + }, + ); + + const ping: TailcatRuntime["Service"]["ping"] = Effect.fn("TailcatRuntime.ping")( + function* (input) { + const runtime = yield* resolve; + const address = yield* requireAddress(input.address); + const timeout = Duration.fromInputUnsafe(input.timeout ?? TAILCAT_PING_DEFAULT_TIMEOUT); + const timeoutSeconds = Math.max(1, Math.ceil(Duration.toMillis(timeout) / 1000)); + const result = yield* runCommand({ + executablePath: runtime.executablePath, + args: [ + ...(input.keyPath === null ? [] : [`--key=${input.keyPath}`]), + "ping", + `--timeout=${timeoutSeconds}s`, + address, + ], + subcommand: "ping", + timeout: Duration.millis(Duration.toMillis(timeout) + 5_000), + }); + const measuredAt = DateTime.formatIso(yield* DateTime.now); + const pong = result.stdout + .split(/\r?\n/u) + .map((line) => parseTailcatPong(line, measuredAt)) + .find((probe) => probe !== null); + if (pong === undefined || pong === null) { + return yield* new TailcatCommandError({ + subcommand: "ping", + exitCode: result.exitCode, + detail: + result.exitCode === 0 + ? "Tailcat ping returned no result." + : "The remote machine did not answer the Tailcat ping. It may be offline, or this device may not be trusted by it.", + }); + } + return pong; + }, + ); + + return TailcatRuntime.of({ + resolve, + refresh, + generateServerIdentity, + generateClientIdentity, + readClientPublicKey, + serve, + forward, + ping, + }); +}); + +export const layer = (options: TailcatRuntimeLayerOptions) => + Layer.effect(TailcatRuntime, make(options)); + +/** + * Bundled binary locations relative to a module directory, matching how the + * resource monitor is staged: a `tailcat//` directory next to the + * bundle, a flat `tailcat/` directory, and the monorepo's `native/tailcat/dist`. + */ +export function bundledTailcatCandidates(input: { + readonly platform: NodeJS.Platform; + readonly architecture: NodeJS.Architecture; + readonly joinPath: (...segments: ReadonlyArray) => string; + readonly moduleDirectory: string; + readonly repoRootCandidates?: ReadonlyArray; +}): ReadonlyArray { + const platformKey = tailcatPlatformKey(input.platform, input.architecture); + if (platformKey === undefined) { + return []; + } + const executable = tailcatExecutableName(input.platform); + const candidates = [ + input.joinPath(input.moduleDirectory, "tailcat", platformKey, executable), + input.joinPath(input.moduleDirectory, "tailcat", executable), + input.joinPath(input.moduleDirectory, "..", "tailcat", executable), + ]; + for (const repoRoot of input.repoRootCandidates ?? []) { + candidates.push(input.joinPath(repoRoot, "native", "tailcat", "dist", platformKey, executable)); + } + return candidates; +} + +export const TAILCAT_BINARY_OVERRIDE_ENV = "T3CODE_TAILCAT_BINARY"; + +/** Reads the developer override from the host environment. */ +export const tailcatOverridePathFromEnvironment = Effect.map(HostProcessEnvironment, (env) => { + const value = env[TAILCAT_BINARY_OVERRIDE_ENV]?.trim(); + return value && value.length > 0 ? value : undefined; +}); diff --git a/packages/tailcat/tsconfig.json b/packages/tailcat/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/tailcat/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 032a1e05ff6d..7f55ee77aa95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: '@t3tools/ssh': specifier: workspace:* version: link:../../packages/ssh + '@t3tools/tailcat': + specifier: workspace:* + version: link:../../packages/tailcat '@t3tools/tailscale': specifier: workspace:* version: link:../../packages/tailscale @@ -521,6 +524,9 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared + '@t3tools/tailcat': + specifier: workspace:* + version: link:../../packages/tailcat '@t3tools/tailscale': specifier: workspace:* version: link:../../packages/tailscale @@ -939,6 +945,31 @@ importers: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/tailcat: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + devDependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/tailscale: dependencies: '@t3tools/shared': From 03949fbf38f26802db9667809ae80fb701f20622 Mon Sep 17 00:00:00 2001 From: Bear Huddleston Date: Thu, 3 Sep 2026 19:08:14 -0500 Subject: [PATCH 03/12] feat(client-runtime): add the Tailcat connection target and gateway Co-Authored-By: Claude Fable 5.1 --- .../src/authorization/remote.ts | 9 ++ .../client-runtime/src/connection/catalog.ts | 47 +++++- .../client-runtime/src/connection/index.ts | 4 + .../client-runtime/src/connection/model.ts | 16 ++ .../src/connection/onboarding.tailcat.test.ts | 144 ++++++++++++++++++ .../src/connection/onboarding.ts | 95 +++++++++++- .../src/connection/presentation.ts | 37 +++++ .../src/connection/registry.test.ts | 6 + .../client-runtime/src/connection/registry.ts | 25 ++- .../src/connection/resolver.test.ts | 8 + .../client-runtime/src/connection/resolver.ts | 71 +++++++++ .../src/platform/capabilities.ts | 35 +++++ .../src/platform/storageDocument.ts | 14 +- packages/client-runtime/src/rpc/client.ts | 5 +- 14 files changed, 494 insertions(+), 22 deletions(-) create mode 100644 packages/client-runtime/src/connection/onboarding.tailcat.test.ts diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 398a592e499d..96544be01b39 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -120,6 +120,12 @@ export const bootstrapRemoteBearerSession = Effect.fn( readonly credential: string; readonly scopes?: ReadonlyArray; readonly clientMetadata?: AuthClientPresentationMetadata; + /** + * This client's Tailcat node key. The server only honors it when the + * credential came from a Tailcat connection code, and then keeps admitting + * this device at the transport layer after the pairing window closes. + */ + readonly clientTailcatNodeKey?: string; readonly timeoutMs?: number; }) { const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); @@ -135,6 +141,9 @@ export const bootstrapRemoteBearerSession = Effect.fn( requested_token_type: AuthAccessTokenType, ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), ...clientMetadataTokenExchangeFields(input.clientMetadata), + ...(input.clientTailcatNodeKey + ? { client_tailcat_node_key: input.clientTailcatNodeKey } + : {}), }, }), ); diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index a79307947c5e..8762d1054062 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -1,4 +1,9 @@ -import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts"; +import { + DesktopSshEnvironmentTargetSchema, + EnvironmentId, + PortSchema, + TailcatAddress, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -7,6 +12,7 @@ import { PrimaryConnectionTarget, RelayConnectionTarget, SshConnectionTarget, + TailcatConnectionTarget, type ConnectionTarget, } from "./model.ts"; @@ -33,7 +39,20 @@ export class SshConnectionProfile extends Schema.TaggedClass()( + "TailcatConnectionProfile", + { + ...ConnectionProfileBase, + address: TailcatAddress, + remotePort: PortSchema, + }, +) {} + +export const ConnectionProfile = Schema.Union([ + BearerConnectionProfile, + SshConnectionProfile, + TailcatConnectionProfile, +]); export type ConnectionProfile = typeof ConnectionProfile.Type; export interface ConnectionCatalogEntry { @@ -82,10 +101,20 @@ export class SshConnectionRegistration extends Schema.TaggedClass()( + "TailcatConnectionRegistration", + { + target: TailcatConnectionTarget, + profile: TailcatConnectionProfile, + credential: BearerConnectionCredential, + }, +) {} + export const ConnectionRegistration = Schema.Union([ RelayConnectionRegistration, BearerConnectionRegistration, SshConnectionRegistration, + TailcatConnectionRegistration, ]); export type ConnectionRegistration = typeof ConnectionRegistration.Type; @@ -116,9 +145,23 @@ export function connectionRegistrationCatalogEntry( }; case "BearerConnectionRegistration": case "SshConnectionRegistration": + case "TailcatConnectionRegistration": return { target: registration.target, profile: Option.some(registration.profile), }; } } + +/** Targets whose profile and credential live under a connection id. */ +export function connectionTargetConnectionId(target: ConnectionTarget): string | null { + switch (target._tag) { + case "PrimaryConnectionTarget": + case "RelayConnectionTarget": + return null; + case "BearerConnectionTarget": + case "SshConnectionTarget": + case "TailcatConnectionTarget": + return target.connectionId; + } +} diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 53a041bbf307..55cfc93d9566 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -14,11 +14,15 @@ export { ConnectionOnboarding, type PairingConnectionInput, type SshConnectionInput, + type TailcatConnectionInput, + parseTailcatConnectionCode, prepareBearerConnectionUpdate, preparePairingRegistration, prepareSshRegistration, + prepareTailcatRegistration, registerPairingConnection, registerSshConnection, + registerTailcatConnection, updateBearerConnection, } from "./onboarding.ts"; export * from "./presentation.ts"; diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index e67e8bdf7493..42ec2ffe20bd 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -38,11 +38,25 @@ export class SshConnectionTarget extends Schema.TaggedClass }, ) {} +/** + * A remote environment reached through a desktop-managed Tailcat forward. The + * logical endpoint (tailcat address + remote port) lives in the profile; the + * loopback port the forwarder binds is runtime state and never persisted. + */ +export class TailcatConnectionTarget extends Schema.TaggedClass()( + "TailcatConnectionTarget", + { + ...ConnectionTargetBase, + connectionId: Schema.String, + }, +) {} + export const ConnectionTarget = Schema.Union([ PrimaryConnectionTarget, BearerConnectionTarget, RelayConnectionTarget, SshConnectionTarget, + TailcatConnectionTarget, ]); export type ConnectionTarget = typeof ConnectionTarget.Type; @@ -50,6 +64,7 @@ export const PersistedConnectionTarget = Schema.Union([ BearerConnectionTarget, RelayConnectionTarget, SshConnectionTarget, + TailcatConnectionTarget, ]); export type PersistedConnectionTarget = typeof PersistedConnectionTarget.Type; @@ -64,6 +79,7 @@ export const ConnectionTransientReason = Schema.Literals([ "endpoint-unavailable", "relay-unavailable", "remote-unavailable", + "tailcat-unavailable", ]); export type ConnectionTransientReason = typeof ConnectionTransientReason.Type; diff --git a/packages/client-runtime/src/connection/onboarding.tailcat.test.ts b/packages/client-runtime/src/connection/onboarding.tailcat.test.ts new file mode 100644 index 000000000000..f94174759ab3 --- /dev/null +++ b/packages/client-runtime/src/connection/onboarding.tailcat.test.ts @@ -0,0 +1,144 @@ +import { EnvironmentId, type TailcatAddress, type TailcatNodeKey } from "@t3tools/contracts"; +import { encodeTailcatConnectionCode } from "@t3tools/shared/t3ConnectionCode"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { TailcatEnvironmentGateway } from "../platform/capabilities.ts"; +import { prepareTailcatRegistration } from "./onboarding.ts"; + +const ADDRESS = + "tco2FwWCBsyGP41dXrPe-jN6lGVysle1gLOeO06eQXFFnAEyTVWmFrWCBXI4Jlw0AzfV9loUv7embdWaR2qZD6dhGPBqQDMD1-a2FpGQEu" as TailcatAddress; +const NODE_KEY = `nodekey:${"ab".repeat(32)}` as TailcatNodeKey; +const ENVIRONMENT_ID = EnvironmentId.make("environment-tailcat"); + +const code = (options: { readonly withPairingToken?: boolean } = {}) => + encodeTailcatConnectionCode({ + v: 1, + transport: "tailcat", + address: ADDRESS, + port: 47831, + environmentId: ENVIRONMENT_ID, + name: "gpu-box", + serverVersion: "0.0.38", + ...(options.withPairingToken === false ? {} : { pairingToken: "PAIRTOKEN123" }), + expiresAt: "2026-09-03T20:05:21.215Z", + }); + +const gateway = (environmentId: EnvironmentId = ENVIRONMENT_ID) => + TailcatEnvironmentGateway.of({ + provision: (payload) => + Effect.succeed({ + environmentId, + label: "GPU box", + bootstrap: { + connectionId: `tailcat:${environmentId}`, + address: payload.address, + remotePort: payload.port, + localPort: 48831, + httpBaseUrl: "http://127.0.0.1:48831", + wsBaseUrl: "ws://127.0.0.1:48831", + clientNodeKey: NODE_KEY, + }, + bearerToken: "bearer-token", + }), + prepare: () => Effect.die("unused"), + disconnect: () => Effect.die("unused"), + }); + +describe("tailcat onboarding", () => { + it.effect("registers the logical Tailcat endpoint, never the local forward port", () => + Effect.gen(function* () { + const registration = yield* prepareTailcatRegistration({ code: code() }).pipe( + Effect.provideService(TailcatEnvironmentGateway, gateway()), + ); + + expect(registration).toMatchObject({ + _tag: "TailcatConnectionRegistration", + target: { + _tag: "TailcatConnectionTarget", + environmentId: ENVIRONMENT_ID, + label: "GPU box", + connectionId: `tailcat:${ENVIRONMENT_ID}`, + }, + profile: { + _tag: "TailcatConnectionProfile", + connectionId: `tailcat:${ENVIRONMENT_ID}`, + address: ADDRESS, + remotePort: 47831, + }, + credential: { _tag: "BearerConnectionCredential", token: "bearer-token" }, + }); + expect(Object.values(registration.profile)).not.toContain(48831); + }), + ); + + it.effect("prefers an explicit label over the descriptor label", () => + Effect.gen(function* () { + const registration = yield* prepareTailcatRegistration({ + code: code(), + label: " Office box ", + }).pipe(Effect.provideService(TailcatEnvironmentGateway, gateway())); + expect(registration.target.label).toBe("Office box"); + }), + ); + + it.effect("refuses a code whose environment differs from the machine that answered", () => + Effect.gen(function* () { + const result = yield* prepareTailcatRegistration({ code: code() }).pipe( + Effect.provideService( + TailcatEnvironmentGateway, + gateway(EnvironmentId.make("environment-other")), + ), + Effect.flip, + ); + expect(result).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "configuration", + }); + }), + ); + + it.effect("refuses a code without a pairing credential before opening a tunnel", () => + Effect.gen(function* () { + let provisioned = false; + const result = yield* prepareTailcatRegistration({ + code: code({ withPairingToken: false }), + }).pipe( + Effect.provideService( + TailcatEnvironmentGateway, + TailcatEnvironmentGateway.of({ + provision: () => + Effect.sync(() => { + provisioned = true; + }).pipe(Effect.andThen(Effect.die("unreachable"))), + prepare: () => Effect.die("unused"), + disconnect: () => Effect.die("unused"), + }), + ), + Effect.flip, + ); + expect(result).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "authentication", + }); + expect(provisioned).toBe(false); + }), + ); + + it.effect("explains an invalid or foreign code", () => + Effect.gen(function* () { + const notACode = yield* prepareTailcatRegistration({ code: "https://example.com/pair" }).pipe( + Effect.provideService(TailcatEnvironmentGateway, gateway()), + Effect.flip, + ); + expect(notACode).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration" }); + + const peerCode = yield* prepareTailcatRegistration({ code: "t3c://peer/eyJ2IjoxfQ" }).pipe( + Effect.provideService(TailcatEnvironmentGateway, gateway()), + Effect.flip, + ); + expect(peerCode).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration" }); + expect(peerCode.detail).toMatch(/peer|Tailcat connection code/iu); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2cc..e81fb03969fe 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -1,5 +1,13 @@ -import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; +import type { + DesktopSshEnvironmentTarget, + EnvironmentId, + TailcatConnectionCodePayload, +} from "@t3tools/contracts"; import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; +import { + T3ConnectionCodeInvalidError, + decodeTailcatConnectionCode, +} from "@t3tools/shared/t3ConnectionCode"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -20,6 +28,8 @@ import { type ConnectionCredential, SshConnectionProfile, SshConnectionRegistration, + TailcatConnectionProfile, + TailcatConnectionRegistration, } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; import { mapRemoteEnvironmentError } from "./errors.ts"; @@ -27,6 +37,7 @@ import { BearerConnectionTarget, ConnectionBlockedError, SshConnectionTarget, + TailcatConnectionTarget, type ConnectionAttemptError, } from "./model.ts"; import * as Persistence from "../platform/persistence.ts"; @@ -43,6 +54,12 @@ export interface SshConnectionInput { readonly label?: string; } +export interface TailcatConnectionInput { + /** A `t3c://tailcat/...` connection code copied or scanned from the remote machine. */ + readonly code: string; + readonly label?: string; +} + export interface BearerConnectionUpdateInput { readonly environmentId: EnvironmentId; readonly label: string; @@ -67,6 +84,12 @@ export class ConnectionOnboarding extends Context.Service< readonly updateBearer: ( input: BearerConnectionUpdateInput, ) => Effect.Effect; + readonly registerTailcat: ( + input: TailcatConnectionInput, + ) => Effect.Effect< + EnvironmentId, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; } >()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {} @@ -242,11 +265,76 @@ export const registerSshConnection = Effect.fn( return registration.target.environmentId; }); +const isT3ConnectionCodeInvalidError = Schema.is(T3ConnectionCodeInvalidError); + +export const parseTailcatConnectionCode = Effect.fn( + "clientRuntime.connection.onboarding.parseTailcatConnectionCode", +)(function* (code: string): Effect.fn.Return { + return yield* Effect.try({ + try: () => decodeTailcatConnectionCode(code), + catch: (cause) => + new ConnectionBlockedError({ + reason: "configuration", + detail: isT3ConnectionCodeInvalidError(cause) + ? cause.message + : "The Tailcat connection code is invalid.", + }), + }); +}); + +export const prepareTailcatRegistration = Effect.fn( + "clientRuntime.connection.onboarding.prepareTailcatRegistration", +)(function* (input: TailcatConnectionInput) { + const payload = yield* parseTailcatConnectionCode(input.code); + if (payload.pairingToken === undefined) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: + "This connection code has no pairing credential. Ask the other machine for a fresh code.", + }); + } + const gateway = yield* ClientCapabilities.TailcatEnvironmentGateway; + const provisioned = yield* gateway.provision(payload); + if (payload.environmentId !== undefined && payload.environmentId !== provisioned.environmentId) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: "The machine behind this Tailcat address is not the environment the code named.", + }); + } + const connectionId = `tailcat:${provisioned.environmentId}`; + const label = input.label?.trim() || provisioned.label || payload.name || "Tailcat environment"; + return new TailcatConnectionRegistration({ + target: new TailcatConnectionTarget({ + environmentId: provisioned.environmentId, + label, + connectionId, + }), + profile: new TailcatConnectionProfile({ + connectionId, + environmentId: provisioned.environmentId, + label, + address: payload.address, + remotePort: payload.port, + }), + credential: new BearerConnectionCredential({ token: provisioned.bearerToken }), + }); +}); + +export const registerTailcatConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerTailcatConnection", +)(function* (input: TailcatConnectionInput) { + const registration = yield* prepareTailcatRegistration(input); + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register(registration); + return registration.target.environmentId; +}); + export const make = Effect.gen(function* () { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const presentation = yield* ClientCapabilities.ClientPresentation; const httpClient = yield* HttpClient.HttpClient; const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const tailcat = yield* ClientCapabilities.TailcatEnvironmentGateway; const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; return ConnectionOnboarding.of({ @@ -266,6 +354,11 @@ export const make = Effect.gen(function* () { Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), Effect.provideService(ConnectionCredentialStore.ConnectionCredentialStore, credentials), ), + registerTailcat: (input) => + registerTailcatConnection(input).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.TailcatEnvironmentGateway, tailcat), + ), }); }); diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 168443deceb4..8f0864b0e7ab 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -103,6 +103,43 @@ export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): stri return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile" ? `${entry.profile.value.target.username}@${entry.profile.value.target.hostname}` : null; + case "TailcatConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "TailcatConnectionProfile" + ? `tailcat:${entry.profile.value.address}` + : null; + } +} + +export type ConnectionTransportKind = "local" | "direct" | "relay" | "ssh" | "tailcat"; + +/** The transport family a catalog entry uses, for labels like "Tailcat · Direct". */ +export function connectionTransportKind(entry: ConnectionCatalogEntry): ConnectionTransportKind { + switch (entry.target._tag) { + case "PrimaryConnectionTarget": + return "local"; + case "BearerConnectionTarget": + return "direct"; + case "RelayConnectionTarget": + return "relay"; + case "SshConnectionTarget": + return "ssh"; + case "TailcatConnectionTarget": + return "tailcat"; + } +} + +export function connectionTransportLabel(kind: ConnectionTransportKind): string { + switch (kind) { + case "local": + return "This machine"; + case "direct": + return "Direct"; + case "relay": + return "T3 Connect"; + case "ssh": + return "SSH"; + case "tailcat": + return "Tailcat"; } } diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 15df040643ea..479f2261b621 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -338,6 +338,11 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( prepare: () => Effect.die(new Error("SSH preparation is not used.")), disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), }); + const tailcatGateway = ClientCapabilities.TailcatEnvironmentGateway.of({ + provision: () => Effect.die(new Error("Tailcat provisioning is not used.")), + prepare: () => Effect.die(new Error("Tailcat preparation is not used.")), + disconnect: () => Effect.void, + }); const driver = ConnectionDriver.ConnectionDriver.of({ connect: (entry, reportProgress) => Effect.gen(function* () { @@ -381,6 +386,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, credentialStore), Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), Layer.succeed(ClientCapabilities.SshEnvironmentGateway, sshGateway), + Layer.succeed(ClientCapabilities.TailcatEnvironmentGateway, tailcatGateway), Layer.succeed(Connectivity.Connectivity, connectivity), Layer.succeed( ConnectionWakeups.ConnectionWakeups, diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 6907c43d6037..3e4d2d3e2d12 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -20,6 +20,7 @@ import { type PrimaryConnectionRegistration, SshConnectionProfile, connectionRegistrationCatalogEntry, + connectionTargetConnectionId, } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; @@ -137,15 +138,14 @@ export const make = Effect.gen(function* () { const driver = yield* ConnectionDriver.ConnectionDriver; const wakeups = yield* ConnectionWakeups.ConnectionWakeups; const ssh = yield* ClientCapabilities.SshEnvironmentGateway; + const tailcat = yield* ClientCapabilities.TailcatEnvironmentGateway; const persistedTargets = yield* storage.list; const initialEntries = new Map( yield* Effect.forEach( persistedTargets, Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* (target) { - const profile = - target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" - ? yield* profiles.get(target.connectionId) - : Option.none(); + const connectionId = connectionTargetConnectionId(target); + const profile = connectionId !== null ? yield* profiles.get(connectionId) : Option.none(); return [ target.environmentId, { target, profile } satisfies ConnectionCatalogEntry, @@ -552,10 +552,8 @@ export const make = Effect.gen(function* () { }); } const target = (yield* getEntry(environmentId)).target; - const profile = - target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" - ? yield* profiles.get(target.connectionId) - : Option.none(); + const connectionId = connectionTargetConnectionId(target); + const profile = connectionId !== null ? yield* profiles.get(connectionId) : Option.none(); yield* registrations.remove(target); yield* Ref.update(persistedTargetsByEnvironment, (current) => { @@ -599,6 +597,17 @@ export const make = Effect.gen(function* () { Effect.ignore, ); } + if (target._tag === "TailcatConnectionTarget") { + yield* tailcat.disconnect(target.connectionId).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not stop the Tailcat forwarder.", { + environmentId, + error, + }), + ), + Effect.ignore, + ); + } }), ); }); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index 5bfac4faadae..9d71b88d384a 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -190,6 +190,14 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o ), Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh), + Layer.succeed( + ClientCapabilities.TailcatEnvironmentGateway, + ClientCapabilities.TailcatEnvironmentGateway.of({ + provision: () => Effect.die("unused"), + prepare: () => Effect.die("unused"), + disconnect: () => Effect.void, + }), + ), Layer.succeed( ManagedRelay.ManagedRelayClient, relayClient( diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index a786ed9f9d8d..b5bd9ac66e6b 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -16,6 +16,7 @@ import { BearerConnectionProfile, type ConnectionCatalogEntry, SshConnectionProfile, + TailcatConnectionProfile, } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; import { @@ -31,6 +32,7 @@ import type { PrimaryConnectionTarget, RelayConnectionTarget, SshConnectionTarget, + TailcatConnectionTarget, } from "./model.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; @@ -46,6 +48,7 @@ export class ConnectionResolver extends Context.Service< const isBearerProfile = Schema.is(BearerConnectionProfile); const isSshProfile = Schema.is(SshConnectionProfile); +const isTailcatProfile = Schema.is(TailcatConnectionProfile); const isBearerCredential = Schema.is(BearerConnectionCredential); function primarySocketUrl( @@ -251,11 +254,77 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct }); }); +/** + * Tailcat targets pair once and keep the issued bearer session, like a plain + * bearer target. Each connection attempt only has to re-establish the + * forward, which the platform gateway owns, and then authorize through it. + */ +const makeTailcatBroker = Effect.fn("clientRuntime.connection.broker.makeTailcat")(function* () { + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const tailcat = yield* ClientCapabilities.TailcatEnvironmentGateway; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.tailcat")(function* ( + entry: ConnectionCatalogEntry & { readonly target: TailcatConnectionTarget }, + ) { + const target = entry.target; + const profile = yield* Option.match(entry.profile, { + onNone: () => Effect.fail(profileMissingError(target.connectionId)), + onSome: Effect.succeed, + }); + if (!isTailcatProfile(profile)) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: `Connection profile ${target.connectionId} is not a Tailcat connection.`, + }); + } + if (profile.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: profile.environmentId, + }); + } + const credential = yield* credentials.get(target.connectionId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(credentialMissingError(target.connectionId)), + onSome: Effect.succeed, + }), + ), + ); + if (!isBearerCredential(credential)) { + return yield* credentialMissingError(target.connectionId); + } + const prepared = yield* tailcat.prepare({ + connectionId: target.connectionId, + expectedEnvironmentId: target.environmentId, + address: profile.address, + remotePort: profile.remotePort, + }); + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: prepared.bootstrap.httpBaseUrl, + wsBaseUrl: prepared.bootstrap.wsBaseUrl, + bearerToken: credential.token, + connectionMethod: "tailcat", + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }); +}); + export const make = Effect.gen(function* () { const primary = yield* makePrimaryBroker(); const bearer = yield* makeBearerBroker(); const relay = yield* makeRelayBroker(); const ssh = yield* makeSshBroker(); + const tailcat = yield* makeTailcatBroker(); const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( entry: ConnectionCatalogEntry, @@ -274,6 +343,8 @@ export const make = Effect.gen(function* () { return yield* relay(target); case "SshConnectionTarget": return yield* ssh({ ...entry, target }); + case "TailcatConnectionTarget": + return yield* tailcat({ ...entry, target }); } }); diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index a20b7d404b2f..c3c1e91b5e05 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -3,6 +3,8 @@ import { type AuthEnvironmentScope, type DesktopSshEnvironmentBootstrap, type DesktopSshEnvironmentTarget, + type DesktopTailcatEnvironmentBootstrap, + type TailcatConnectionCodePayload, EnvironmentId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -50,6 +52,39 @@ export class PrimaryEnvironmentAuth extends Context.Service< } >()("@t3tools/client-runtime/platform/capabilities/PrimaryEnvironmentAuth") {} +export interface PreparedTailcatEnvironment { + readonly bootstrap: DesktopTailcatEnvironmentBootstrap; +} + +export interface ProvisionedTailcatEnvironment extends PreparedTailcatEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly bearerToken: string; +} + +/** + * The platform's Tailcat transport. Desktop implements it with a managed + * forwarder in the main process; web and mobile report it as unsupported, + * because a browser or phone has no process to run tailcat in. + */ +export class TailcatEnvironmentGateway extends Context.Service< + TailcatEnvironmentGateway, + { + /** Establishes the forward for a pasted code and pairs with T3 auth. */ + readonly provision: ( + code: TailcatConnectionCodePayload, + ) => Effect.Effect; + /** Ensures a live forward exists for a saved environment. */ + readonly prepare: (input: { + readonly connectionId: string; + readonly expectedEnvironmentId: EnvironmentId; + readonly address: string; + readonly remotePort: number; + }) => Effect.Effect; + readonly disconnect: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/capabilities/TailcatEnvironmentGateway") {} + export class SshEnvironmentGateway extends Context.Service< SshEnvironmentGateway, { diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 0ba55dfa2fbc..71882586cc65 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -4,6 +4,7 @@ import { type ConnectionRegistration, ConnectionCredential, ConnectionProfile, + connectionTargetConnectionId, } from "../connection/catalog.ts"; import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; import * as TokenStore from "../authorization/tokenStore.ts"; @@ -48,16 +49,8 @@ export function removeCatalogValue( return values.filter((value) => key(value) !== removedKey); } -function connectionIdOf(target: ConnectionTarget): string | null { - switch (target._tag) { - case "PrimaryConnectionTarget": - case "RelayConnectionTarget": - return null; - case "BearerConnectionTarget": - case "SshConnectionTarget": - return target.connectionId; - } -} +const connectionIdOf = (target: ConnectionTarget): string | null => + connectionTargetConnectionId(target); function removeConnectionMetadata( document: ConnectionCatalogDocument, @@ -109,6 +102,7 @@ export function registerConnectionInCatalog( case "RelayConnectionRegistration": return next; case "BearerConnectionRegistration": + case "TailcatConnectionRegistration": return { ...next, profiles: replaceCatalogValue( diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 38eb3735ab8f..e97bdcf4b202 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -54,7 +54,10 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus - | typeof WS_METHODS.terminalAttach; + | typeof WS_METHODS.terminalAttach + | typeof WS_METHODS.tailcatSubscribeRemoteAccess + | typeof WS_METHODS.federationSubscribePeers + | typeof WS_METHODS.federationSubscribeRemoteRuns; export type EnvironmentStreamCommandRpcTag = | typeof WS_METHODS.cloudInstallRelayClient From d823723b023945b260e48865f716fd2534dca733 Mon Sep 17 00:00:00 2001 From: Bear Huddleston Date: Thu, 3 Sep 2026 19:08:15 -0500 Subject: [PATCH 04/12] feat(server): Tailcat remote access and server-to-server federation Co-Authored-By: Claude Fable 5.1 --- apps/server/src/auth/EnvironmentAuth.ts | 124 +- apps/server/src/auth/PairingGrantStore.ts | 3 + apps/server/src/auth/RpcAuthorization.ts | 22 + apps/server/src/auth/http.ts | 28 +- apps/server/src/bin.test.ts | 2 + apps/server/src/cli/config.test.ts | 14 + apps/server/src/cli/config.ts | 22 + apps/server/src/cli/pair.ts | 8 +- apps/server/src/config.ts | 6 + .../src/environment/ServerEnvironment.test.ts | 2 + .../src/environment/ServerEnvironment.ts | 3 + .../src/federation/FederationIdentity.ts | 130 ++ .../src/federation/FederationPeerStore.ts | 249 +++ .../src/federation/FederationService.ts | 1354 +++++++++++++++++ .../src/federation/FederationTransport.ts | 224 +++ apps/server/src/federation/http.ts | 124 ++ .../src/federation/runProjection.test.ts | 478 ++++++ apps/server/src/federation/runProjection.ts | 127 ++ apps/server/src/server.test.ts | 14 + apps/server/src/server.ts | 85 +- .../src/tailcat/TailcatRemoteAccess.test.ts | 440 ++++++ .../server/src/tailcat/TailcatRemoteAccess.ts | 784 ++++++++++ apps/server/src/tailcat/TailcatRuntimeLive.ts | 45 + apps/server/src/tailcat/http.ts | 48 + apps/server/src/tailcat/startupOutput.ts | 32 + apps/server/src/ws.ts | 118 ++ 26 files changed, 4441 insertions(+), 45 deletions(-) create mode 100644 apps/server/src/federation/FederationIdentity.ts create mode 100644 apps/server/src/federation/FederationPeerStore.ts create mode 100644 apps/server/src/federation/FederationService.ts create mode 100644 apps/server/src/federation/FederationTransport.ts create mode 100644 apps/server/src/federation/http.ts create mode 100644 apps/server/src/federation/runProjection.test.ts create mode 100644 apps/server/src/federation/runProjection.ts create mode 100644 apps/server/src/tailcat/TailcatRemoteAccess.test.ts create mode 100644 apps/server/src/tailcat/TailcatRemoteAccess.ts create mode 100644 apps/server/src/tailcat/TailcatRuntimeLive.ts create mode 100644 apps/server/src/tailcat/http.ts create mode 100644 apps/server/src/tailcat/startupOutput.ts diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 2d0f02274de9..8177e5737158 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -61,6 +61,17 @@ export interface IssuedBearerSession { readonly expiresAt: DateTime.Utc; } +/** A completed bootstrap exchange with the facts callers need beyond the wire result. */ +export interface BootstrapCredentialExchange { + readonly result: AuthAccessTokenResult; + readonly sessionId: AuthSessionId; + readonly grant: { + readonly id?: string; + readonly subject: string; + readonly label?: string; + }; +} + export interface AuthenticatedSession { readonly sessionId: AuthSessionId; readonly subject: string; @@ -441,6 +452,18 @@ export class EnvironmentAuth extends Context.Service< AuthAccessTokenResult, ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError >; + /** Same exchange, also reporting which grant was consumed and the session it made. */ + readonly exchangeBootstrapCredential: ( + credential: string, + requestedScopes: ReadonlyArray | undefined, + requestMetadata: AuthClientMetadata, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect< + BootstrapCredentialExchange, + ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + >; readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; readonly label?: string; @@ -729,44 +752,50 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); - const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = - (credential, requestedScopes, requestMetadata, input) => - bootstrapCredentials.consume(credential, input).pipe( - Effect.mapError(toBootstrapExchangeError), - Effect.flatMap((grant) => - Effect.gen(function* () { - const grantedScopes = requestedScopes ?? grant.scopes; - if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthScopeNotGrantedError({}); - } - return yield* sessions - .issue({ - method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", - subject: grant.subject, - scopes: grantedScopes, - ...(input?.proofKeyThumbprint - ? { - proofKeyThumbprint: input.proofKeyThumbprint, - ttl: Duration.hours(1), - } - : {}), - client: { - ...requestMetadata, - ...(grant.label ? { label: grant.label } : {}), - }, - }) - .pipe( - Effect.mapError( - (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), - ), - ); - }), - ), - Effect.flatMap((session) => - DateTime.now.pipe( - Effect.map( - (now) => - ({ + const exchangeBootstrapCredential: EnvironmentAuth["Service"]["exchangeBootstrapCredential"] = ( + credential, + requestedScopes, + requestMetadata, + input, + ) => + bootstrapCredentials.consume(credential, input).pipe( + Effect.mapError(toBootstrapExchangeError), + Effect.flatMap((grant) => + Effect.gen(function* () { + const grantedScopes = requestedScopes ?? grant.scopes; + if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { + return yield* new ServerAuthScopeNotGrantedError({}); + } + const session = yield* sessions + .issue({ + method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", + subject: grant.subject, + scopes: grantedScopes, + ...(input?.proofKeyThumbprint + ? { + proofKeyThumbprint: input.proofKeyThumbprint, + ttl: Duration.hours(1), + } + : {}), + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), + ), + ); + return { grant, session }; + }), + ), + Effect.flatMap(({ grant, session }) => + DateTime.now.pipe( + Effect.map( + (now) => + ({ + result: { access_token: session.token, issued_token_type: AuthAccessTokenType, token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", @@ -777,10 +806,24 @@ export const make = Effect.gen(function* () { ), ), scope: encodeOAuthScope(session.scopes), - }) satisfies AuthAccessTokenResult, - ), + } satisfies AuthAccessTokenResult, + sessionId: session.sessionId, + grant: { + ...(grant.id === undefined ? {} : { id: grant.id }), + subject: grant.subject, + ...(grant.label === undefined ? {} : { label: grant.label }), + }, + }) satisfies BootstrapCredentialExchange, ), ), + ), + Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredential"), + ); + + const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = + (credential, requestedScopes, requestMetadata, input) => + exchangeBootstrapCredential(credential, requestedScopes, requestMetadata, input).pipe( + Effect.map((exchange) => exchange.result), Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), ); @@ -1005,6 +1048,7 @@ export const make = Effect.gen(function* () { getSessionState, createBrowserSession, exchangeBootstrapCredentialForAccessToken, + exchangeBootstrapCredential, createPairingLink, issuePairingCredential, issueStartupPairingCredential, diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 057a257ba664..46cbcedd4719 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -21,6 +21,8 @@ import * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { + /** The pairing link id, when the grant came from a persisted link. */ + readonly id?: string; readonly method: ServerAuthBootstrapMethod; readonly scopes: ReadonlyArray; readonly subject: string; @@ -526,6 +528,7 @@ export const make = Effect.gen(function* () { if (Option.isSome(consumed)) { yield* emitRemoved(consumed.value.id); return { + id: consumed.value.id, method: consumed.value.method, scopes: consumed.value.scopes, subject: consumed.value.subject, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 65f590d1a838..2d37f56733fa 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,5 +1,6 @@ import { AuthAccessReadScope, + AuthAccessWriteScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -140,6 +141,27 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, + // Tailcat remote access is administrative: it changes who can reach this + // server at the transport layer. + [WS_METHODS.tailcatSubscribeRemoteAccess]: AuthAccessReadScope, + [WS_METHODS.tailcatSetRemoteAccessEnabled]: AuthAccessWriteScope, + [WS_METHODS.tailcatCreateConnectionCode]: AuthAccessWriteScope, + [WS_METHODS.tailcatRevokeTrustedPeer]: AuthAccessWriteScope, + [WS_METHODS.tailcatRenameTrustedPeer]: AuthAccessWriteScope, + [WS_METHODS.tailcatRegenerateIdentity]: AuthAccessWriteScope, + // Peer trust is administrative; using an already-trusted peer is ordinary + // orchestration work, read or operate like the local equivalent. + [WS_METHODS.federationSubscribePeers]: AuthAccessReadScope, + [WS_METHODS.federationCreatePeerCode]: AuthAccessWriteScope, + [WS_METHODS.federationAddPeer]: AuthAccessWriteScope, + [WS_METHODS.federationRemovePeer]: AuthAccessWriteScope, + [WS_METHODS.federationRefreshPeer]: AuthOrchestrationReadScope, + [WS_METHODS.federationListRemoteProjects]: AuthOrchestrationReadScope, + [WS_METHODS.federationStartRemoteRun]: AuthOrchestrationOperateScope, + [WS_METHODS.federationCancelRemoteRun]: AuthOrchestrationOperateScope, + [WS_METHODS.federationSubscribeRemoteRuns]: AuthOrchestrationReadScope, + [WS_METHODS.federationDescribeRemoteArtifacts]: AuthOrchestrationReadScope, + [WS_METHODS.federationFetchRemoteArtifact]: AuthOrchestrationReadScope, [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope, } as const satisfies Readonly>; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index cc74966c41e2..63255e935709 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -36,6 +36,9 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; +import { isTailcatNodeKey } from "@t3tools/tailcat/address"; +import { TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT } from "@t3tools/contracts"; +import * as TailcatRemoteAccess from "../tailcat/TailcatRemoteAccess.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -233,6 +236,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const tailcatRemoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; return handlers .handle( @@ -335,7 +339,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( ) : undefined; yield* appendCredentialResponseHeaders; - return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + const exchange = yield* serverAuth.exchangeBootstrapCredential( args.payload.subject_token, requestedScopes, deriveAuthClientMetadata({ @@ -350,6 +354,28 @@ export const authHttpApiLayer = HttpApiBuilder.group( }), proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); + // A Tailcat connection code binds the pairing to the client's node + // key: only a grant minted as such may extend the transport + // allowlist, so a LAN pairing link cannot smuggle a key in. + const tailcatNodeKey = args.payload.client_tailcat_node_key?.trim(); + if ( + tailcatNodeKey !== undefined && + exchange.grant.subject === TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT && + isTailcatNodeKey(tailcatNodeKey) + ) { + yield* tailcatRemoteAccess + .recordTrustedPeer({ + nodeKey: tailcatNodeKey, + label: args.payload.client_label, + sessionId: exchange.sessionId, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not record the paired Tailcat peer.", { error }), + ), + ); + } + return exchange.result; }, traceRelayRequest, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 0deb261dbf9e..83daaa40344c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -116,6 +116,8 @@ const makeCliTestServerConfig = (baseDir: string) => logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, } satisfies ServerConfig.ServerConfig["Service"]; }); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index def63b61fafe..c7f94b80fcd3 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -132,6 +132,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(resolved.stateDir, join(baseDir, "userdata")); }), @@ -202,6 +204,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: true, tailscaleServePort: 8443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite")); }), @@ -275,6 +279,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -354,6 +360,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(join(baseDir, "userdata"), resolved.stateDir); assert.equal(resolved.desktopTelemetryFd, 4); @@ -484,6 +492,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -553,6 +563,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -616,6 +628,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index f739a4e2f22c..6c84865cfc35 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -74,6 +74,12 @@ export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +const tailcatFlag = Flag.boolean("tailcat").pipe( + Flag.withDescription( + "Enable Tailcat remote access: serve this backend through an encrypted Tailcat tunnel and print a connection code.", + ), + Flag.optional, +); const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), @@ -139,6 +145,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + tailcatEnabled: Config.boolean("T3CODE_TAILCAT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), }); export interface CliServerFlags { @@ -154,6 +164,7 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly tailcatEnabled?: Option.Option; } export interface CliAuthLocationFlags { @@ -188,6 +199,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + tailcatEnabled: tailcatFlag, } as const; const resolveOptionPrecedence = ( @@ -231,6 +243,7 @@ export const resolveServerConfig = ( logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + tailcatEnabled: flags.tailcatEnabled ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -336,6 +349,13 @@ export const resolveServerConfig = ( ), () => 443, ); + const tailcatEnabled = Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.tailcatEnabled ?? Option.none(), + Option.fromUndefinedOr(env.tailcatEnabled), + ), + ); + const tailcatBinaryPath = bootstrap?.tailcatBinaryPath; const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( @@ -384,6 +404,8 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + tailcatEnabled, + tailcatBinaryPath, }; return config; diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index d40e0d97e484..aa9e483d6408 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -230,14 +230,14 @@ const probeEnvironmentDescriptor = ( return { _tag: "descriptor", descriptor } as const; }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); -interface DiscoveredPairTarget { +export interface DiscoveredPairTarget { readonly baseDir: string; readonly variant: PairStateVariant; readonly state: PersistedServerRuntimeState; readonly descriptor: ExecutionEnvironmentDescriptor; } -const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( +export const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( explicitBaseDir: string | undefined, ) { const bases: Array = []; @@ -297,7 +297,7 @@ const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( * choice pinned to where the runtime state was actually found, independent of * ambient environment variables. */ -const makePairServerConfig = Effect.fn(function* (input: { +export const makePairServerConfig = Effect.fn(function* (input: { readonly target: DiscoveredPairTarget; readonly logLevel: ServerConfig.ServerConfig["Service"]["logLevel"]; }) { @@ -341,6 +341,8 @@ const makePairServerConfig = Effect.fn(function* (input: { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 42df3814b070..361d3a43c0f1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -88,6 +88,10 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + /** Enable Tailcat remote access at startup (`t3 serve --tailcat`). */ + readonly tailcatEnabled: boolean | undefined; + /** Tailcat executable handed over by the desktop app's bootstrap. */ + readonly tailcatBinaryPath: string | undefined; } >()("t3/config/ServerConfig") { /** @deprecated Import and use `layerTest` from this module. */ @@ -200,6 +204,8 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 91895fd5dcfc..7bd2b4218e12 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -61,6 +61,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1010011e90cd..05dec167f521 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + FEDERATION_PROTOCOL_VERSION, PROVIDER_SEND_TURN_MAX_FILE_BYTES, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -226,6 +227,8 @@ export const make = Effect.gen(function* () { threadTitleRegeneration: true, threadPullRequestLinking: true, environmentIcon: true, + tailcatRemoteAccess: true, + federation: { protocolVersion: FEDERATION_PROTOCOL_VERSION }, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { diff --git a/apps/server/src/federation/FederationIdentity.ts b/apps/server/src/federation/FederationIdentity.ts new file mode 100644 index 000000000000..600c8db96e15 --- /dev/null +++ b/apps/server/src/federation/FederationIdentity.ts @@ -0,0 +1,130 @@ +import { FEDERATION_AUTH_JWT_TYP, type EnvironmentId } from "@t3tools/contracts"; +import { signRelayJwt, verifyRelayJwt } from "@t3tools/shared/relayJwt"; +import * as NodeCrypto from "node:crypto"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; + +/** + * The environment's federation identity is its existing Ed25519 key pair from + * the secret store (also used for T3 Connect link proofs). Reusing it keeps one + * stable cryptographic identity per environment instead of a second key system. + */ +export const FEDERATION_ASSERTION_MAX_AGE_SECONDS = 120; + +export class FederationIdentity extends Context.Service< + FederationIdentity, + { + readonly environmentId: EnvironmentId; + /** SPKI PEM. Safe to share; it is what peers pin. */ + readonly publicKey: string; + readonly fingerprint: string; + /** Signs a challenge for `audience`, proving control of this environment's key. */ + readonly signChallenge: (input: { + readonly audience: EnvironmentId; + readonly challenge: string; + }) => Effect.Effect; + /** + * Verifies a peer's signed assertion against the public key pinned for it + * and returns the challenge it answers, for the caller to match against + * the challenges it issued. + */ + readonly verifyChallenge: (input: { + readonly assertion: string; + readonly issuer: EnvironmentId; + readonly publicKey: string; + }) => Effect.Effect; + } +>()("t3/federation/FederationIdentity") {} + +export class FederationIdentitySignError extends Schema.TaggedErrorClass()( + "FederationIdentitySignError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not sign the federation challenge."; + } +} + +export class FederationIdentityVerifyError extends Schema.TaggedErrorClass()( + "FederationIdentityVerifyError", + { + reason: Schema.Literals(["signature", "challenge"]), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "challenge" + ? "The federation assertion does not answer the issued challenge." + : "The federation assertion signature is invalid."; + } +} + +export function federationKeyFingerprint(publicKeyPem: string): string { + const normalized = publicKeyPem.replace(/\\n/gu, "\n").trim(); + const hex = NodeCrypto.createHash("sha256").update(normalized).digest("hex").slice(0, 16); + return `${hex.slice(0, 4)}·${hex.slice(4, 8)}·${hex.slice(8, 12)}·${hex.slice(12, 16)}`; +} + +export const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const environment = yield* ServerEnvironment.ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + + const signChallenge: FederationIdentity["Service"]["signChallenge"] = ({ audience, challenge }) => + DateTime.now.pipe( + Effect.flatMap((now) => { + const iat = Math.floor(DateTime.toEpochMillis(now) / 1000); + return signRelayJwt({ + privateKey: keyPair.privateKey, + typ: FEDERATION_AUTH_JWT_TYP, + payload: { + iss: environmentId, + aud: audience, + jti: challenge, + iat, + exp: iat + FEDERATION_ASSERTION_MAX_AGE_SECONDS, + }, + }); + }), + Effect.mapError((cause) => new FederationIdentitySignError({ cause })), + ); + + const verifyChallenge: FederationIdentity["Service"]["verifyChallenge"] = (input) => + DateTime.now.pipe( + Effect.flatMap((now) => + verifyRelayJwt({ + publicKey: input.publicKey, + token: input.assertion, + typ: FEDERATION_AUTH_JWT_TYP, + issuer: input.issuer, + audience: environmentId, + nowEpochSeconds: Math.floor(DateTime.toEpochMillis(now) / 1000), + maxTokenAge: `${FEDERATION_ASSERTION_MAX_AGE_SECONDS} seconds`, + }), + ), + Effect.mapError((cause) => new FederationIdentityVerifyError({ reason: "signature", cause })), + Effect.flatMap((payload) => + typeof payload.jti === "string" && payload.jti.length > 0 + ? Effect.succeed(payload.jti) + : Effect.fail(new FederationIdentityVerifyError({ reason: "challenge" })), + ), + ); + + return FederationIdentity.of({ + environmentId, + publicKey: keyPair.publicKey, + fingerprint: federationKeyFingerprint(keyPair.publicKey), + signChallenge, + verifyChallenge, + }); +}); + +export const layer = Layer.effect(FederationIdentity, make); diff --git a/apps/server/src/federation/FederationPeerStore.ts b/apps/server/src/federation/FederationPeerStore.ts new file mode 100644 index 000000000000..4061ffaa4f05 --- /dev/null +++ b/apps/server/src/federation/FederationPeerStore.ts @@ -0,0 +1,249 @@ +import { + EnvironmentId, + FederationCapability, + type FederationPeer, + type FederationPeerStatus, + FederationRemoteRun, + FederationScopes, + FederationTransport, + IsoDateTime, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ServerConfig from "../config.ts"; +import { federationKeyFingerprint } from "./FederationIdentity.ts"; + +/** + * Durable federation state: the peers this environment trusts, the runs it + * started on peers, and the runs peers started here. Pinned public keys live + * here in plain JSON on purpose: they are public, and pinning them is what + * makes a peer's identity stable across relabels and transport changes. + */ +export const FEDERATION_STATE_FILE = "federation.json"; + +export const PersistedFederationPeer = Schema.Struct({ + peerId: EnvironmentId, + label: TrimmedNonEmptyString, + publicKey: TrimmedNonEmptyString, + grantedScopes: FederationScopes, + allowedScopes: FederationScopes, + transport: Schema.NullOr(FederationTransport), + remoteServerVersion: Schema.NullOr(TrimmedNonEmptyString), + remoteProtocolVersion: Schema.NullOr(Schema.Int), + remoteCapabilities: Schema.Array(FederationCapability), + createdAt: IsoDateTime, + lastSeenAt: Schema.NullOr(IsoDateTime), +}); +export type PersistedFederationPeer = typeof PersistedFederationPeer.Type; + +/** A run a peer started here; only that peer may observe it. */ +export const PersistedInboundRun = Schema.Struct({ + threadId: ThreadId, + peerId: EnvironmentId, + createdAt: IsoDateTime, +}); +export type PersistedInboundRun = typeof PersistedInboundRun.Type; + +const PersistedFederationState = Schema.Struct({ + version: Schema.Literal(1), + peers: Schema.Array(PersistedFederationPeer), + remoteRuns: Schema.Array(FederationRemoteRun), + inboundRuns: Schema.Array(PersistedInboundRun), +}); +type PersistedFederationState = typeof PersistedFederationState.Type; + +const PersistedFederationStateJson = Schema.fromJsonString(PersistedFederationState); +const decodeState = Schema.decodeUnknownEffect(PersistedFederationStateJson); +const encodeState = Schema.encodeEffect(PersistedFederationStateJson); + +const EMPTY_STATE: PersistedFederationState = { + version: 1, + peers: [], + remoteRuns: [], + inboundRuns: [], +}; + +export interface PeerRuntimeStatus { + readonly status: FederationPeerStatus; + readonly lastError: string | null; +} + +export class FederationPeerStoreError extends Schema.TaggedErrorClass()( + "FederationPeerStoreError", + { operation: Schema.Literals(["read", "write"]), cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not ${this.operation} federation state.`; + } +} + +export class FederationPeerStore extends Context.Service< + FederationPeerStore, + { + readonly peers: Effect.Effect>; + readonly getPeer: ( + peerId: EnvironmentId, + ) => Effect.Effect>; + readonly upsertPeer: ( + peer: PersistedFederationPeer, + ) => Effect.Effect; + readonly removePeer: (peerId: EnvironmentId) => Effect.Effect; + readonly peerStatus: (peerId: EnvironmentId) => Effect.Effect; + readonly setPeerStatus: ( + peerId: EnvironmentId, + status: PeerRuntimeStatus, + ) => Effect.Effect; + readonly remoteRuns: Effect.Effect>; + readonly upsertRemoteRun: ( + run: FederationRemoteRun, + ) => Effect.Effect; + readonly removeRemoteRunsForPeer: ( + peerId: EnvironmentId, + ) => Effect.Effect; + readonly inboundRuns: Effect.Effect>; + readonly recordInboundRun: ( + run: PersistedInboundRun, + ) => Effect.Effect; + /** Present-tense view for clients, merging pinned facts with runtime status. */ + readonly presentPeer: (peer: PersistedFederationPeer) => Effect.Effect; + } +>()("t3/federation/FederationPeerStore") {} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const statePath = path.join(config.stateDir, FEDERATION_STATE_FILE); + const lock = yield* Semaphore.make(1); + + const initial = yield* fileSystem.readFileString(statePath).pipe( + Effect.option, + Effect.flatMap((raw) => + Option.isNone(raw) || raw.value.trim().length === 0 + ? Effect.succeed(EMPTY_STATE) + : decodeState(raw.value).pipe( + Effect.catch((cause) => + Effect.logWarning("Federation state is unreadable; starting from defaults.", { + statePath, + cause, + }).pipe(Effect.as(EMPTY_STATE)), + ), + ), + ), + ); + const state = yield* Ref.make(initial); + const statuses = yield* Ref.make>(new Map()); + + const update = (transform: (current: PersistedFederationState) => PersistedFederationState) => + lock.withPermits(1)( + Effect.gen(function* () { + const next = transform(yield* Ref.get(state)); + const encoded = yield* encodeState(next).pipe( + Effect.mapError((cause) => new FederationPeerStoreError({ operation: "write", cause })), + ); + yield* writeFileStringAtomically({ filePath: statePath, contents: `${encoded}\n` }).pipe( + Effect.mapError((cause) => new FederationPeerStoreError({ operation: "write", cause })), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + yield* Ref.set(state, next); + }), + ); + + const peerStatus: FederationPeerStore["Service"]["peerStatus"] = (peerId) => + Ref.get(statuses).pipe( + Effect.map((current) => current.get(peerId) ?? { status: "unknown", lastError: null }), + ); + + const presentPeer: FederationPeerStore["Service"]["presentPeer"] = (peer) => + peerStatus(peer.peerId).pipe( + Effect.map((status): FederationPeer => ({ + peerId: peer.peerId, + label: peer.label, + publicKeyFingerprint: federationKeyFingerprint(peer.publicKey), + grantedScopes: peer.grantedScopes, + allowedScopes: peer.allowedScopes, + transport: peer.transport, + remoteServerVersion: peer.remoteServerVersion, + remoteProtocolVersion: peer.remoteProtocolVersion, + remoteCapabilities: peer.remoteCapabilities, + status: status.status, + lastSeenAt: peer.lastSeenAt, + lastError: status.lastError, + createdAt: peer.createdAt, + })), + ); + + return FederationPeerStore.of({ + peers: Ref.get(state).pipe(Effect.map((current) => current.peers)), + getPeer: (peerId) => + Ref.get(state).pipe( + Effect.map((current) => + Option.fromUndefinedOr(current.peers.find((peer) => peer.peerId === peerId)), + ), + ), + upsertPeer: (peer) => + update((current) => ({ + ...current, + peers: [...current.peers.filter((existing) => existing.peerId !== peer.peerId), peer], + })), + removePeer: (peerId) => + update((current) => ({ + ...current, + peers: current.peers.filter((peer) => peer.peerId !== peerId), + remoteRuns: current.remoteRuns.filter((run) => run.peerId !== peerId), + inboundRuns: current.inboundRuns.filter((run) => run.peerId !== peerId), + })).pipe( + Effect.andThen( + Ref.update(statuses, (current) => { + const next = new Map(current); + next.delete(peerId); + return next; + }), + ), + ), + peerStatus, + setPeerStatus: (peerId, status) => + Ref.update(statuses, (current) => new Map(current).set(peerId, status)), + remoteRuns: Ref.get(state).pipe(Effect.map((current) => current.remoteRuns)), + upsertRemoteRun: (run) => + update((current) => ({ + ...current, + remoteRuns: [ + ...current.remoteRuns.filter( + (existing) => + !(existing.peerId === run.peerId && existing.run.threadId === run.run.threadId), + ), + run, + ], + })), + removeRemoteRunsForPeer: (peerId) => + update((current) => ({ + ...current, + remoteRuns: current.remoteRuns.filter((run) => run.peerId !== peerId), + })), + inboundRuns: Ref.get(state).pipe(Effect.map((current) => current.inboundRuns)), + recordInboundRun: (run) => + update((current) => ({ + ...current, + inboundRuns: [ + ...current.inboundRuns.filter((existing) => existing.threadId !== run.threadId), + run, + ], + })), + presentPeer, + }); +}); + +export const layer = Layer.effect(FederationPeerStore, make); diff --git a/apps/server/src/federation/FederationService.ts b/apps/server/src/federation/FederationService.ts new file mode 100644 index 000000000000..85360715837f --- /dev/null +++ b/apps/server/src/federation/FederationService.ts @@ -0,0 +1,1354 @@ +import { + AuthFederationPeerScope, + type ClientOrchestrationCommand, + CommandId, + DEFAULT_MODEL, + DEFAULT_PROVIDER_INTERACTION_MODE, + type EnvironmentId, + EnvironmentHttpApi, + FEDERATION_PEER_CODE_DEFAULT_TTL_SECONDS, + FEDERATION_PEER_CODE_PAIRING_SUBJECT, + FEDERATION_PROTOCOL_VERSION, + FEDERATION_SESSION_SUBJECT_PREFIX, + type FederationAddPeerInput, + type FederationArtifactFetchResponse, + type FederationArtifactsResponse, + type FederationCapability, + type FederationChallengeRequest, + type FederationChallengeResponse, + type FederationCreatePeerCodeInput, + FederationError, + type FederationHello, + type FederationPairRequest, + type FederationPairResponse, + type FederationPeer, + type FederationPeerCodeResult, + type FederationProjectsResponse, + type FederationRemoteArtifactInput, + type FederationRemoteRun, + type FederationRemoteRunInput, + type FederationRemoteRunsSnapshot, + type FederationRun, + type FederationRunEventsResponse, + type FederationRunStartRequest, + type FederationScope, + type FederationSnapshot, + type FederationStartRemoteRunInput, + type FederationTokenRequest, + type FederationTokenResponse, + type ModelSelection, + MessageId, + ProviderInstanceId, + type ThreadId, + ThreadId as ThreadIdSchema, + type TurnId, +} from "@t3tools/contracts"; +import { + T3ConnectionCodeInvalidError, + decodeFederationPeerCode, + encodeFederationPeerCode, +} from "@t3tools/shared/t3ConnectionCode"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +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 Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "../auth/PairingGrantStore.ts"; +import * as CheckpointDiffQuery from "../checkpointing/CheckpointDiffQuery.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { normalizeDispatchCommand } from "../orchestration/Normalizer.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as TailcatRemoteAccess from "../tailcat/TailcatRemoteAccess.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as FederationIdentity from "./FederationIdentity.ts"; +import * as FederationPeerStore from "./FederationPeerStore.ts"; +import * as FederationTransport from "./FederationTransport.ts"; +import { + isFederationRunActive, + projectFederationArtifacts, + projectFederationRun, + summarizeFederationRunEvent, + truncatePreview, +} from "./runProjection.ts"; + +/** + * FederationService is the T3 federation protocol, both halves: + * + * - as an issuer/peer-facing server it pairs requesters that redeem a peer + * code, answers signed challenges with federation sessions, and serves the + * explicit, scope-checked federation endpoints; + * - as a requester it pairs with peers from their codes, keeps a session per + * peer, and coordinates runs that stay owned by the peer that executes them. + * + * Transport is Tailcat (FederationTransport). Authentication is this + * environment's Ed25519 identity (FederationIdentity) plus ordinary T3 sessions + * scoped to `federation:peer`. Authorization is the per-peer scope grant made + * at pairing time; a transport path never implies trust by itself. + */ + +export const FEDERATION_CAPABILITIES: ReadonlyArray = [ + "hello", + "projects.list", + "runs.start", + "runs.status", + "runs.cancel", + "runs.events", + "artifacts.describe", + "artifacts.fetch", +]; + +const FEDERATION_SESSION_TTL = Duration.hours(1); +const FEDERATION_SESSION_REFRESH_SKEW = Duration.minutes(2); +const CHALLENGE_TTL = Duration.minutes(2); +const REMOTE_RUN_POLL_INTERVAL = Duration.seconds(2); +const REMOTE_RUN_EVENT_LIMIT = 200; +const PEER_REFRESH_INTERVAL = Duration.minutes(5); +const PEER_REQUEST_TIMEOUT = Duration.seconds(20); +const DEFAULT_REMOTE_RUNTIME_MODE = "auto" as const; + +export class FederationService extends Context.Service< + FederationService, + { + // Local owner operations (driven over RPC by this environment's clients) + readonly snapshot: Effect.Effect; + readonly changes: Stream.Stream; + readonly remoteRuns: Effect.Effect; + readonly remoteRunChanges: Stream.Stream; + readonly createPeerCode: ( + input: FederationCreatePeerCodeInput, + ) => Effect.Effect; + readonly addPeer: ( + input: FederationAddPeerInput, + ) => Effect.Effect; + readonly removePeer: (peerId: EnvironmentId) => Effect.Effect; + readonly refreshPeer: (peerId: EnvironmentId) => Effect.Effect; + readonly listRemoteProjects: ( + peerId: EnvironmentId, + ) => Effect.Effect; + readonly startRemoteRun: ( + input: FederationStartRemoteRunInput, + ) => Effect.Effect; + readonly cancelRemoteRun: ( + input: FederationRemoteRunInput, + ) => Effect.Effect; + readonly describeRemoteArtifacts: ( + input: FederationRemoteRunInput, + ) => Effect.Effect; + readonly fetchRemoteArtifact: ( + input: FederationRemoteArtifactInput, + ) => Effect.Effect; + // Peer-facing protocol operations (driven by the federation HTTP group) + readonly acceptPair: ( + request: FederationPairRequest, + ) => Effect.Effect; + readonly issueChallenge: ( + request: FederationChallengeRequest, + ) => Effect.Effect; + readonly redeemChallenge: ( + request: FederationTokenRequest, + ) => Effect.Effect; + readonly authorizePeer: ( + principal: { readonly subject: string; readonly scopes: ReadonlySet }, + required: FederationScope, + ) => Effect.Effect; + readonly hello: Effect.Effect; + readonly localProjects: Effect.Effect; + readonly startLocalRun: ( + peer: FederationPeerStore.PersistedFederationPeer, + request: FederationRunStartRequest, + ) => Effect.Effect; + readonly localRunStatus: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + ) => Effect.Effect; + readonly cancelLocalRun: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + ) => Effect.Effect; + readonly localRunEvents: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + afterSequence: number, + ) => Effect.Effect; + readonly localRunArtifacts: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + ) => Effect.Effect; + readonly fetchLocalArtifact: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + turnId: TurnId, + ) => Effect.Effect; + } +>()("t3/federation/FederationService") {} + +interface PendingChallenge { + readonly peerId: EnvironmentId; + readonly expiresAtMs: number; +} + +interface PeerSession { + readonly token: string; + readonly expiresAtMs: number; +} + +const internalError = (message: string) => new FederationError({ code: "internal", message }); +const isFederationError = Schema.is(FederationError); +const isConnectionCodeInvalidError = Schema.is(T3ConnectionCodeInvalidError); + +const describeCause = (cause: unknown): string => + cause instanceof Error ? cause.message : typeof cause === "string" ? cause : String(cause); + +function scopesIncludeAll( + granted: ReadonlyArray, + required: ReadonlyArray, +): boolean { + return required.every((scope) => granted.includes(scope)); +} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const identity = yield* FederationIdentity.FederationIdentity; + const peers = yield* FederationPeerStore.FederationPeerStore; + const transport = yield* FederationTransport.FederationTransport; + const tailcat = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairingLinks = yield* PairingGrantStore.PairingGrantStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const checkpointDiffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const httpClient = yield* HttpClient.HttpClient; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const serviceScope = yield* Scope.Scope; + + const pendingPeerCodes = yield* Ref.make>>( + new Map(), + ); + const challenges = yield* Ref.make>(new Map()); + const peerSessions = yield* Ref.make>(new Map()); + const pollSignals = yield* Queue.unbounded<"poll">(); + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const nowMs = DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + + const buildSnapshot = Effect.gen(function* () { + const stored = yield* peers.peers; + const presented = yield* Effect.forEach(stored, peers.presentPeer); + return { + environmentId: identity.environmentId, + publicKeyFingerprint: identity.fingerprint, + protocolVersion: FEDERATION_PROTOCOL_VERSION, + peers: presented.toSorted((left, right) => left.label.localeCompare(right.label)), + updatedAt: yield* nowIso, + } satisfies FederationSnapshot; + }); + const snapshotRef = yield* SubscriptionRef.make(yield* buildSnapshot); + const publishPeers = buildSnapshot.pipe( + Effect.flatMap((next) => SubscriptionRef.set(snapshotRef, next)), + ); + + const buildRuns = Effect.gen(function* () { + const runs = yield* peers.remoteRuns; + return { + runs: runs.toSorted((left, right) => + right.run.requestedAt.localeCompare(left.run.requestedAt), + ), + updatedAt: yield* nowIso, + } satisfies FederationRemoteRunsSnapshot; + }); + const runsRef = yield* SubscriptionRef.make(yield* buildRuns); + const publishRuns = buildRuns.pipe(Effect.flatMap((next) => SubscriptionRef.set(runsRef, next))); + + const storeError = (error: FederationPeerStore.FederationPeerStoreError) => + internalError(error.message); + + const helloEffect: FederationService["Service"]["hello"] = serverEnvironment.getDescriptor.pipe( + Effect.map((descriptor): FederationHello => ({ + protocolVersion: FEDERATION_PROTOCOL_VERSION, + environmentId: descriptor.environmentId, + label: descriptor.label, + serverVersion: descriptor.serverVersion, + platform: descriptor.platform, + capabilities: FEDERATION_CAPABILITIES, + })), + ); + + const ourTransport = tailcat.readyEndpoint.pipe( + Effect.map((endpoint) => + Option.match(endpoint, { + onNone: () => null, + onSome: ({ address, port }) => ({ tailcat: { address, port } }), + }), + ), + ); + + // ── Peer-facing protocol ──────────────────────────────────────────── + + const acceptPair: FederationService["Service"]["acceptPair"] = Effect.fn( + "FederationService.acceptPair", + )(function* (request) { + if (request.protocolVersion !== FEDERATION_PROTOCOL_VERSION) { + return yield* new FederationError({ + code: "protocol-incompatible", + message: `The peer speaks federation protocol v${request.protocolVersion}; this environment speaks v${FEDERATION_PROTOCOL_VERSION}. Update the older side.`, + }); + } + if (request.environmentId === identity.environmentId) { + return yield* new FederationError({ + code: "code-invalid", + message: "An environment cannot federate with itself.", + }); + } + const grant = yield* pairingLinks.consume(request.token).pipe( + Effect.mapError((error) => + PairingGrantStore.isBootstrapCredentialInvalidError(error) + ? new FederationError({ + code: + error._tag === "ExpiredBootstrapCredentialError" ? "code-expired" : "code-invalid", + message: + error._tag === "ExpiredBootstrapCredentialError" + ? "This peer code has expired. Create a new one on the other machine." + : "This peer code is not valid or was already used.", + }) + : internalError(`Could not validate the peer code: ${error.message}`), + ), + ); + if (grant.subject !== FEDERATION_PEER_CODE_PAIRING_SUBJECT) { + return yield* new FederationError({ + code: "code-invalid", + message: "This code is a device pairing code, not a federation peer code.", + }); + } + const offered = + grant.id === undefined ? undefined : (yield* Ref.get(pendingPeerCodes)).get(grant.id); + if (offered === undefined) { + return yield* new FederationError({ + code: "code-expired", + message: "This peer code is no longer offered by this environment. Create a new one.", + }); + } + yield* Ref.update(pendingPeerCodes, (current) => { + const next = new Map(current); + next.delete(grant.id!); + return next; + }); + const at = yield* nowIso; + const existing = yield* peers.getPeer(request.environmentId); + yield* peers + .upsertPeer({ + peerId: request.environmentId, + label: request.label, + publicKey: request.publicKey, + grantedScopes: offered, + allowedScopes: request.grantedScopes, + transport: request.transport, + remoteServerVersion: request.serverVersion, + remoteProtocolVersion: request.protocolVersion, + remoteCapabilities: request.capabilities, + createdAt: Option.isSome(existing) ? existing.value.createdAt : at, + lastSeenAt: at, + }) + .pipe(Effect.mapError(storeError)); + yield* peers.setPeerStatus(request.environmentId, { status: "online", lastError: null }); + if (request.tailcatNodeKey !== undefined) { + yield* tailcat + .recordTrustedPeer({ + nodeKey: request.tailcatNodeKey, + label: `Federation: ${request.label}`, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not trust the federation peer's Tailcat key.", { error }), + ), + ); + } + yield* Effect.logInfo("Federation peer paired.", { + peerId: request.environmentId, + grantedScopes: offered, + allowedScopes: request.grantedScopes, + }); + yield* publishPeers; + const descriptor = yield* serverEnvironment.getDescriptor; + const ourNodeKey = yield* transport.clientNodeKey.pipe(Effect.option); + return { + protocolVersion: FEDERATION_PROTOCOL_VERSION, + environmentId: identity.environmentId, + publicKey: identity.publicKey, + label: descriptor.label, + serverVersion: descriptor.serverVersion, + capabilities: FEDERATION_CAPABILITIES, + grantedScopes: offered, + transport: yield* ourTransport, + ...(Option.isSome(ourNodeKey) ? { tailcatNodeKey: ourNodeKey.value } : {}), + } satisfies FederationPairResponse; + }); + + const pruneChallenges = nowMs.pipe( + Effect.flatMap((current) => + Ref.update(challenges, (pending) => { + const next = new Map(); + for (const [nonce, entry] of pending) { + if (entry.expiresAtMs > current) next.set(nonce, entry); + } + return next; + }), + ), + ); + + const requirePeer = (peerId: EnvironmentId) => + peers.getPeer(peerId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new FederationError({ + code: "peer-unknown", + message: "This environment is not paired with the requesting environment.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + const issueChallenge: FederationService["Service"]["issueChallenge"] = Effect.fn( + "FederationService.issueChallenge", + )(function* (request) { + yield* requirePeer(request.environmentId); + yield* pruneChallenges; + const bytes = yield* crypto + .randomBytes(32) + .pipe(Effect.mapError((cause) => internalError(describeCause(cause)))); + const challenge = Encoding.encodeBase64Url(bytes); + const expiresAtMs = (yield* nowMs) + Duration.toMillis(CHALLENGE_TTL); + yield* Ref.update(challenges, (pending) => + new Map(pending).set(challenge, { peerId: request.environmentId, expiresAtMs }), + ); + return { + challenge, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMs)), + } satisfies FederationChallengeResponse; + }); + + const redeemChallenge: FederationService["Service"]["redeemChallenge"] = Effect.fn( + "FederationService.redeemChallenge", + )(function* (request) { + const peer = yield* requirePeer(request.environmentId); + const answered = yield* identity + .verifyChallenge({ + assertion: request.assertion, + issuer: request.environmentId, + publicKey: peer.publicKey, + }) + .pipe( + Effect.mapError( + (error) => + new FederationError({ + code: "peer-rejected", + message: error.message, + }), + ), + ); + yield* pruneChallenges; + const pending = (yield* Ref.get(challenges)).get(answered); + if (pending === undefined || pending.peerId !== request.environmentId) { + return yield* new FederationError({ + code: "peer-rejected", + message: "The federation challenge is unknown or expired. Request a new one.", + }); + } + yield* Ref.update(challenges, (current) => { + const next = new Map(current); + next.delete(answered); + return next; + }); + const session = yield* environmentAuth + .issueSession({ + ttl: FEDERATION_SESSION_TTL, + subject: `${FEDERATION_SESSION_SUBJECT_PREFIX}${peer.peerId}`, + scopes: [AuthFederationPeerScope], + label: `Federation: ${peer.label}`, + }) + .pipe(Effect.mapError((error) => internalError(error.message))); + const at = yield* nowIso; + yield* peers.upsertPeer({ ...peer, lastSeenAt: at }).pipe(Effect.ignore); + yield* peers.setPeerStatus(peer.peerId, { status: "online", lastError: null }); + yield* publishPeers; + return { + accessToken: session.token, + expiresAt: DateTime.formatIso(session.expiresAt), + scopes: peer.grantedScopes, + protocolVersion: FEDERATION_PROTOCOL_VERSION, + } satisfies FederationTokenResponse; + }); + + const authorizePeer: FederationService["Service"]["authorizePeer"] = Effect.fn( + "FederationService.authorizePeer", + )(function* (principal, required) { + if ( + !principal.subject.startsWith(FEDERATION_SESSION_SUBJECT_PREFIX) || + !principal.scopes.has(AuthFederationPeerScope) + ) { + return yield* new FederationError({ + code: "peer-unknown", + message: "This session is not a federation peer session.", + }); + } + const peerId = principal.subject.slice( + FEDERATION_SESSION_SUBJECT_PREFIX.length, + ) as EnvironmentId; + const peer = yield* peers.getPeer(peerId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new FederationError({ + code: "peer-revoked", + message: "This environment no longer trusts the requesting environment.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + if (!peer.grantedScopes.includes(required)) { + return yield* new FederationError({ + code: "scope-denied", + message: `The requesting environment was not granted ${required}.`, + }); + } + yield* peers.setPeerStatus(peer.peerId, { status: "online", lastError: null }); + return peer; + }); + + const localProjects: FederationService["Service"]["localProjects"] = projections + .getShellSnapshot() + .pipe( + Effect.map((snapshot): FederationProjectsResponse => ({ + environmentId: identity.environmentId, + projects: snapshot.projects.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, + defaultModelSelection: project.defaultModelSelection, + })), + })), + Effect.mapError((error) => internalError(`Could not list projects: ${error.message}`)), + ); + + const requireInboundRun = ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + ) => + peers.inboundRuns.pipe( + Effect.flatMap((runs) => + runs.some((run) => run.threadId === threadId && run.peerId === peer.peerId) + ? Effect.void + : Effect.fail( + new FederationError({ + code: "run-not-found", + message: "No federated run with that id was started by this peer.", + }), + ), + ), + ); + + const projectLocalRun = (threadId: ThreadId) => + Effect.gen(function* () { + const shell = yield* projections + .getThreadShellById(threadId) + .pipe(Effect.mapError((error) => internalError(error.message))); + if (Option.isNone(shell)) { + return yield* new FederationError({ + code: "run-not-found", + message: "The federated run no longer exists on this environment.", + }); + } + const detail = yield* projections + .getThreadDetailById(threadId, { activityKinds: [] }) + .pipe(Effect.orElseSucceed(() => Option.none())); + const assistantPreview = Option.match(detail, { + onNone: () => null, + onSome: (thread) => { + const lastAssistant = thread.messages + .toReversed() + .find((message) => message.role === "assistant" && message.text.trim().length > 0); + return lastAssistant === undefined ? null : truncatePreview(lastAssistant.text); + }, + }); + const checkpoints = yield* projections + .getThreadCheckpointContext(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + const turnCount = Option.match(checkpoints, { + onNone: () => 0, + onSome: (context) => + context.checkpoints.reduce( + (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), + 0, + ), + }); + return projectFederationRun({ + environmentId: identity.environmentId, + thread: shell.value, + assistantPreview, + turnCount, + }); + }); + + const dispatchClientCommand = (command: ClientOrchestrationCommand) => + normalizeDispatchCommand(command).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(WorkspacePaths.WorkspacePaths, workspacePaths), + Effect.mapError((error) => + internalError(`Invalid federation command: ${describeCause(error)}`), + ), + Effect.flatMap((normalized) => + orchestrationEngine + .dispatch(normalized) + .pipe(Effect.mapError((error) => internalError(describeCause(error)))), + ), + ); + + const newId = crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => internalError(describeCause(cause))), + ); + + const startLocalRun: FederationService["Service"]["startLocalRun"] = Effect.fn( + "FederationService.startLocalRun", + )(function* (peer, request) { + const project = yield* projections + .getProjectShellById(request.projectId) + .pipe(Effect.mapError((error) => internalError(error.message))); + if (Option.isNone(project)) { + return yield* new FederationError({ + code: "run-not-found", + message: "That project does not exist on this environment.", + }); + } + const modelSelection: ModelSelection = request.modelSelection ?? + project.value.defaultModelSelection ?? { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }; + const runtimeMode = request.runtimeMode ?? DEFAULT_REMOTE_RUNTIME_MODE; + const title = request.title ?? truncatePreview(request.prompt, 60); + const threadId = ThreadIdSchema.make(yield* newId); + const createdAt = yield* nowIso; + yield* dispatchClientCommand({ + type: "thread.create", + commandId: CommandId.make(yield* newId), + threadId, + projectId: request.projectId, + title, + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode, + branch: null, + worktreePath: null, + createdAt, + }); + yield* peers + .recordInboundRun({ threadId, peerId: peer.peerId, createdAt }) + .pipe(Effect.mapError(storeError)); + yield* dispatchClientCommand({ + type: "thread.turn.start", + commandId: CommandId.make(yield* newId), + threadId, + message: { + messageId: MessageId.make(yield* newId), + role: "user", + text: request.prompt, + attachments: [], + }, + modelSelection, + runtimeMode, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: yield* nowIso, + }); + yield* Effect.logInfo("Federated run started for a peer.", { + peerId: peer.peerId, + threadId, + projectId: request.projectId, + runtimeMode, + }); + return yield* projectLocalRun(threadId); + }); + + const localRunStatus: FederationService["Service"]["localRunStatus"] = (peer, threadId) => + requireInboundRun(peer, threadId).pipe(Effect.andThen(projectLocalRun(threadId))); + + const cancelLocalRun: FederationService["Service"]["cancelLocalRun"] = Effect.fn( + "FederationService.cancelLocalRun", + )(function* (peer, threadId) { + yield* requireInboundRun(peer, threadId); + const run = yield* projectLocalRun(threadId); + if (isFederationRunActive(run)) { + yield* dispatchClientCommand({ + type: "thread.turn.interrupt", + commandId: CommandId.make(yield* newId), + threadId, + ...(run.turnId === null ? {} : { turnId: run.turnId }), + createdAt: yield* nowIso, + }); + } + return yield* projectLocalRun(threadId); + }); + + const localRunEvents: FederationService["Service"]["localRunEvents"] = Effect.fn( + "FederationService.localRunEvents", + )(function* (peer, threadId, afterSequence) { + yield* requireInboundRun(peer, threadId); + const run = yield* projectLocalRun(threadId); + const latestSequence = yield* orchestrationEngine.latestSequence; + const events = yield* orchestrationEngine + .readEvents(afterSequence, Math.max(1, latestSequence - afterSequence)) + .pipe( + Stream.map((event) => summarizeFederationRunEvent(event, threadId)), + Stream.filter((event) => event !== null), + Stream.runCollect, + Effect.mapError((error) => internalError(`Could not read run events: ${error.message}`)), + ); + return { + run, + events: events.slice(-REMOTE_RUN_EVENT_LIMIT), + latestSequence, + } satisfies FederationRunEventsResponse; + }); + + const localArtifactRefs = (threadId: ThreadId) => + projections.getThreadCheckpointContext(threadId).pipe( + Effect.mapError((error) => internalError(error.message)), + Effect.map((context) => + Option.match(context, { + onNone: () => [], + onSome: (value) => + projectFederationArtifacts({ + environmentId: identity.environmentId, + threadId, + checkpoints: value.checkpoints, + }), + }), + ), + ); + + const localRunArtifacts: FederationService["Service"]["localRunArtifacts"] = Effect.fn( + "FederationService.localRunArtifacts", + )(function* (peer, threadId) { + yield* requireInboundRun(peer, threadId); + const run = yield* projectLocalRun(threadId); + const artifacts = yield* localArtifactRefs(threadId); + return { run, artifacts } satisfies FederationArtifactsResponse; + }); + + const fetchLocalArtifact: FederationService["Service"]["fetchLocalArtifact"] = Effect.fn( + "FederationService.fetchLocalArtifact", + )(function* (peer, threadId, turnId) { + yield* requireInboundRun(peer, threadId); + const artifacts = yield* localArtifactRefs(threadId); + const ref = artifacts.find((artifact) => artifact.turnId === turnId); + if (ref === undefined) { + return yield* new FederationError({ + code: "artifact-unavailable", + message: "That turn has no recorded changes yet.", + }); + } + const diff = yield* checkpointDiffs + .getTurnDiff({ threadId, fromTurnCount: ref.fromTurnCount, toTurnCount: ref.toTurnCount }) + .pipe( + Effect.mapError( + (error) => + new FederationError({ + code: "artifact-unavailable", + message: `Could not compute the diff: ${describeCause(error)}`, + }), + ), + ); + return { + ref, + contentType: "text/x-diff", + diff: diff.diff, + fetchedAt: yield* nowIso, + } satisfies FederationArtifactFetchResponse; + }); + + // ── Requester side ────────────────────────────────────────────────── + + const clientFor = (httpBaseUrl: string) => + HttpApiClient.make(EnvironmentHttpApi, { baseUrl: httpBaseUrl }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + + type PeerClient = Effect.Success>; + + const mapPeerCallError = (peerId: EnvironmentId) => (cause: unknown) => + Effect.gen(function* () { + if (isFederationError(cause)) { + if (cause.code === "peer-unknown" || cause.code === "peer-revoked") { + yield* peers.setPeerStatus(peerId, { status: "offline", lastError: cause.message }); + } + return cause; + } + const message = describeCause(cause); + yield* peers.setPeerStatus(peerId, { status: "offline", lastError: message }); + return new FederationError({ code: "peer-unreachable", message }); + }).pipe(Effect.flatMap(Effect.fail)); + + const requestSession = (peer: FederationPeerStore.PersistedFederationPeer, client: PeerClient) => + Effect.gen(function* () { + const challenge = yield* client.federation.challenge({ + payload: { environmentId: identity.environmentId }, + }); + const assertion = yield* identity + .signChallenge({ audience: peer.peerId, challenge: challenge.challenge }) + .pipe(Effect.mapError((error) => internalError(error.message))); + const token = yield* client.federation.token({ + payload: { environmentId: identity.environmentId, assertion }, + }); + const expiresAtMs = DateTime.toEpochMillis(DateTime.makeUnsafe(token.expiresAt)); + yield* Ref.update(peerSessions, (current) => + new Map(current).set(peer.peerId, { token: token.accessToken, expiresAtMs }), + ); + return token; + }); + + const sessionFor = (peer: FederationPeerStore.PersistedFederationPeer, client: PeerClient) => + Effect.gen(function* () { + const cached = (yield* Ref.get(peerSessions)).get(peer.peerId); + const current = yield* nowMs; + if ( + cached !== undefined && + cached.expiresAtMs - Duration.toMillis(FEDERATION_SESSION_REFRESH_SKEW) > current + ) { + return cached.token; + } + return (yield* requestSession(peer, client)).accessToken; + }); + + const callPeer = ( + peer: FederationPeerStore.PersistedFederationPeer, + call: (client: PeerClient, headers: { readonly authorization: string }) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + if (peer.transport === null) { + return yield* new FederationError({ + code: "transport-unavailable", + message: `${peer.label} did not share a Tailcat address, so this environment cannot reach it.`, + }); + } + const endpoint = yield* transport.endpointFor({ + peerId: peer.peerId, + transport: peer.transport, + }); + const client = yield* clientFor(endpoint.httpBaseUrl).pipe( + Effect.mapError((cause) => internalError(describeCause(cause))), + ); + const attempt = Effect.gen(function* () { + const token = yield* sessionFor(peer, client); + return yield* call(client, { authorization: `Bearer ${token}` }); + }); + return yield* attempt.pipe( + Effect.catch((cause) => + // One retry after a session refresh covers a revoked or expired + // token; anything else is a real failure. + isAuthRejection(cause) + ? Ref.update(peerSessions, (current) => { + const next = new Map(current); + next.delete(peer.peerId); + return next; + }).pipe(Effect.andThen(attempt)) + : Effect.fail(cause), + ), + Effect.timeoutOrElse({ + duration: PEER_REQUEST_TIMEOUT, + orElse: () => + Effect.fail( + new FederationError({ + code: "peer-unreachable", + message: `${peer.label} did not answer in time.`, + }), + ), + }), + Effect.catch((cause) => mapPeerCallError(peer.peerId)(cause)), + Effect.tap(() => peers.setPeerStatus(peer.peerId, { status: "online", lastError: null })), + ); + }); + + const isAuthRejection = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "_tag" in cause && + (cause as { _tag: unknown })._tag === "EnvironmentAuthInvalidError"; + + const requireAllowed = ( + peer: FederationPeerStore.PersistedFederationPeer, + scopes: ReadonlyArray, + ) => + scopesIncludeAll(peer.allowedScopes, scopes) + ? Effect.void + : Effect.fail( + new FederationError({ + code: "scope-denied", + message: `${peer.label} has not granted this environment ${scopes.join(", ")}.`, + }), + ); + + const requireLocalPeer = (peerId: EnvironmentId) => + peers.getPeer(peerId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new FederationError({ + code: "peer-unknown", + message: "That environment is not paired here.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + const refreshPeer: FederationService["Service"]["refreshPeer"] = Effect.fn( + "FederationService.refreshPeer", + )(function* (peerId) { + const peer = yield* requireLocalPeer(peerId); + const hello = yield* callPeer(peer, (client, headers) => + client.federation.hello({ headers }), + ).pipe(Effect.result); + if (Result.isFailure(hello)) { + yield* publishPeers; + const presented = yield* peers.presentPeer(peer); + return presented; + } + const at = yield* nowIso; + const updated = { + ...peer, + label: hello.success.label, + remoteServerVersion: hello.success.serverVersion, + remoteProtocolVersion: hello.success.protocolVersion, + remoteCapabilities: hello.success.capabilities, + lastSeenAt: at, + }; + yield* peers.upsertPeer(updated).pipe(Effect.mapError(storeError)); + yield* publishPeers; + return yield* peers.presentPeer(updated); + }); + + const createPeerCode: FederationService["Service"]["createPeerCode"] = Effect.fn( + "FederationService.createPeerCode", + )(function* (input) { + const endpoint = yield* tailcat.readyEndpoint; + if (Option.isNone(endpoint)) { + return yield* new FederationError({ + code: "transport-unavailable", + message: "Enable Tailcat access on this environment before pairing peers.", + }); + } + if (input.scopes.length === 0) { + return yield* new FederationError({ + code: "scope-denied", + message: "Grant the peer at least one capability.", + }); + } + const ttlSeconds = input.ttlSeconds ?? FEDERATION_PEER_CODE_DEFAULT_TTL_SECONDS; + const issued = yield* environmentAuth + .createPairingLink({ + scopes: [AuthFederationPeerScope], + subject: FEDERATION_PEER_CODE_PAIRING_SUBJECT, + label: "Federation peer code", + ttl: Duration.seconds(ttlSeconds), + }) + .pipe(Effect.mapError((error) => internalError(error.message))); + yield* Ref.update(pendingPeerCodes, (current) => new Map(current).set(issued.id, input.scopes)); + const descriptor = yield* serverEnvironment.getDescriptor; + const expiresAt = DateTime.formatIso(issued.expiresAt); + const payload = { + v: 1 as const, + kind: "peer" as const, + protocolVersion: FEDERATION_PROTOCOL_VERSION, + environmentId: identity.environmentId, + publicKey: identity.publicKey, + label: descriptor.label, + transport: { tailcat: { address: endpoint.value.address, port: endpoint.value.port } }, + token: issued.credential, + scopes: input.scopes, + expiresAt, + }; + yield* Effect.logInfo("Federation peer code issued.", { + pairingLinkId: issued.id, + scopes: input.scopes, + expiresAt, + }); + return { + code: encodeFederationPeerCode(payload), + payload, + expiresAt, + } satisfies FederationPeerCodeResult; + }); + + const addPeer: FederationService["Service"]["addPeer"] = Effect.fn("FederationService.addPeer")( + function* (input) { + const payload = yield* Effect.try({ + try: () => decodeFederationPeerCode(input.code), + catch: (cause) => + new FederationError({ + code: "code-invalid", + message: isConnectionCodeInvalidError(cause) + ? cause.message + : "The peer code is invalid.", + }), + }); + if (payload.protocolVersion !== FEDERATION_PROTOCOL_VERSION) { + return yield* new FederationError({ + code: "protocol-incompatible", + message: `The peer speaks federation protocol v${payload.protocolVersion}; this environment speaks v${FEDERATION_PROTOCOL_VERSION}. Update the older side.`, + }); + } + if (payload.environmentId === identity.environmentId) { + return yield* new FederationError({ + code: "code-invalid", + message: "This is this environment's own peer code. Paste it on the other machine.", + }); + } + if (DateTime.toEpochMillis(DateTime.makeUnsafe(payload.expiresAt)) <= (yield* nowMs)) { + return yield* new FederationError({ + code: "code-expired", + message: "This peer code has expired. Create a new one on the other machine.", + }); + } + const endpoint = yield* transport.endpointFor({ + peerId: payload.environmentId, + transport: payload.transport, + }); + const client = yield* clientFor(endpoint.httpBaseUrl).pipe( + Effect.mapError((cause) => internalError(describeCause(cause))), + ); + const descriptor = yield* serverEnvironment.getDescriptor; + const ourNodeKey = yield* transport.clientNodeKey.pipe(Effect.option); + const response = yield* client.federation + .pair({ + payload: { + token: payload.token, + protocolVersion: FEDERATION_PROTOCOL_VERSION, + environmentId: identity.environmentId, + publicKey: identity.publicKey, + label: descriptor.label, + serverVersion: descriptor.serverVersion, + capabilities: FEDERATION_CAPABILITIES, + transport: yield* ourTransport, + grantedScopes: input.grantedScopes, + ...(Option.isSome(ourNodeKey) ? { tailcatNodeKey: ourNodeKey.value } : {}), + }, + }) + .pipe( + Effect.timeoutOrElse({ + duration: PEER_REQUEST_TIMEOUT, + orElse: () => + Effect.fail( + new FederationError({ + code: "peer-unreachable", + message: "The other machine did not answer the pairing request in time.", + }), + ), + }), + Effect.mapError((cause) => + isFederationError(cause) + ? cause + : new FederationError({ + code: "peer-unreachable", + message: `Pairing failed: ${describeCause(cause)}`, + }), + ), + ); + if ( + response.environmentId !== payload.environmentId || + response.publicKey !== payload.publicKey + ) { + yield* transport.drop(payload.environmentId); + return yield* new FederationError({ + code: "peer-rejected", + message: + "The machine behind this code identified itself differently than the code claims. Pairing was aborted.", + }); + } + const at = yield* nowIso; + const stored: FederationPeerStore.PersistedFederationPeer = { + peerId: response.environmentId, + label: response.label, + publicKey: response.publicKey, + grantedScopes: input.grantedScopes, + allowedScopes: response.grantedScopes, + transport: payload.transport, + remoteServerVersion: response.serverVersion, + remoteProtocolVersion: response.protocolVersion, + remoteCapabilities: response.capabilities, + createdAt: at, + lastSeenAt: at, + }; + yield* peers.upsertPeer(stored).pipe(Effect.mapError(storeError)); + yield* peers.setPeerStatus(stored.peerId, { status: "online", lastError: null }); + if (response.tailcatNodeKey !== undefined) { + yield* tailcat + .recordTrustedPeer({ + nodeKey: response.tailcatNodeKey, + label: `Federation: ${response.label}`, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not trust the peer's Tailcat key.", { error }), + ), + ); + } + yield* Effect.logInfo("Paired with a federation peer.", { + peerId: stored.peerId, + allowedScopes: stored.allowedScopes, + grantedScopes: stored.grantedScopes, + }); + yield* publishPeers; + return yield* peers.presentPeer(stored); + }, + ); + + const removePeer: FederationService["Service"]["removePeer"] = Effect.fn( + "FederationService.removePeer", + )(function* (peerId) { + const peer = yield* requireLocalPeer(peerId); + yield* peers.removePeer(peerId).pipe(Effect.mapError(storeError)); + yield* Ref.update(peerSessions, (current) => { + const next = new Map(current); + next.delete(peerId); + return next; + }); + yield* transport.drop(peerId); + // Sessions the peer holds here die with the trust relationship. + const sessions = yield* environmentAuth.listSessions().pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + sessions.filter( + (session) => session.subject === `${FEDERATION_SESSION_SUBJECT_PREFIX}${peerId}`, + ), + (session) => environmentAuth.revokeSession(session.sessionId).pipe(Effect.ignore), + { discard: true }, + ); + yield* Effect.logInfo("Federation peer removed.", { peerId, label: peer.label }); + yield* publishPeers; + yield* publishRuns; + }); + + const listRemoteProjects: FederationService["Service"]["listRemoteProjects"] = Effect.fn( + "FederationService.listRemoteProjects", + )(function* (peerId) { + const peer = yield* requireLocalPeer(peerId); + yield* requireAllowed(peer, ["projects.read"]); + return yield* callPeer(peer, (client, headers) => client.federation.projects({ headers })); + }); + + const upsertRemoteRun = (record: FederationRemoteRun) => + peers.upsertRemoteRun(record).pipe(Effect.mapError(storeError), Effect.andThen(publishRuns)); + + const startRemoteRun: FederationService["Service"]["startRemoteRun"] = Effect.fn( + "FederationService.startRemoteRun", + )(function* (input) { + const peer = yield* requireLocalPeer(input.peerId); + yield* requireAllowed(peer, ["runs.start", "runs.read"]); + const run = yield* callPeer(peer, (client, headers) => + client.federation.startRun({ + headers, + payload: { + projectId: input.projectId, + prompt: input.prompt, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.runtimeMode === undefined ? {} : { runtimeMode: input.runtimeMode }), + }, + }), + ); + const record: FederationRemoteRun = { + peerId: peer.peerId, + peerLabel: peer.label, + run, + events: [], + lastSyncedAt: yield* nowIso, + syncError: null, + }; + yield* upsertRemoteRun(record); + yield* Queue.offer(pollSignals, "poll"); + return record; + }); + + const findRemoteRun = (input: FederationRemoteRunInput) => + peers.remoteRuns.pipe( + Effect.flatMap((runs) => { + const record = runs.find( + (run) => run.peerId === input.peerId && run.run.threadId === input.threadId, + ); + return record === undefined + ? Effect.fail( + new FederationError({ + code: "run-not-found", + message: "That remote run is not tracked here.", + }), + ) + : Effect.succeed(record); + }), + ); + + const cancelRemoteRun: FederationService["Service"]["cancelRemoteRun"] = Effect.fn( + "FederationService.cancelRemoteRun", + )(function* (input) { + const peer = yield* requireLocalPeer(input.peerId); + yield* requireAllowed(peer, ["runs.cancel"]); + const record = yield* findRemoteRun(input); + const run = yield* callPeer(peer, (client, headers) => + client.federation.cancelRun({ headers, params: { threadId: input.threadId } }), + ); + const updated = { ...record, run, lastSyncedAt: yield* nowIso, syncError: null }; + yield* upsertRemoteRun(updated); + return updated; + }); + + const describeRemoteArtifacts: FederationService["Service"]["describeRemoteArtifacts"] = + Effect.fn("FederationService.describeRemoteArtifacts")(function* (input) { + const peer = yield* requireLocalPeer(input.peerId); + yield* requireAllowed(peer, ["artifacts.read"]); + yield* findRemoteRun(input); + return yield* callPeer(peer, (client, headers) => + client.federation.runArtifacts({ headers, params: { threadId: input.threadId } }), + ); + }); + + const fetchRemoteArtifact: FederationService["Service"]["fetchRemoteArtifact"] = Effect.fn( + "FederationService.fetchRemoteArtifact", + )(function* (input) { + const peer = yield* requireLocalPeer(input.peerId); + yield* requireAllowed(peer, ["artifacts.read"]); + yield* findRemoteRun({ peerId: input.peerId, threadId: input.threadId }); + return yield* callPeer(peer, (client, headers) => + client.federation.fetchArtifact({ + headers, + params: { threadId: input.threadId, turnId: input.turnId }, + }), + ); + }); + + const syncRemoteRun = (record: FederationRemoteRun) => + Effect.gen(function* () { + const peer = yield* peers.getPeer(record.peerId); + if (Option.isNone(peer)) { + return; + } + const afterSequence = record.events.at(-1)?.sequence ?? 0; + const response = yield* callPeer(peer.value, (client, headers) => + client.federation.runEvents({ + headers, + params: { threadId: record.run.threadId }, + payload: { afterSequence }, + }), + ).pipe(Effect.result); + const at = yield* nowIso; + if (Result.isFailure(response)) { + yield* upsertRemoteRun({ + ...record, + lastSyncedAt: at, + syncError: response.failure.message, + }); + return; + } + const merged = [...record.events, ...response.success.events].slice(-REMOTE_RUN_EVENT_LIMIT); + yield* upsertRemoteRun({ + ...record, + run: response.success.run, + events: merged, + lastSyncedAt: at, + syncError: null, + }); + }); + + const pollLoop = Effect.gen(function* () { + for (;;) { + const runs = yield* peers.remoteRuns; + const active = runs.filter((record) => isFederationRunActive(record.run)); + if (active.length === 0) { + // Nothing to watch: sleep until a run starts instead of polling peers for nothing. + yield* Queue.take(pollSignals); + yield* Queue.clear(pollSignals); + continue; + } + yield* Effect.forEach(active, syncRemoteRun, { discard: true, concurrency: 2 }); + yield* Effect.raceFirst( + Effect.sleep(REMOTE_RUN_POLL_INTERVAL), + Queue.take(pollSignals).pipe(Effect.asVoid), + ); + } + }); + yield* pollLoop.pipe(Effect.forkIn(serviceScope)); + + const refreshAllPeers = peers.peers.pipe( + Effect.flatMap((stored) => + Effect.forEach(stored, (peer) => refreshPeer(peer.peerId).pipe(Effect.ignore), { + discard: true, + concurrency: 2, + }), + ), + ); + yield* Effect.sleep(Duration.seconds(15)).pipe( + Effect.andThen(refreshAllPeers), + Effect.andThen( + Effect.sleep(PEER_REFRESH_INTERVAL).pipe(Effect.andThen(refreshAllPeers), Effect.forever), + ), + Effect.forkIn(serviceScope), + ); + + return FederationService.of({ + snapshot: SubscriptionRef.get(snapshotRef), + changes: SubscriptionRef.changes(snapshotRef), + remoteRuns: SubscriptionRef.get(runsRef), + remoteRunChanges: SubscriptionRef.changes(runsRef), + createPeerCode, + addPeer, + removePeer, + refreshPeer, + listRemoteProjects, + startRemoteRun, + cancelRemoteRun, + describeRemoteArtifacts, + fetchRemoteArtifact, + acceptPair, + issueChallenge, + redeemChallenge, + authorizePeer, + hello: helloEffect, + localProjects, + startLocalRun, + localRunStatus, + cancelLocalRun, + localRunEvents, + localRunArtifacts, + fetchLocalArtifact, + }); +}); + +export const layer = Layer.effect(FederationService, make); diff --git a/apps/server/src/federation/FederationTransport.ts b/apps/server/src/federation/FederationTransport.ts new file mode 100644 index 000000000000..185795dd9b19 --- /dev/null +++ b/apps/server/src/federation/FederationTransport.ts @@ -0,0 +1,224 @@ +import { + type EnvironmentId, + FederationError, + type FederationTransport as FederationTransportDescriptor, + type TailcatNodeKey, +} from "@t3tools/contracts"; +import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; +import * as NetService from "@t3tools/shared/Net"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; + +/** + * FederationTransport gives this server a loopback HTTP endpoint for each peer + * by running a Tailcat forward to the peer's listener, using this server's own + * Tailcat client identity. Forwards are created lazily, reused while healthy, + * and closed when the peer is removed or the server shuts down. + */ +export const TAILCAT_CLIENT_IDENTITY_FILE = "tailcat-client-identity.private.json"; +const PEER_READY_TIMEOUT = Duration.seconds(25); +const PEER_HEALTH_TIMEOUT = Duration.millis(2_500); + +export interface PeerEndpoint { + readonly httpBaseUrl: string; + readonly localPort: number; +} + +export class FederationTransport extends Context.Service< + FederationTransport, + { + /** This server's Tailcat client node key, created on first use. */ + readonly clientNodeKey: Effect.Effect; + readonly endpointFor: (input: { + readonly peerId: EnvironmentId; + readonly transport: FederationTransportDescriptor; + }) => Effect.Effect; + /** Drops the forward for a peer so the next call starts a fresh one. */ + readonly drop: (peerId: EnvironmentId) => Effect.Effect; + } +>()("t3/federation/FederationTransport") {} + +interface ActiveForward { + readonly scope: Scope.Closeable; + readonly handle: TailcatRuntime.TailcatForwardHandle; + readonly address: string; + readonly port: number; +} + +const transportUnavailable = (message: string) => + new FederationError({ code: "transport-unavailable", message }); + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const runtime = yield* TailcatRuntime.TailcatRuntime; + const net = yield* NetService.NetService; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serviceScope = yield* Scope.Scope; + const identityPath = path.join(config.secretsDir, TAILCAT_CLIENT_IDENTITY_FILE); + const forwards = yield* Ref.make>(new Map()); + const lock = yield* Semaphore.make(1); + + const clientNodeKey: FederationTransport["Service"]["clientNodeKey"] = Effect.gen(function* () { + const exists = yield* fileSystem.exists(identityPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + const created = yield* runtime.generateClientIdentity({ keyPath: identityPath }); + return created.nodeKey; + } + return yield* runtime.readClientPublicKey({ keyPath: identityPath }); + }).pipe( + Effect.mapError((error) => + transportUnavailable(`Tailcat is not available on this machine: ${error.message}`), + ), + ); + + const closeForward = (forward: ActiveForward) => + Scope.close(forward.scope, Exit.void).pipe(Effect.ignore); + + const probe = (httpBaseUrl: string) => + waitForHttpReady({ + baseUrl: httpBaseUrl, + path: "/.well-known/t3/environment", + timeoutMs: Duration.toMillis(PEER_HEALTH_TIMEOUT), + intervalMs: 250, + probeTimeoutMs: Duration.toMillis(PEER_HEALTH_TIMEOUT), + makeError: () => "unhealthy" as const, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + + const endpointFor: FederationTransport["Service"]["endpointFor"] = ({ peerId, transport }) => + lock.withPermits(1)( + Effect.gen(function* () { + const existing = (yield* Ref.get(forwards)).get(peerId); + if (existing !== undefined) { + const sameTarget = + existing.address === transport.tailcat.address && + existing.port === transport.tailcat.port; + const alive = sameTarget && (yield* existing.handle.isRunning); + if (alive) { + const healthy = yield* probe(existing.handle.httpBaseUrl).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (healthy) { + return { + httpBaseUrl: existing.handle.httpBaseUrl, + localPort: existing.handle.localPort, + } satisfies PeerEndpoint; + } + } + yield* Ref.update(forwards, (current) => { + const next = new Map(current); + next.delete(peerId); + return next; + }); + yield* closeForward(existing); + } + yield* clientNodeKey; + const localPort = yield* net + .reserveLoopbackPort() + .pipe( + Effect.mapError((error) => + transportUnavailable(`Could not reserve a local port: ${error.message}`), + ), + ); + const scope = yield* Scope.make("sequential"); + const handle = yield* runtime + .forward({ + keyPath: identityPath, + address: transport.tailcat.address, + remotePort: transport.tailcat.port, + localPort, + readiness: ({ httpBaseUrl }) => + waitForHttpReady({ + baseUrl: httpBaseUrl, + path: "/.well-known/t3/environment", + timeoutMs: Duration.toMillis(PEER_READY_TIMEOUT), + intervalMs: 300, + probeTimeoutMs: 3_000, + makeError: () => "unreachable" as const, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + readinessTimeout: PEER_READY_TIMEOUT, + }) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + Effect.mapError((error) => + error === "unreachable" + ? new FederationError({ + code: "peer-unreachable", + message: + "The peer did not answer through Tailcat. It may be offline, or this environment may no longer be trusted by it.", + }) + : error._tag === "TailcatBinaryMissingError" || + error._tag === "TailcatBinaryNotExecutableError" || + error._tag === "TailcatVersionIncompatibleError" + ? transportUnavailable(error.message) + : new FederationError({ code: "peer-unreachable", message: error.message }), + ), + ); + yield* Ref.update(forwards, (current) => + new Map(current).set(peerId, { + scope, + handle, + address: transport.tailcat.address, + port: transport.tailcat.port, + }), + ); + yield* Effect.logInfo("Federation transport ready.", { + peerId, + localPort: handle.localPort, + pid: handle.pid, + }); + return { + httpBaseUrl: handle.httpBaseUrl, + localPort: handle.localPort, + } satisfies PeerEndpoint; + }), + ); + + const drop: FederationTransport["Service"]["drop"] = (peerId) => + lock.withPermits(1)( + Effect.gen(function* () { + const existing = (yield* Ref.get(forwards)).get(peerId); + if (existing === undefined) { + return; + } + yield* Ref.update(forwards, (current) => { + const next = new Map(current); + next.delete(peerId); + return next; + }); + yield* closeForward(existing); + }), + ); + + yield* Scope.addFinalizer( + serviceScope, + Ref.get(forwards).pipe( + Effect.flatMap((current) => + Effect.forEach(current.values(), closeForward, { discard: true, concurrency: "unbounded" }), + ), + ), + ); + + return FederationTransport.of({ + clientNodeKey, + endpointFor, + drop, + }); +}); + +export const layer = Layer.effect(FederationTransport, make); diff --git a/apps/server/src/federation/http.ts b/apps/server/src/federation/http.ts new file mode 100644 index 000000000000..3bee37b2a713 --- /dev/null +++ b/apps/server/src/federation/http.ts @@ -0,0 +1,124 @@ +import { EnvironmentAuthenticatedPrincipal, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { annotateEnvironmentRequest } from "../auth/http.ts"; +import * as FederationService from "./FederationService.ts"; + +/** + * Peer-facing federation endpoints. Pairing and authentication are open by + * design (they establish trust and sessions); everything else runs under the + * ordinary session middleware and then checks the peer's federation grant. + */ +export const federationHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "federation", + Effect.fnUntraced(function* (handlers) { + const federation = yield* FederationService.FederationService; + + const peerFor = (required: Parameters[1]) => + EnvironmentAuthenticatedPrincipal.pipe( + Effect.flatMap((principal) => + federation.authorizePeer( + { subject: principal.subject, scopes: principal.scopes }, + required, + ), + ), + ); + + return handlers + .handle( + "pair", + Effect.fn("environment.federation.pair")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + return yield* federation.acceptPair(args.payload); + }), + ) + .handle( + "challenge", + Effect.fn("environment.federation.challenge")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + return yield* federation.issueChallenge(args.payload); + }), + ) + .handle( + "token", + Effect.fn("environment.federation.token")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + return yield* federation.redeemChallenge(args.payload); + }), + ) + .handle( + "hello", + Effect.fn("environment.federation.hello")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* peerFor("environment.read"); + return yield* federation.hello; + }), + ) + .handle( + "projects", + Effect.fn("environment.federation.projects")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* peerFor("projects.read"); + return yield* federation.localProjects; + }), + ) + .handle( + "startRun", + Effect.fn("environment.federation.startRun")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("runs.start"); + return yield* federation.startLocalRun(peer, args.payload); + }), + ) + .handle( + "runStatus", + Effect.fn("environment.federation.runStatus")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("runs.read"); + return yield* federation.localRunStatus(peer, args.params.threadId); + }), + ) + .handle( + "cancelRun", + Effect.fn("environment.federation.cancelRun")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("runs.cancel"); + return yield* federation.cancelLocalRun(peer, args.params.threadId); + }), + ) + .handle( + "runEvents", + Effect.fn("environment.federation.runEvents")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("runs.read"); + return yield* federation.localRunEvents( + peer, + args.params.threadId, + args.payload.afterSequence ?? 0, + ); + }), + ) + .handle( + "runArtifacts", + Effect.fn("environment.federation.runArtifacts")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("artifacts.read"); + return yield* federation.localRunArtifacts(peer, args.params.threadId); + }), + ) + .handle( + "fetchArtifact", + Effect.fn("environment.federation.fetchArtifact")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("artifacts.read"); + return yield* federation.fetchLocalArtifact( + peer, + args.params.threadId, + args.params.turnId, + ); + }), + ); + }), +); diff --git a/apps/server/src/federation/runProjection.test.ts b/apps/server/src/federation/runProjection.test.ts new file mode 100644 index 000000000000..002d39228bd4 --- /dev/null +++ b/apps/server/src/federation/runProjection.test.ts @@ -0,0 +1,478 @@ +import { + CheckpointRef, + EnvironmentId, + EventId, + MessageId, + type OrchestrationCheckpointSummary, + type OrchestrationEvent, + type OrchestrationLatestTurn, + type OrchestrationLatestTurnState, + type OrchestrationThreadShell, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + FEDERATION_PREVIEW_MAX_CHARS, + federationRunStatus, + isFederationRunActive, + projectFederationArtifacts, + projectFederationRun, + summarizeFederationRunEvent, + truncatePreview, +} from "./runProjection.ts"; + +const environmentId = EnvironmentId.make("environment-origin"); +const projectId = ProjectId.make("project-t3code"); +const threadId = ThreadId.make("thread-fix-checkpoints"); +const otherThreadId = ThreadId.make("thread-unrelated"); +const turnId = TurnId.make("turn-1"); +const messageId = MessageId.make("message-1"); +const createdAt = "2026-03-01T09:00:00.000Z"; +const requestedAt = "2026-03-01T09:30:00.000Z"; +const startedAt = "2026-03-01T09:30:01.000Z"; +const completedAt = "2026-03-01T09:42:17.000Z"; +const occurredAt = "2026-03-01T09:31:00.000Z"; +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +} as const; + +const makeThreadShell = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: threadId, + projectId, + title: "Fix flaky checkpoint test", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt, + updatedAt: createdAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const makeLatestTurn = ( + overrides: Partial = {}, +): OrchestrationLatestTurn => ({ + turnId, + state: "running", + requestedAt, + startedAt, + completedAt: null, + assistantMessageId: null, + ...overrides, +}); + +const eventBase = ( + sequence: number, + aggregateId: ThreadId | ProjectId = threadId, + aggregateKind: "thread" | "project" = "thread", +) => ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind, + aggregateId, + occurredAt, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, +}); + +const messageSentEvent = (input: { + readonly text: string; + readonly role?: "user" | "assistant"; + readonly aggregateId?: ThreadId; +}): OrchestrationEvent => { + const aggregateId = input.aggregateId ?? threadId; + return { + ...eventBase(7, aggregateId), + type: "thread.message-sent", + payload: { + threadId: aggregateId, + messageId, + role: input.role ?? "user", + text: input.text, + turnId, + streaming: false, + createdAt: occurredAt, + updatedAt: occurredAt, + }, + }; +}; + +const sessionSetEvent = (lastError: string | null): OrchestrationEvent => ({ + ...eventBase(9), + type: "thread.session-set", + payload: { + threadId, + session: { + threadId, + status: lastError === null ? "ready" : "error", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError, + updatedAt: occurredAt, + }, + }, +}); + +const turnDiffCompletedEvent = (fileCount: number): OrchestrationEvent => ({ + ...eventBase(11), + type: "thread.turn-diff-completed", + payload: { + threadId, + turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-fix-checkpoints/1"), + status: "ready", + files: Array.from({ length: fileCount }, (_, index) => ({ + path: `src/file-${index}.ts`, + kind: "modified", + additions: 3, + deletions: 1, + })), + assistantMessageId: null, + completedAt, + }, +}); + +const makeCheckpoint = ( + overrides: Partial = {}, +): OrchestrationCheckpointSummary => ({ + turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-fix-checkpoints/1"), + status: "ready", + files: [ + { path: "src/server.ts", kind: "modified", additions: 12, deletions: 4 }, + { path: "src/server.test.ts", kind: "added", additions: 40, deletions: 0 }, + ], + assistantMessageId: null, + completedAt, + ...overrides, +}); + +describe("federationRunStatus", () => { + it.each<{ readonly state: OrchestrationLatestTurnState | null; readonly expected: string }>([ + { state: null, expected: "queued" }, + { state: "running", expected: "running" }, + { state: "completed", expected: "completed" }, + { state: "interrupted", expected: "interrupted" }, + { state: "error", expected: "error" }, + ])("maps latest turn state $state to $expected", ({ state, expected }) => { + expect(federationRunStatus(state)).toBe(expected); + }); +}); + +describe("truncatePreview", () => { + it("collapses whitespace runs and trims the edges", () => { + expect(truncatePreview(" Refactor\n\n the reactor\t queue ")).toBe( + "Refactor the reactor queue", + ); + }); + + it("leaves text at the limit untouched", () => { + const text = "a".repeat(FEDERATION_PREVIEW_MAX_CHARS); + expect(truncatePreview(text)).toBe(text); + }); + + it("cuts longer text to the limit and ends it with an ellipsis", () => { + const preview = truncatePreview("b".repeat(FEDERATION_PREVIEW_MAX_CHARS + 60)); + expect(preview).toHaveLength(FEDERATION_PREVIEW_MAX_CHARS); + expect(preview).toBe(`${"b".repeat(FEDERATION_PREVIEW_MAX_CHARS - 1)}…`); + }); + + it("honors a custom limit", () => { + expect(truncatePreview("abcdefgh", 5)).toBe("abcd…"); + }); +}); + +describe("projectFederationRun", () => { + it("reports a thread without a turn as queued, timed from its creation", () => { + const run = projectFederationRun({ + environmentId, + thread: makeThreadShell(), + assistantPreview: null, + turnCount: 0, + }); + + expect(run).toEqual({ + environmentId, + projectId, + threadId, + turnId: null, + title: "Fix flaky checkpoint test", + status: "queued", + runtimeMode: "full-access", + modelSelection, + requestedAt: createdAt, + startedAt: null, + completedAt: null, + assistantPreview: null, + turnCount: 0, + }); + }); + + it("reports a running turn with its own timestamps", () => { + const run = projectFederationRun({ + environmentId, + thread: makeThreadShell({ latestTurn: makeLatestTurn() }), + assistantPreview: "Looking at the flaky test…", + turnCount: 1, + }); + + expect(run).toMatchObject({ + turnId, + status: "running", + requestedAt, + startedAt, + completedAt: null, + assistantPreview: "Looking at the flaky test…", + turnCount: 1, + }); + }); + + it.each<{ readonly state: OrchestrationLatestTurnState; readonly expected: string }>([ + { state: "completed", expected: "completed" }, + { state: "interrupted", expected: "interrupted" }, + { state: "error", expected: "error" }, + ])("reports a $state turn as $expected with its completion time", ({ state, expected }) => { + const run = projectFederationRun({ + environmentId, + thread: makeThreadShell({ + latestTurn: makeLatestTurn({ state, completedAt }), + }), + assistantPreview: "Done.", + turnCount: 3, + }); + + expect(run.status).toBe(expected); + expect(run.completedAt).toBe(completedAt); + expect(run.startedAt).toBe(startedAt); + }); +}); + +describe("isFederationRunActive", () => { + const baseRun = projectFederationRun({ + environmentId, + thread: makeThreadShell(), + assistantPreview: null, + turnCount: 0, + }); + + it("treats queued and running runs as active", () => { + expect(isFederationRunActive({ ...baseRun, status: "queued" })).toBe(true); + expect(isFederationRunActive({ ...baseRun, status: "running" })).toBe(true); + }); + + it("treats settled runs as inactive", () => { + expect(isFederationRunActive({ ...baseRun, status: "completed" })).toBe(false); + expect(isFederationRunActive({ ...baseRun, status: "interrupted" })).toBe(false); + expect(isFederationRunActive({ ...baseRun, status: "error" })).toBe(false); + }); +}); + +describe("summarizeFederationRunEvent", () => { + it("ignores events that belong to another thread", () => { + const event = messageSentEvent({ text: "hello", aggregateId: otherThreadId }); + expect(summarizeFederationRunEvent(event, threadId)).toBeNull(); + }); + + it("ignores project events even when the aggregate id matches", () => { + const event: OrchestrationEvent = { + ...eventBase(3, threadId, "project"), + type: "project.created", + payload: { + projectId, + title: "t3code", + workspaceRoot: "/home/dev/t3code", + defaultModelSelection: modelSelection, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }; + expect(summarizeFederationRunEvent(event, threadId)).toBeNull(); + }); + + it("ignores thread events that carry nothing worth relaying", () => { + const event: OrchestrationEvent = { + ...eventBase(4), + type: "thread.archived", + payload: { threadId, archivedAt: occurredAt, updatedAt: occurredAt }, + }; + expect(summarizeFederationRunEvent(event, threadId)).toBeNull(); + }); + + it("relays a sent message with its role and the event position", () => { + const summary = summarizeFederationRunEvent( + messageSentEvent({ text: "Please fix the\nflaky test", role: "user" }), + threadId, + ); + + expect(summary).toEqual({ + sequence: 7, + at: occurredAt, + type: "thread.message-sent", + summary: "user: Please fix the flaky test", + }); + }); + + it("truncates long message text after the role prefix", () => { + const summary = summarizeFederationRunEvent( + messageSentEvent({ text: "x".repeat(1_000), role: "assistant" }), + threadId, + ); + + expect(summary?.summary.startsWith("assistant: ")).toBe(true); + expect(summary?.summary.endsWith("…")).toBe(true); + expect(summary?.summary).toHaveLength("assistant: ".length + FEDERATION_PREVIEW_MAX_CHARS); + }); + + it("describes turn lifecycle requests", () => { + const started: OrchestrationEvent = { + ...eventBase(8), + type: "thread.turn-start-requested", + payload: { + threadId, + messageId, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: occurredAt, + }, + }; + const interrupted: OrchestrationEvent = { + ...eventBase(10), + type: "thread.turn-interrupt-requested", + payload: { threadId, turnId, createdAt: occurredAt }, + }; + + expect(summarizeFederationRunEvent(started, threadId)?.summary).toBe("Turn started"); + expect(summarizeFederationRunEvent(interrupted, threadId)?.summary).toBe("Interrupt requested"); + }); + + it("describes session changes, including the last error when there is one", () => { + expect(summarizeFederationRunEvent(sessionSetEvent(null), threadId)?.summary).toBe( + "Session ready", + ); + expect( + summarizeFederationRunEvent(sessionSetEvent("codex exited with code 1"), threadId)?.summary, + ).toBe("Session error: codex exited with code 1"); + }); + + it("counts the files in a completed turn diff", () => { + expect(summarizeFederationRunEvent(turnDiffCompletedEvent(1), threadId)?.summary).toBe( + "Changes recorded (1 file)", + ); + expect(summarizeFederationRunEvent(turnDiffCompletedEvent(3), threadId)?.summary).toBe( + "Changes recorded (3 files)", + ); + expect(summarizeFederationRunEvent(turnDiffCompletedEvent(0), threadId)?.summary).toBe( + "Changes recorded (0 files)", + ); + }); + + it("relays activity summaries, truncated", () => { + const event: OrchestrationEvent = { + ...eventBase(12), + type: "thread.activity-appended", + payload: { + threadId, + activity: { + id: EventId.make("activity-12"), + tone: "tool", + kind: "tool-call", + summary: `Ran vitest ${"-".repeat(FEDERATION_PREVIEW_MAX_CHARS)}`, + payload: { command: "vitest" }, + turnId, + createdAt: occurredAt, + }, + }, + }; + + const summary = summarizeFederationRunEvent(event, threadId); + expect(summary?.type).toBe("thread.activity-appended"); + expect(summary?.summary.startsWith("Ran vitest ")).toBe(true); + expect(summary?.summary).toHaveLength(FEDERATION_PREVIEW_MAX_CHARS); + }); +}); + +describe("projectFederationArtifacts", () => { + it("projects ready checkpoints as turn diffs stamped with their origin", () => { + const secondTurnId = TurnId.make("turn-2"); + const artifacts = projectFederationArtifacts({ + environmentId, + threadId, + checkpoints: [ + makeCheckpoint(), + makeCheckpoint({ + turnId: secondTurnId, + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-fix-checkpoints/2"), + files: [{ path: "README.md", kind: "deleted", additions: 0, deletions: 20 }], + }), + ], + }); + + expect(artifacts).toEqual([ + { + environmentId, + threadId, + turnId, + kind: "turn-diff", + fromTurnCount: 0, + toTurnCount: 1, + files: [ + { path: "src/server.ts", status: "modified" }, + { path: "src/server.test.ts", status: "added" }, + ], + }, + { + environmentId, + threadId, + turnId: secondTurnId, + kind: "turn-diff", + fromTurnCount: 1, + toTurnCount: 2, + files: [{ path: "README.md", status: "deleted" }], + }, + ]); + }); + + it("skips checkpoints that are not ready and the pre-turn baseline", () => { + const artifacts = projectFederationArtifacts({ + environmentId, + threadId, + checkpoints: [ + makeCheckpoint({ status: "missing" }), + makeCheckpoint({ status: "error" }), + makeCheckpoint({ checkpointTurnCount: 0 }), + makeCheckpoint({ turnId: TurnId.make("turn-3"), checkpointTurnCount: 3 }), + ], + }); + + expect(artifacts.map((artifact) => artifact.turnId)).toEqual([TurnId.make("turn-3")]); + expect(artifacts[0]).toMatchObject({ fromTurnCount: 2, toTurnCount: 3 }); + }); + + it("returns nothing for a thread without checkpoints", () => { + expect(projectFederationArtifacts({ environmentId, threadId, checkpoints: [] })).toEqual([]); + }); +}); diff --git a/apps/server/src/federation/runProjection.ts b/apps/server/src/federation/runProjection.ts new file mode 100644 index 000000000000..8f9fff6e06d6 --- /dev/null +++ b/apps/server/src/federation/runProjection.ts @@ -0,0 +1,127 @@ +import type { + EnvironmentId, + FederationArtifactRef, + FederationRun, + FederationRunEvent, + FederationRunStatus, + OrchestrationCheckpointSummary, + OrchestrationEvent, + OrchestrationLatestTurnState, + OrchestrationThreadShell, + ThreadId, +} from "@t3tools/contracts"; + +/** + * Pure projections from orchestration state onto the federation protocol. + * Runs are threads the peer started; the projection deliberately exposes the + * few facts a coordinating environment needs and nothing about the rest of + * the thread. + */ + +export const FEDERATION_PREVIEW_MAX_CHARS = 240; + +export function federationRunStatus( + state: OrchestrationLatestTurnState | null, +): FederationRunStatus { + switch (state) { + case null: + return "queued"; + case "running": + return "running"; + case "completed": + return "completed"; + case "interrupted": + return "interrupted"; + case "error": + return "error"; + } +} + +export function truncatePreview(text: string, max = FEDERATION_PREVIEW_MAX_CHARS): string { + const collapsed = text.replace(/\s+/gu, " ").trim(); + return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`; +} + +export function projectFederationRun(input: { + readonly environmentId: EnvironmentId; + readonly thread: OrchestrationThreadShell; + readonly assistantPreview: string | null; + readonly turnCount: number; +}): FederationRun { + const latestTurn = input.thread.latestTurn; + return { + environmentId: input.environmentId, + projectId: input.thread.projectId, + threadId: input.thread.id, + turnId: latestTurn?.turnId ?? null, + title: input.thread.title, + status: federationRunStatus(latestTurn?.state ?? null), + runtimeMode: input.thread.runtimeMode, + modelSelection: input.thread.modelSelection, + requestedAt: latestTurn?.requestedAt ?? input.thread.createdAt, + startedAt: latestTurn?.startedAt ?? null, + completedAt: latestTurn?.completedAt ?? null, + assistantPreview: input.assistantPreview, + turnCount: input.turnCount, + }; +} + +export function isFederationRunActive(run: FederationRun): boolean { + return run.status === "queued" || run.status === "running"; +} + +/** Summarizes one persisted event for a peer; null when it carries nothing worth relaying. */ +export function summarizeFederationRunEvent( + event: OrchestrationEvent, + threadId: ThreadId, +): FederationRunEvent | null { + if (event.aggregateKind !== "thread" || event.aggregateId !== threadId) { + return null; + } + const base = { sequence: event.sequence, at: event.occurredAt, type: event.type }; + switch (event.type) { + case "thread.message-sent": + return { + ...base, + summary: `${event.payload.role}: ${truncatePreview(event.payload.text)}`, + }; + case "thread.turn-start-requested": + return { ...base, summary: "Turn started" }; + case "thread.turn-interrupt-requested": + return { ...base, summary: "Interrupt requested" }; + case "thread.session-set": + return { + ...base, + summary: event.payload.session.lastError + ? `Session ${event.payload.session.status}: ${truncatePreview(event.payload.session.lastError)}` + : `Session ${event.payload.session.status}`, + }; + case "thread.turn-diff-completed": + return { + ...base, + summary: `Changes recorded (${event.payload.files.length} ${event.payload.files.length === 1 ? "file" : "files"})`, + }; + case "thread.activity-appended": + return { ...base, summary: truncatePreview(event.payload.activity.summary) }; + default: + return null; + } +} + +export function projectFederationArtifacts(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly checkpoints: ReadonlyArray; +}): ReadonlyArray { + return input.checkpoints + .filter((checkpoint) => checkpoint.status === "ready" && checkpoint.checkpointTurnCount > 0) + .map((checkpoint) => ({ + environmentId: input.environmentId, + threadId: input.threadId, + turnId: checkpoint.turnId, + kind: "turn-diff" as const, + fromTurnCount: checkpoint.checkpointTurnCount - 1, + toTurnCount: checkpoint.checkpointTurnCount, + files: checkpoint.files.map((file) => ({ path: file.path, status: file.kind })), + })); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d972e2e00803..b92dbec79547 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -93,6 +93,8 @@ const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; +import * as FederationService from "./federation/FederationService.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; import { isThreadDetailEvent, resolveAvailableEditorsForConfig, @@ -562,6 +564,8 @@ const buildAppUnderTest = (options?: { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, ...options?.config, }; const layerConfig = ServerConfig.layer(config); @@ -717,6 +721,14 @@ const buildAppUnderTest = (options?: { Layer.provide(Layer.succeed(HostProcessEnvironment, {})), ); + // Tailcat and federation are exercised by their own tests; here they only + // need to exist so the auth token exchange and RPC layer can resolve them. + const tailcatRemoteAccessLayer = Layer.mock(TailcatRemoteAccess.TailcatRemoteAccess)({ + readyEndpoint: Effect.succeed(Option.none()), + recordTrustedPeer: () => Effect.void, + start: () => Effect.void, + }); + const federationLayer = Layer.mock(FederationService.FederationService)({}); const servedRoutesLayer = HttpRouter.serve( makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)), { @@ -979,6 +991,8 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), + Layer.provide(tailcatRemoteAccessLayer), + Layer.provide(federationLayer), Layer.provide(UsageService.layerTest), Layer.provide( Layer.mock(AnalyticsService.AnalyticsService)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3a93adc6d761..23567a655995 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; +import * as Console from "effect/Console"; import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -126,6 +127,15 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import { federationHttpApiLayer } from "./federation/http.ts"; +import * as FederationIdentity from "./federation/FederationIdentity.ts"; +import * as FederationPeerStore from "./federation/FederationPeerStore.ts"; +import * as FederationService from "./federation/FederationService.ts"; +import * as FederationTransport from "./federation/FederationTransport.ts"; +import { tailcatHttpApiLayer } from "./tailcat/http.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; +import * as TailcatRuntimeLive from "./tailcat/TailcatRuntimeLive.ts"; +import { formatTailcatHeadlessOutput } from "./tailcat/startupOutput.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; @@ -496,7 +506,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); -const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( +const RuntimeBaseDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), @@ -509,6 +519,26 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provide(NetService.layer), ); +// Tailcat exposes this environment's loopback listener; federation rides on it. +// Both consume the runtime above (auth, orchestration, checkpoints, workspace). +const TailcatRemoteAccessLayerLive = TailcatRemoteAccess.layer.pipe( + Layer.provide(TailcatRuntimeLive.layer), +); +const FederationLayerLive = FederationService.layer.pipe( + Layer.provideMerge( + FederationTransport.layer.pipe( + Layer.provide(TailcatRuntimeLive.layer), + Layer.provide(NetService.layer), + ), + ), + Layer.provideMerge(FederationPeerStore.layer), + Layer.provideMerge(FederationIdentity.layer.pipe(Layer.provide(ServerSecretStore.layer))), +); +const RuntimeDependenciesLive = FederationLayerLive.pipe( + Layer.provideMerge(TailcatRemoteAccessLayerLive), + Layer.provideMerge(RuntimeBaseDependenciesLive), +); + const commandReadinessLayer = HttpRouter.middleware( (httpEffect) => Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => @@ -525,6 +555,8 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(orchestrationHttpApiLayer), Layer.provide(pullRequestHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), + Layer.provide(tailcatHttpApiLayer), + Layer.provide(federationHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), otlpTracesProxyRouteLayer, @@ -553,6 +585,7 @@ export const makeServerLayer = Layer.unwrap( const activationLayer = Layer.succeed(ServerActivation, awaitActivation); const runtimeStateParked = yield* Deferred.make(); const tailscaleParked = yield* Deferred.make(); + const tailcatParked = yield* Deferred.make(); const cloudLinkParked = yield* Deferred.make(); const routesReady = yield* Deferred.make(); const launcherLayer = ServiceLauncherClient.layer; @@ -651,6 +684,54 @@ export const makeServerLayer = Layer.unwrap( ), ) : Layer.empty; + // Tailcat learns the bound loopback port once the listener is up, then + // starts serving if remote access is enabled (persisted or `--tailcat`). + const tailcatStartLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Deferred.succeed(tailcatParked, undefined).pipe(Effect.orDie); + yield* awaitActivation; + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return; + } + const remoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; + yield* remoteAccess.start({ localPort: address.port }); + if (config.tailcatEnabled !== true || config.startupPresentation !== "headless") { + return; + } + // Headless `t3 serve --tailcat`: print a one-time connection code once + // the Tailcat listener is reachable, like the pairing URL for HTTP. + yield* Effect.forkScoped( + Stream.concat(Stream.fromEffect(remoteAccess.state), remoteAccess.changes).pipe( + Stream.filter( + (state) => + state.status === "ready" || + state.status === "error" || + state.status === "unavailable", + ), + Stream.take(1), + Stream.runHead, + Effect.flatMap((settled) => + settled._tag === "Some" && settled.value.status === "ready" + ? remoteAccess + .createConnectionCode({}) + .pipe( + Effect.flatMap((issued) => + Console.log(formatTailcatHeadlessOutput(settled.value, issued)), + ), + ) + : Effect.logWarning("Tailcat remote access did not become ready.", { + error: settled._tag === "Some" ? settled.value.lastError : null, + }), + ), + Effect.catch((error) => + Effect.logWarning("Could not print the Tailcat connection code.", { error }), + ), + ), + ); + }), + ); const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { if (!hasCloudPublicConfig) { @@ -729,6 +810,7 @@ export const makeServerLayer = Layer.unwrap( Deferred.await(runtimeStateParked), Deferred.await(cloudLinkParked), Deferred.await(routesReady), + Deferred.await(tailcatParked), ...(config.tailscaleServeEnabled ? [Deferred.await(tailscaleParked)] : []), ], { concurrency: "unbounded" }, @@ -744,6 +826,7 @@ export const makeServerLayer = Layer.unwrap( httpListeningLayer, runtimeStateLayer, tailscaleServeLayer, + tailcatStartLayer, cloudDesiredLinkReconcileLayer, ); diff --git a/apps/server/src/tailcat/TailcatRemoteAccess.test.ts b/apps/server/src/tailcat/TailcatRemoteAccess.test.ts new file mode 100644 index 000000000000..d14de416d9d6 --- /dev/null +++ b/apps/server/src/tailcat/TailcatRemoteAccess.test.ts @@ -0,0 +1,440 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + AuthSessionId, + EnvironmentId, + type ExecutionEnvironmentDescriptor, + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS, + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + type TailcatAddress, + type TailcatNodeKey, + type TailcatRemoteAccessState, + type TailcatRuntimeInfo, + TailcatTrustedPeer, +} from "@t3tools/contracts"; +import { decodeTailcatConnectionCode } from "@t3tools/shared/t3ConnectionCode"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +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 FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "../auth/PairingGrantStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as TailcatRemoteAccess from "./TailcatRemoteAccess.ts"; + +// Captured from a real `tailcat serve` run; decodes to server key 7ea7…ff32. +const SERVER_ADDRESS: TailcatAddress = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; +const SERVER_FINGERPRINT = "7ea7·7163·ff32"; +const PEER_NODE_KEY: TailcatNodeKey = + "nodekey:9ab555a4a588b75d2054adb683db82461bb6c707d43e8ba39439f8eb1e821503"; +const LOCAL_PORT = 3773; +/** Mirrors the service's relock debounce: one adjust lets a pending reconcile run. */ +const RELOCK_DEBOUNCE = Duration.millis(1_500); +/** Ceiling of the first-failure restart backoff (1s base plus 25% jitter). */ +const FIRST_RETRY_BACKOFF_MAX = Duration.millis(1_250); +const RUNTIME_INFO: TailcatRuntimeInfo = { + executablePath: "/opt/t3/bin/tailcat", + source: "bundled", + version: "0.4.2", + pinnedVersion: "0.4.2", + compatible: true, +}; +const ENVIRONMENT_ID = EnvironmentId.make("environment-tailcat-test"); +const DESCRIPTOR: ExecutionEnvironmentDescriptor = { + environmentId: ENVIRONMENT_ID, + label: "Tailcat test environment", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, +}; + +interface FakeServe { + readonly options: { + readonly keyPath: string; + readonly localPort: number; + readonly allow: TailcatRuntime.TailcatAllowPolicy; + }; + /** Complete this to simulate the listener process dying. */ + readonly exit: Deferred.Deferred>; + /** False once the owning scope closed, i.e. the service stopped this listener. */ + readonly isRunning: Effect.Effect; +} + +/** Records what the service asked of the tailcat runtime; `Queue.take` is the receipt for a (re)started listener. */ +class FakeTailcat extends Context.Service< + FakeTailcat, + { + readonly serves: Queue.Queue; + readonly identityGenerations: Ref.Ref; + } +>()("t3/tailcat/TailcatRemoteAccess.test/FakeTailcat") { + static readonly layer = Layer.effect( + FakeTailcat, + Effect.gen(function* () { + return FakeTailcat.of({ + serves: yield* Queue.unbounded(), + identityGenerations: yield* Ref.make(0), + }); + }), + ); +} + +const fakeRuntimeLayer = Layer.unwrap( + Effect.gen(function* () { + const fake = yield* FakeTailcat; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let nextPid = 40_000; + return Layer.mock(TailcatRuntime.TailcatRuntime)({ + resolve: Effect.succeed(RUNTIME_INFO), + refresh: Effect.succeed(RUNTIME_INFO), + generateServerIdentity: ({ keyPath }) => + Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.dirname(keyPath), { recursive: true }); + yield* fileSystem.writeFileString(keyPath, "fake tailcat identity"); + yield* Ref.update(fake.identityGenerations, (count) => count + 1); + return { address: SERVER_ADDRESS }; + }).pipe(Effect.orDie), + serve: (options) => + Effect.gen(function* () { + const exit = yield* Deferred.make>(); + const running = yield* Ref.make(true); + const stop = Ref.set(running, false).pipe( + Effect.andThen(Deferred.succeed(exit, Option.none())), + Effect.asVoid, + ); + yield* Effect.addFinalizer(() => stop); + const handle: TailcatRuntime.TailcatServeHandle = { + pid: nextPid++, + address: SERVER_ADDRESS, + localPort: options.localPort, + allow: options.allow, + exit: Deferred.await(exit), + isRunning: Ref.get(running), + recentOutput: Effect.succeed([`listening on 127.0.0.1:${options.localPort}`]), + stop, + }; + yield* Queue.offer(fake.serves, { options, exit, isRunning: Ref.get(running) }); + return handle; + }), + }); + }), +).pipe(Layer.provideMerge(FakeTailcat.layer)); + +const authLayer = EnvironmentAuth.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(ENVIRONMENT_ID), + }), + ), +); + +const serverEnvironmentLayer = Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed(ENVIRONMENT_ID), + getDescriptor: Effect.succeed(DESCRIPTOR), +}); + +const makeTestLayer = () => + TailcatRemoteAccess.layer.pipe( + Layer.provideMerge(fakeRuntimeLayer), + Layer.provideMerge(authLayer), + Layer.provide(serverEnvironmentLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-tailcat-remote-access-test-" }), + ), + ); + +const PersistedStateJson = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + enabled: Schema.Boolean, + trustedPeers: Schema.Array(TailcatTrustedPeer), + }), +); +const decodePersistedState = Schema.decodeUnknownSync(PersistedStateJson); + +const readPersistedState = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const raw = yield* fileSystem.readFileString( + path.join(config.stateDir, TailcatRemoteAccess.TAILCAT_REMOTE_ACCESS_STATE_FILE), + ); + return decodePersistedState(raw); +}); + +/** + * `changes` only carries publishes made after subscribing, so the watcher is + * forked (and subscribed) synchronously before the caller triggers anything. + * Join it to get the first published state matching `predicate`. + */ +const watchState = (predicate: (state: TailcatRemoteAccessState) => boolean) => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + return yield* Effect.forkChild( + service.changes.pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)), + { startImmediately: true }, + ); + }); + +/** Binds the service to the local port, enables it, and waits for the first listener. */ +const startEnabled = Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const ready = yield* watchState((state) => state.status === "ready"); + yield* service.start({ localPort: LOCAL_PORT }); + const enabled = yield* service.setEnabled(true); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const serve = yield* Queue.take(fake.serves); + const state = yield* Fiber.join(ready); + return { enabled, serve, state }; +}); + +it.layer(NodeServices.layer)("TailcatRemoteAccess", (it) => { + it.effect("stays disabled and spawns nothing while remote access is off", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const reconcileAt = (yield* Clock.currentTimeMillis) + Duration.toMillis(RELOCK_DEBOUNCE); + const reconciled = yield* watchState((state) => Date.parse(state.updatedAt) >= reconcileAt); + + yield* service.start({ localPort: LOCAL_PORT }); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const state = yield* Fiber.join(reconciled); + + expect(state).toMatchObject({ + enabled: false, + status: "disabled", + address: null, + pairingOpen: false, + trustedPeers: [], + runtime: null, + identityFingerprint: null, + lastError: null, + }); + expect(yield* Queue.size(fake.serves)).toBe(0); + expect(yield* Ref.get(fake.identityGenerations)).toBe(0); + expect(yield* service.readyEndpoint).toEqual(Option.none()); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("enabling creates the identity once and serves a locked listener", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const { enabled, serve, state } = yield* startEnabled; + + // setEnabled answers immediately; the listener comes up after the debounce. + expect(enabled.enabled).toBe(true); + expect(enabled.status).toBe("disabled"); + expect(serve.options).toEqual({ + keyPath: path.join(config.secretsDir, TailcatRemoteAccess.TAILCAT_SERVER_IDENTITY_FILE), + localPort: LOCAL_PORT, + allow: { _tag: "keys", nodeKeys: [] }, + }); + expect(yield* Ref.get(fake.identityGenerations)).toBe(1); + expect(yield* fileSystem.exists(serve.options.keyPath)).toBe(true); + expect(state).toMatchObject({ + enabled: true, + status: "ready", + address: SERVER_ADDRESS, + remotePort: LOCAL_PORT, + pairingOpen: false, + trustedPeers: [], + runtime: RUNTIME_INFO, + identityFingerprint: SERVER_FINGERPRINT, + lastError: null, + }); + expect(yield* service.readyEndpoint).toEqual( + Option.some({ address: SERVER_ADDRESS, port: LOCAL_PORT }), + ); + expect((yield* readPersistedState).enabled).toBe(true); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("a connection code carries a one-time pairing token and opens the listener", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const pairingLinks = yield* PairingGrantStore.PairingGrantStore; + const { serve: locked } = yield* startEnabled; + + const opened = yield* watchState((state) => state.pairingOpen && state.status === "ready"); + const issuedAt = yield* Clock.currentTimeMillis; + const result = yield* service.createConnectionCode({}); + const payload = decodeTailcatConnectionCode(result.code); + + expect(result.code.startsWith("t3c://tailcat/")).toBe(true); + expect(payload).toEqual(result.payload); + expect(payload).toMatchObject({ + v: 1, + transport: "tailcat", + address: SERVER_ADDRESS, + port: LOCAL_PORT, + environmentId: ENVIRONMENT_ID, + name: DESCRIPTOR.label, + serverVersion: DESCRIPTOR.serverVersion, + expiresAt: result.expiresAt, + }); + expect(Date.parse(result.expiresAt) - issuedAt).toBe( + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS * 1_000, + ); + const link = (yield* pairingLinks.listActive()).find( + (candidate) => candidate.id === result.pairingLinkId, + ); + expect(link?.subject).toBe(TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT); + expect(link?.credential).toBe(payload.pairingToken); + + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const open = yield* Queue.take(fake.serves); + const state = yield* Fiber.join(opened); + + expect(open.options.allow).toEqual({ _tag: "all" }); + expect(yield* locked.isRunning).toBe(false); + expect(state.address).toBe(SERVER_ADDRESS); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("relocks the listener once the connection code expires", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + yield* startEnabled; + yield* service.createConnectionCode({ ttlSeconds: 60 }); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const open = yield* Queue.take(fake.serves); + expect(open.options.allow).toEqual({ _tag: "all" }); + + const closed = yield* watchState((state) => !state.pairingOpen && state.status === "ready"); + // Past the code's expiry (plus the service's grace second), then the debounce. + yield* TestClock.adjust(Duration.seconds(61)); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const relocked = yield* Queue.take(fake.serves); + const state = yield* Fiber.join(closed); + + expect(relocked.options.allow).toEqual({ _tag: "keys", nodeKeys: [] }); + expect(yield* open.isRunning).toBe(false); + expect(state.pairingOpen).toBe(false); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("trusted peers are persisted, admitted on relock, and revocable", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const { serve: locked } = yield* startEnabled; + const sessionId = AuthSessionId.make("session-julius-iphone"); + + yield* service.recordTrustedPeer({ + nodeKey: PEER_NODE_KEY, + label: " Julius iPhone ", + sessionId, + }); + const recorded = yield* service.state; + expect(recorded.trustedPeers).toHaveLength(1); + const peer = recorded.trustedPeers[0]!; + expect(peer).toMatchObject({ + nodeKey: PEER_NODE_KEY, + label: "Julius iPhone", + sessionIds: [sessionId], + }); + expect((yield* readPersistedState).trustedPeers).toEqual([peer]); + + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const admitting = yield* Queue.take(fake.serves); + expect(admitting.options.allow).toEqual({ _tag: "keys", nodeKeys: [PEER_NODE_KEY] }); + expect(yield* locked.isRunning).toBe(false); + + const revoked = yield* service.revokeTrustedPeer(peer.id); + expect(revoked.trustedPeers).toEqual([]); + expect((yield* readPersistedState).trustedPeers).toEqual([]); + + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const relocked = yield* Queue.take(fake.serves); + expect(relocked.options.allow).toEqual({ _tag: "keys", nodeKeys: [] }); + expect(yield* admitting.isRunning).toBe(false); + + const missing = yield* Effect.flip(service.revokeTrustedPeer(peer.id)); + expect(missing.code).toBe("unknown"); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("an unexpected listener exit is reported and retried after the backoff", () => + Effect.gen(function* () { + const fake = yield* FakeTailcat; + const { serve: first } = yield* startEnabled; + + const failed = yield* watchState((state) => state.status === "error"); + yield* Deferred.succeed(first.exit, Option.some(1)); + const errorState = yield* Fiber.join(failed); + + expect(errorState.lastError).toMatchObject({ code: "process-exited" }); + expect(errorState.lastError?.message).toContain("exited (1)"); + // A transient failure keeps the stable address; only permanent ones drop it. + expect(errorState.address).toBe(SERVER_ADDRESS); + + const restarted = yield* watchState( + (state) => state.status === "ready" && state.lastError === null, + ); + yield* TestClock.adjust(FIRST_RETRY_BACKOFF_MAX); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const second = yield* Queue.take(fake.serves); + const readyState = yield* Fiber.join(restarted); + + expect(second.options.allow).toEqual({ _tag: "keys", nodeKeys: [] }); + expect(readyState.address).toBe(SERVER_ADDRESS); + // The identity file survived the restart, so no new address was minted. + expect(yield* Ref.get(fake.identityGenerations)).toBe(1); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("disabling stops the listener and reports disabled", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const { serve } = yield* startEnabled; + + const disabled = yield* watchState((state) => state.status === "disabled"); + const returned = yield* service.setEnabled(false); + expect(returned.enabled).toBe(false); + + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const state = yield* Fiber.join(disabled); + + expect(state).toMatchObject({ + enabled: false, + status: "disabled", + address: null, + identityFingerprint: null, + lastError: null, + }); + expect(yield* serve.isRunning).toBe(false); + expect(yield* service.readyEndpoint).toEqual(Option.none()); + expect((yield* readPersistedState).enabled).toBe(false); + expect(yield* Queue.size(fake.serves)).toBe(0); + }).pipe(Effect.provide(makeTestLayer())), + ); +}); diff --git a/apps/server/src/tailcat/TailcatRemoteAccess.ts b/apps/server/src/tailcat/TailcatRemoteAccess.ts new file mode 100644 index 000000000000..b1859eb11dcf --- /dev/null +++ b/apps/server/src/tailcat/TailcatRemoteAccess.ts @@ -0,0 +1,784 @@ +import { + AuthStandardClientScopes, + type AuthSessionId, + FEDERATION_PEER_CODE_PAIRING_SUBJECT, + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS, + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + type TailcatAddress, + type TailcatConnectionCodeResult, + type TailcatCreateConnectionCodeInput, + type TailcatFailure, + type TailcatFailureCode, + type TailcatNodeKey, + TailcatRemoteAccessError, + type TailcatRemoteAccessState, + type TailcatRuntimeInfo, + type TailcatServeStatus, + TailcatTrustedPeer, +} from "@t3tools/contracts"; +import { encodeTailcatConnectionCode } from "@t3tools/shared/t3ConnectionCode"; +import { decodeTailcatAddress, tailcatKeyFingerprint } from "@t3tools/tailcat/address"; +import { tailcatBackoffDelayMs } from "@t3tools/tailcat/backoff"; +import { + type TailcatRuntimeError, + isTailcatRuntimeError, + tailcatFailureCode, +} from "@t3tools/tailcat/errors"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "../auth/PairingGrantStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; + +/** + * TailcatRemoteAccess makes this environment reachable over Tailcat. + * + * It owns one `tailcat serve` child that fronts the server's loopback listener, + * the server's Tailcat identity (a key file in the secrets directory, so the + * address is stable across restarts), and the list of trusted peers. Tailcat's + * CLI takes its allowlist at startup, so the listener is restarted whenever the + * trusted set changes: + * + * - locked: `--allow=` (or `none` while nobody is trusted) + * - open: no allowlist while a connection code is active, so a new device + * can reach the T3 pairing endpoint; T3 auth still gates everything + * + * Pairing over Tailcat is the ordinary T3 pairing flow. The token exchange that + * consumes a connection code reports the client's node key here, which adds it + * to the trusted set; the next relock only admits trusted keys. + */ + +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); + +export const TAILCAT_REMOTE_ACCESS_STATE_FILE = "tailcat-remote-access.json"; +export const TAILCAT_SERVER_IDENTITY_FILE = "tailcat-server-identity.private.json"; +const RELOCK_DEBOUNCE = Duration.millis(1_500); +const EXPIRY_GRACE = Duration.seconds(1); + +/** Pairing-link subjects whose active links open the Tailcat pairing window. */ +const PAIRING_WINDOW_SUBJECTS: ReadonlySet = new Set([ + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + FEDERATION_PEER_CODE_PAIRING_SUBJECT, +]); + +const PersistedTailcatRemoteAccess = Schema.Struct({ + version: Schema.Literal(1), + enabled: Schema.Boolean, + trustedPeers: Schema.Array(TailcatTrustedPeer), +}); +type PersistedTailcatRemoteAccess = typeof PersistedTailcatRemoteAccess.Type; + +const PersistedTailcatRemoteAccessJson = Schema.fromJsonString(PersistedTailcatRemoteAccess); +const decodePersistedState = Schema.decodeUnknownEffect(PersistedTailcatRemoteAccessJson); +const encodePersistedState = Schema.encodeEffect(PersistedTailcatRemoteAccessJson); + +const EMPTY_PERSISTED_STATE: PersistedTailcatRemoteAccess = { + version: 1, + enabled: false, + trustedPeers: [], +}; + +export class TailcatRemoteAccess extends Context.Service< + TailcatRemoteAccess, + { + readonly state: Effect.Effect; + readonly changes: Stream.Stream; + /** The address and port peers should dial while Tailcat access is enabled and serving. */ + readonly readyEndpoint: Effect.Effect< + Option.Option<{ readonly address: TailcatAddress; readonly port: number }> + >; + /** Binds the service to the server's listening port and starts reconciling. */ + readonly start: (input: { readonly localPort: number }) => Effect.Effect; + readonly setEnabled: ( + enabled: boolean, + ) => Effect.Effect; + readonly createConnectionCode: ( + input: TailcatCreateConnectionCodeInput, + ) => Effect.Effect; + /** Called by the token exchange that consumed a Tailcat connection code. */ + readonly recordTrustedPeer: (input: { + readonly nodeKey: TailcatNodeKey; + readonly label: string | undefined; + /** The T3 session issued alongside the pairing, revoked with the peer. */ + readonly sessionId?: AuthSessionId; + }) => Effect.Effect; + readonly revokeTrustedPeer: ( + peerId: string, + ) => Effect.Effect; + readonly renameTrustedPeer: (input: { + readonly peerId: string; + readonly label: string; + }) => Effect.Effect; + readonly regenerateIdentity: Effect.Effect; + } +>()("t3/tailcat/TailcatRemoteAccess") {} + +interface RunningServe { + readonly scope: Scope.Closeable; + readonly handle: TailcatRuntime.TailcatServeHandle; + readonly allow: TailcatRuntime.TailcatAllowPolicy; + readonly generation: number; +} + +interface RuntimeState { + readonly localPort: number | null; + readonly running: RunningServe | null; + readonly status: TailcatServeStatus; + readonly address: TailcatAddress | null; + readonly pairingOpen: boolean; + readonly failures: number; + readonly lastError: TailcatFailure | null; + readonly runtime: TailcatRuntimeInfo | null; + readonly generation: number; +} + +const INITIAL_RUNTIME_STATE: RuntimeState = { + localPort: null, + running: null, + status: "disabled", + address: null, + pairingOpen: false, + failures: 0, + lastError: null, + runtime: null, + generation: 0, +}; + +function allowPolicyEquals( + left: TailcatRuntime.TailcatAllowPolicy, + right: TailcatRuntime.TailcatAllowPolicy, +): boolean { + if (left._tag !== right._tag) return false; + if (left._tag === "keys" && right._tag === "keys") { + const a = [...left.nodeKeys].sort(); + const b = [...right.nodeKeys].sort(); + return a.length === b.length && a.every((key, index) => key === b[index]); + } + return true; +} + +function failureOf( + error: TailcatRuntimeError | TailcatRemoteAccessError, + at: string, +): TailcatFailure { + if (isTailcatRuntimeError(error)) { + return { code: tailcatFailureCode(error), message: error.message, at }; + } + return { code: error.code, message: error.message, at }; +} + +const isPermanentFailure = (code: TailcatFailureCode): boolean => + code === "binary-missing" || + code === "binary-not-executable" || + code === "version-incompatible" || + code === "identity-failed"; + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const runtime = yield* TailcatRuntime.TailcatRuntime; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairingLinks = yield* PairingGrantStore.PairingGrantStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const serviceScope = yield* Scope.Scope; + + const statePath = path.join(config.stateDir, TAILCAT_REMOTE_ACCESS_STATE_FILE); + const identityPath = path.join(config.secretsDir, TAILCAT_SERVER_IDENTITY_FILE); + + const now = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const readPersisted = Effect.gen(function* () { + const raw = yield* fileSystem.readFileString(statePath).pipe(Effect.option); + if (Option.isNone(raw) || raw.value.trim().length === 0) { + return EMPTY_PERSISTED_STATE; + } + return yield* decodePersistedState(raw.value).pipe( + Effect.catch((cause) => + Effect.logWarning("Tailcat remote access state is unreadable; starting from defaults.", { + statePath, + cause, + }).pipe(Effect.as(EMPTY_PERSISTED_STATE)), + ), + ); + }); + + const persisted = yield* Ref.make(yield* readPersisted); + const runtimeState = yield* Ref.make(INITIAL_RUNTIME_STATE); + const signals = yield* Queue.unbounded<"reconcile">(); + const expiryTimer = yield* Ref.make>>(Option.none()); + const retryTimer = yield* Ref.make>>(Option.none()); + + const persistError = (cause: unknown) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not save Tailcat remote access settings: ${String(cause)}`, + }); + + const writePersisted = (next: PersistedTailcatRemoteAccess) => + encodePersistedState(next).pipe( + Effect.flatMap((contents) => + writeFileStringAtomically({ filePath: statePath, contents: `${contents}\n` }), + ), + Effect.mapError(persistError), + Effect.andThen(Ref.set(persisted, next)), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const buildState = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + const fingerprint = + current.address === null + ? null + : Result.match(decodeTailcatAddress(current.address), { + onFailure: () => null, + onSuccess: (decoded) => tailcatKeyFingerprint(decoded.serverNodeKey), + }); + return { + enabled: saved.enabled, + status: current.status, + address: current.address, + remotePort: current.localPort, + pairingOpen: current.pairingOpen, + trustedPeers: saved.trustedPeers, + runtime: current.runtime, + identityFingerprint: fingerprint, + lastError: current.lastError, + updatedAt: yield* now, + } satisfies TailcatRemoteAccessState; + }); + + const published = yield* SubscriptionRef.make(yield* buildState); + const publish = buildState.pipe(Effect.flatMap((state) => SubscriptionRef.set(published, state))); + + const signalReconcile = Queue.offer(signals, "reconcile").pipe(Effect.asVoid); + + const listActiveConnectionCodes = pairingLinks.listActive().pipe( + Effect.map((links) => links.filter((link) => PAIRING_WINDOW_SUBJECTS.has(link.subject))), + Effect.catch((cause) => + Effect.logWarning("Could not list Tailcat connection codes; treating none as active.", { + cause, + }).pipe(Effect.as([])), + ), + ); + + /** + * The pairing window is derived, never stored: it is open exactly while an + * unconsumed, unexpired connection code exists. Expiry does not emit a store + * event, so a timer re-evaluates at the earliest expiry. + */ + const refreshPairingWindow = Effect.gen(function* () { + const active = yield* listActiveConnectionCodes; + const open = active.length > 0; + yield* Option.match(yield* Ref.getAndSet(expiryTimer, Option.none()), { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber), + }); + if (open) { + const currentMs = yield* DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + const earliestExpiry = Math.min( + ...active.map((link) => DateTime.toEpochMillis(link.expiresAt)), + ); + const delayMs = Math.max(0, earliestExpiry - currentMs) + Duration.toMillis(EXPIRY_GRACE); + const fiber = yield* Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.andThen(signalReconcile), + Effect.forkIn(serviceScope), + ); + yield* Ref.set(expiryTimer, Option.some(fiber)); + } + const previous = yield* Ref.get(runtimeState); + yield* Ref.update(runtimeState, (current) => ({ ...current, pairingOpen: open })); + return previous.pairingOpen !== open; + }); + + const ensureIdentity = Effect.gen(function* () { + const exists = yield* fileSystem.exists(identityPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return; + } + yield* Effect.logInfo("Creating the Tailcat server identity.", { identityPath }); + yield* runtime.generateServerIdentity({ keyPath: identityPath }); + }); + + const stopRunning = Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running === null) { + return; + } + yield* Ref.update(runtimeState, (state) => ({ ...state, running: null })); + yield* Scope.close(current.running.scope, Exit.void).pipe(Effect.ignore); + yield* Effect.logInfo("Tailcat listener stopped.", { pid: current.running.handle.pid }); + }); + + const desiredAllowPolicy = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + if (current.pairingOpen) { + return { _tag: "all" } as const satisfies TailcatRuntime.TailcatAllowPolicy; + } + return { + _tag: "keys", + nodeKeys: saved.trustedPeers.map((peer) => peer.nodeKey), + } as const satisfies TailcatRuntime.TailcatAllowPolicy; + }); + + const scheduleRetry = (failures: number) => + Effect.gen(function* () { + yield* Option.match(yield* Ref.getAndSet(retryTimer, Option.none()), { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber), + }); + const delayMs = tailcatBackoffDelayMs(failures, yield* Random.next); + const fiber = yield* Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.andThen(signalReconcile), + Effect.forkIn(serviceScope), + ); + yield* Ref.set(retryTimer, Option.some(fiber)); + }); + + const recordFailure = (error: TailcatRuntimeError | TailcatRemoteAccessError) => + Effect.gen(function* () { + const at = yield* now; + const failure = failureOf(error, at); + const permanent = isPermanentFailure(failure.code); + const next = yield* Ref.updateAndGet(runtimeState, (state) => ({ + ...state, + status: permanent ? ("unavailable" as const) : ("error" as const), + address: permanent ? null : state.address, + failures: state.failures + 1, + lastError: failure, + })); + yield* Effect.logWarning("Tailcat listener failed.", { + code: failure.code, + message: failure.message, + failures: next.failures, + permanent, + }); + if (!permanent) { + yield* scheduleRetry(next.failures); + } + }); + + const startServe = (allow: TailcatRuntime.TailcatAllowPolicy, localPort: number) => + Effect.gen(function* () { + const generation = (yield* Ref.get(runtimeState)).generation + 1; + yield* Ref.update(runtimeState, (state) => ({ + ...state, + generation, + status: state.address === null ? ("starting" as const) : ("restarting" as const), + })); + yield* publish; + const info = yield* runtime.resolve; + yield* Ref.update(runtimeState, (state) => ({ ...state, runtime: info })); + yield* ensureIdentity.pipe( + Effect.mapError( + (error) => + new TailcatRemoteAccessError({ + code: "identity-failed", + message: `Could not prepare the Tailcat identity: ${error.message}`, + }), + ), + ); + const scope = yield* Scope.make("sequential"); + const handle = yield* runtime.serve({ keyPath: identityPath, localPort, allow }).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + ); + const running: RunningServe = { scope, handle, allow, generation }; + yield* Ref.update(runtimeState, (state) => ({ + ...state, + running, + status: "ready" as const, + address: handle.address, + failures: 0, + lastError: null, + })); + yield* Effect.logInfo("Tailcat listener ready.", { + pid: handle.pid, + localPort, + allow: allow._tag, + trustedPeerCount: allow._tag === "keys" ? allow.nodeKeys.length : null, + }); + // Watch for an unexpected exit. A stop we initiated replaces `running` + // first, so only a still-current generation schedules a restart. + yield* handle.exit.pipe( + Effect.flatMap((exitCode) => + Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running?.generation !== generation) { + return; + } + const recentOutput = yield* handle.recentOutput; + yield* Ref.update(runtimeState, (state) => ({ ...state, running: null })); + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + yield* recordFailure( + new TailcatRemoteAccessError({ + code: "process-exited", + message: + recentOutput.at(-1) !== undefined + ? `The Tailcat listener exited (${Option.getOrNull(exitCode) ?? "signal"}): ${recentOutput.at(-1)}` + : `The Tailcat listener exited unexpectedly (${Option.getOrNull(exitCode) ?? "signal"}).`, + }), + ); + yield* publish; + }), + ), + Effect.forkIn(serviceScope), + ); + }); + + const reconcile = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + if (current.localPort === null) { + return; + } + if (!saved.enabled) { + yield* stopRunning; + yield* Ref.update(runtimeState, (state) => ({ + ...state, + status: "disabled" as const, + address: null, + failures: 0, + lastError: null, + })); + return; + } + const allow = yield* desiredAllowPolicy; + if (current.running !== null && allowPolicyEquals(current.running.allow, allow)) { + return; + } + if (current.running !== null) { + yield* Effect.logInfo("Tailcat allowlist changed; restarting the listener.", { + allow: allow._tag, + }); + yield* stopRunning; + } + yield* startServe(allow, current.localPort).pipe( + Effect.catch((error) => + isTailcatRuntimeError(error) || isTailcatRemoteAccessError(error) + ? recordFailure(error) + : Effect.die(error), + ), + ); + }); + + const reconcileLoop = Effect.gen(function* () { + for (;;) { + yield* Queue.take(signals); + // Coalesce bursts (a consumed code plus its recorded peer arrive together). + yield* Effect.sleep(RELOCK_DEBOUNCE); + yield* Queue.clear(signals); + yield* refreshPairingWindow; + yield* reconcile; + yield* publish; + } + }); + yield* reconcileLoop.pipe(Effect.forkIn(serviceScope)); + + yield* pairingLinks.streamChanges.pipe( + Stream.filter( + (change) => + change.type === "pairingLinkRemoved" || + PAIRING_WINDOW_SUBJECTS.has(change.pairingLink.subject), + ), + Stream.runForEach(() => signalReconcile), + Effect.forkIn(serviceScope), + ); + + yield* Scope.addFinalizer( + serviceScope, + Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running !== null) { + yield* Scope.close(current.running.scope, Exit.void).pipe(Effect.ignore); + } + }), + ); + + const requireEnabledAndReady = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + if (!saved.enabled) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "Enable Tailcat access before creating a connection code.", + }); + } + if (current.address === null || current.localPort === null) { + return yield* new TailcatRemoteAccessError({ + code: current.lastError?.code ?? "startup-failed", + message: current.lastError?.message ?? "Tailcat is still starting. Try again in a moment.", + }); + } + return { address: current.address, localPort: current.localPort }; + }); + + const createConnectionCode: TailcatRemoteAccess["Service"]["createConnectionCode"] = Effect.fn( + "TailcatRemoteAccess.createConnectionCode", + )(function* (input) { + const ready = yield* requireEnabledAndReady; + const descriptor = yield* serverEnvironment.getDescriptor; + const ttlSeconds = input.ttlSeconds ?? TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS; + const issued = yield* environmentAuth + .createPairingLink({ + scopes: AuthStandardClientScopes, + subject: TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + label: input.label ?? "Tailcat connection code", + ttl: Duration.seconds(ttlSeconds), + }) + .pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not issue a pairing credential: ${cause.message}`, + }), + ), + ); + const expiresAt = DateTime.formatIso(issued.expiresAt); + const payload = { + v: 1 as const, + transport: "tailcat" as const, + address: ready.address, + port: ready.localPort, + environmentId: descriptor.environmentId, + name: descriptor.label, + serverVersion: descriptor.serverVersion, + pairingToken: issued.credential, + expiresAt, + }; + // The window opens through the pairing-link change stream; nudge it so the + // listener reopens without waiting for the debounce to notice on its own. + yield* signalReconcile; + yield* Effect.logInfo("Tailcat connection code issued.", { + pairingLinkId: issued.id, + expiresAt, + }); + return { + code: encodeTailcatConnectionCode(payload), + payload, + pairingLinkId: issued.id, + expiresAt, + } satisfies TailcatConnectionCodeResult; + }); + + const setEnabled: TailcatRemoteAccess["Service"]["setEnabled"] = Effect.fn( + "TailcatRemoteAccess.setEnabled", + )(function* (enabled) { + const saved = yield* Ref.get(persisted); + if (saved.enabled !== enabled) { + yield* writePersisted({ ...saved, enabled }); + yield* Effect.logInfo(enabled ? "Tailcat access enabled." : "Tailcat access disabled."); + } + if (enabled) { + // Clear a stale permanent failure so a retry actually happens after the + // user installed or repaired the runtime. + yield* Ref.update(runtimeState, (state) => ({ ...state, failures: 0 })); + yield* runtime.refresh.pipe(Effect.ignore); + } + yield* signalReconcile; + yield* publish; + return yield* SubscriptionRef.get(published); + }); + + const recordTrustedPeer: TailcatRemoteAccess["Service"]["recordTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.recordTrustedPeer", + )(function* (input) { + const saved = yield* Ref.get(persisted); + const at = yield* now; + const existing = saved.trustedPeers.find((peer) => peer.nodeKey === input.nodeKey); + const label = input.label?.trim() || existing?.label || "Paired device"; + const sessionIds = input.sessionId === undefined ? [] : [input.sessionId]; + const peers = existing + ? saved.trustedPeers.map((peer) => + peer.nodeKey === input.nodeKey + ? { + ...peer, + label, + lastSeenAt: at, + sessionIds: [ + ...peer.sessionIds, + ...sessionIds.filter((sessionId) => !peer.sessionIds.includes(sessionId)), + ], + } + : peer, + ) + : [ + ...saved.trustedPeers, + { + id: yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not allocate a peer id: ${String(cause)}`, + }), + ), + ), + nodeKey: input.nodeKey, + label, + createdAt: at, + lastSeenAt: at, + sessionIds, + }, + ]; + yield* writePersisted({ ...saved, trustedPeers: peers }); + yield* Effect.logInfo(existing ? "Tailcat peer re-paired." : "Tailcat peer trusted.", { + fingerprint: tailcatKeyFingerprint(input.nodeKey), + label, + }); + yield* signalReconcile; + yield* publish; + }); + + const revokeTrustedPeer: TailcatRemoteAccess["Service"]["revokeTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.revokeTrustedPeer", + )(function* (peerId) { + const saved = yield* Ref.get(persisted); + const peer = saved.trustedPeers.find((candidate) => candidate.id === peerId); + if (peer === undefined) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + yield* writePersisted({ + ...saved, + trustedPeers: saved.trustedPeers.filter((candidate) => candidate.id !== peerId), + }); + yield* Effect.forEach( + peer.sessionIds, + (sessionId) => environmentAuth.revokeSession(sessionId).pipe(Effect.ignore), + { discard: true }, + ); + yield* Effect.logInfo("Tailcat peer revoked.", { + fingerprint: tailcatKeyFingerprint(peer.nodeKey), + revokedSessions: peer.sessionIds.length, + }); + yield* signalReconcile; + yield* publish; + return yield* SubscriptionRef.get(published); + }); + + const renameTrustedPeer: TailcatRemoteAccess["Service"]["renameTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.renameTrustedPeer", + )(function* ({ peerId, label }) { + const saved = yield* Ref.get(persisted); + if (!saved.trustedPeers.some((peer) => peer.id === peerId)) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + const trimmed = label.trim(); + if (trimmed.length === 0) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "A device name cannot be empty.", + }); + } + yield* writePersisted({ + ...saved, + trustedPeers: saved.trustedPeers.map((peer) => + peer.id === peerId ? { ...peer, label: trimmed } : peer, + ), + }); + yield* publish; + return yield* SubscriptionRef.get(published); + }); + + const regenerateIdentity: TailcatRemoteAccess["Service"]["regenerateIdentity"] = Effect.gen( + function* () { + yield* stopRunning; + yield* fileSystem.remove(identityPath, { force: true }).pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "identity-failed", + message: `Could not remove the previous Tailcat identity: ${String(cause)}`, + }), + ), + ); + yield* Ref.update(runtimeState, (state) => ({ + ...state, + address: null, + failures: 0, + lastError: null, + })); + yield* Effect.logInfo("Tailcat identity regenerated; connected devices must re-pair."); + yield* signalReconcile; + yield* publish; + return yield* SubscriptionRef.get(published); + }, + ).pipe(Effect.withSpan("TailcatRemoteAccess.regenerateIdentity")); + + const start: TailcatRemoteAccess["Service"]["start"] = Effect.fn("TailcatRemoteAccess.start")( + function* ({ localPort }) { + const current = yield* Ref.get(runtimeState); + if (current.localPort !== null) { + return; + } + yield* Ref.update(runtimeState, (state) => ({ ...state, localPort })); + if (config.tailcatEnabled === true) { + const saved = yield* Ref.get(persisted); + if (!saved.enabled) { + yield* writePersisted({ ...saved, enabled: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the Tailcat enable flag.", { error }), + ), + ); + } + } + yield* signalReconcile; + }, + ); + + return TailcatRemoteAccess.of({ + readyEndpoint: Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + const saved = yield* Ref.get(persisted); + // Only while the listener is up (or bouncing for a relock): a failed or + // unavailable listener must not be advertised in codes. + const serving = current.status === "ready" || current.status === "restarting"; + return saved.enabled && serving && current.address !== null && current.localPort !== null + ? Option.some({ address: current.address, port: current.localPort }) + : Option.none(); + }), + state: SubscriptionRef.get(published), + changes: SubscriptionRef.changes(published), + start, + setEnabled, + createConnectionCode, + recordTrustedPeer, + revokeTrustedPeer, + renameTrustedPeer, + regenerateIdentity, + }); +}); + +export const layer = Layer.effect(TailcatRemoteAccess, make); diff --git a/apps/server/src/tailcat/TailcatRuntimeLive.ts b/apps/server/src/tailcat/TailcatRuntimeLive.ts new file mode 100644 index 000000000000..5bd5fa4d3fc6 --- /dev/null +++ b/apps/server/src/tailcat/TailcatRuntimeLive.ts @@ -0,0 +1,45 @@ +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; + +/** + * Resolves the Tailcat executable for this server process. Preference order: + * an explicit `T3CODE_TAILCAT_BINARY` override, the path the desktop app hands + * over in its bootstrap (the binary it ships), the copy bundled next to the CLI + * bundle (`dist/tailcat//`), the monorepo's fetched runtime, and + * finally a `tailcat` already on PATH. + */ +export const layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + const overridePath = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + const moduleDirectory = import.meta.dirname; + const bundledCandidates = [ + ...(config.tailcatBinaryPath === undefined ? [] : [config.tailcatBinaryPath]), + ...TailcatRuntime.bundledTailcatCandidates({ + platform, + architecture, + joinPath: path.join, + moduleDirectory, + repoRootCandidates: [ + path.resolve(moduleDirectory, "../../../.."), + path.resolve(moduleDirectory, "../../.."), + ], + }), + ]; + return TailcatRuntime.layer({ + resolution: { + overridePath, + bundledCandidates, + allowSystem: true, + }, + }); + }), +); diff --git a/apps/server/src/tailcat/http.ts b/apps/server/src/tailcat/http.ts new file mode 100644 index 000000000000..1ca1cefe1c78 --- /dev/null +++ b/apps/server/src/tailcat/http.ts @@ -0,0 +1,48 @@ +import { AuthAccessReadScope, AuthAccessWriteScope, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; +import * as TailcatRemoteAccess from "./TailcatRemoteAccess.ts"; + +/** HTTP surface for the CLI; the UI uses the equivalent RPC methods. */ +export const tailcatHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "tailcat", + Effect.fnUntraced(function* (handlers) { + const remoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; + return handlers + .handle( + "remoteAccess", + Effect.fn("environment.tailcat.remoteAccess")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessReadScope); + return yield* remoteAccess.state; + }), + ) + .handle( + "setRemoteAccess", + Effect.fn("environment.tailcat.setRemoteAccess")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + return yield* remoteAccess.setEnabled(args.payload.enabled); + }), + ) + .handle( + "createConnectionCode", + Effect.fn("environment.tailcat.createConnectionCode")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + return yield* remoteAccess.createConnectionCode(args.payload); + }), + ) + .handle( + "revokeTrustedPeer", + Effect.fn("environment.tailcat.revokeTrustedPeer")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + return yield* remoteAccess.revokeTrustedPeer(args.payload.peerId); + }), + ); + }), +); diff --git a/apps/server/src/tailcat/startupOutput.ts b/apps/server/src/tailcat/startupOutput.ts new file mode 100644 index 000000000000..cce3d7a36ba2 --- /dev/null +++ b/apps/server/src/tailcat/startupOutput.ts @@ -0,0 +1,32 @@ +import type { TailcatConnectionCodeResult, TailcatRemoteAccessState } from "@t3tools/contracts"; + +import { renderTerminalQrCode } from "../startupAccess.ts"; + +/** + * Terminal output for `t3 serve --tailcat`: the connection code a client pastes + * into Add Environment, plus a QR for the mobile app. The code embeds a + * single-use pairing credential, so it is shown exactly like the pairing URL. + */ +export function formatTailcatHeadlessOutput( + state: TailcatRemoteAccessState, + issued: TailcatConnectionCodeResult, +): string { + const path = + state.runtime === null + ? "unknown" + : `${state.runtime.source} ${state.runtime.version ?? "?"} (${state.runtime.executablePath})`; + return [ + "", + "Tailcat remote access is ready.", + `Tailcat address: ${state.address ?? "unknown"}`, + `Tailcat runtime: ${path}`, + `Connection code (expires ${issued.expiresAt}, single use):`, + issued.code, + "", + renderTerminalQrCode(issued.code), + "", + "Paste the code in T3 Code under Add Environment → Tailcat, or scan it with the mobile app.", + "Trusted devices stay connected after the code expires; issue a new code per device.", + "", + ].join("\n"); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 839937cf2ea7..030bee1f7a1a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -59,6 +59,7 @@ import { AssetWorkspaceContextResolutionError, RpcClientId, EnvironmentAuthorizationError, + FEDERATION_SESSION_SUBJECT_PREFIX, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -111,6 +112,8 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as FederationService from "./federation/FederationService.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -523,6 +526,8 @@ const makeWsRpcLayer = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const tailcatRemoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const federation = yield* FederationService.FederationService; const canReplayPersistedRange = Effect.fnUntraced(function* ( afterSequence: number, headSequence: number, @@ -2670,6 +2675,114 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + // Tailcat remote access: the Tailcat listener this environment exposes. + [WS_METHODS.tailcatSubscribeRemoteAccess]: (_input) => + observeRpcStream( + WS_METHODS.tailcatSubscribeRemoteAccess, + Stream.unwrap( + Effect.map(tailcatRemoteAccess.state, (latest) => + Stream.concat(Stream.make(latest), tailcatRemoteAccess.changes), + ), + ), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatSetRemoteAccessEnabled]: (input) => + observeRpcEffect( + WS_METHODS.tailcatSetRemoteAccessEnabled, + tailcatRemoteAccess.setEnabled(input.enabled), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatCreateConnectionCode]: (input) => + observeRpcEffect( + WS_METHODS.tailcatCreateConnectionCode, + tailcatRemoteAccess.createConnectionCode(input), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRevokeTrustedPeer]: (input) => + observeRpcEffect( + WS_METHODS.tailcatRevokeTrustedPeer, + tailcatRemoteAccess.revokeTrustedPeer(input.peerId), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRenameTrustedPeer]: (input) => + observeRpcEffect( + WS_METHODS.tailcatRenameTrustedPeer, + tailcatRemoteAccess.renameTrustedPeer(input), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRegenerateIdentity]: (_input) => + observeRpcEffect( + WS_METHODS.tailcatRegenerateIdentity, + tailcatRemoteAccess.regenerateIdentity, + { "rpc.aggregate": "tailcat" }, + ), + // Federation: peers this environment trusts and runs it delegated to them. + [WS_METHODS.federationSubscribePeers]: (_input) => + observeRpcStream( + WS_METHODS.federationSubscribePeers, + Stream.unwrap( + Effect.map(federation.snapshot, (latest) => + Stream.concat(Stream.make(latest), federation.changes), + ), + ), + { "rpc.aggregate": "federation" }, + ), + [WS_METHODS.federationCreatePeerCode]: (input) => + observeRpcEffect(WS_METHODS.federationCreatePeerCode, federation.createPeerCode(input), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationAddPeer]: (input) => + observeRpcEffect(WS_METHODS.federationAddPeer, federation.addPeer(input), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationRemovePeer]: (input) => + observeRpcEffect(WS_METHODS.federationRemovePeer, federation.removePeer(input.peerId), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationRefreshPeer]: (input) => + observeRpcEffect(WS_METHODS.federationRefreshPeer, federation.refreshPeer(input.peerId), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationListRemoteProjects]: (input) => + observeRpcEffect( + WS_METHODS.federationListRemoteProjects, + federation.listRemoteProjects(input.peerId), + { "rpc.aggregate": "federation" }, + ), + [WS_METHODS.federationStartRemoteRun]: (input) => + observeRpcEffect(WS_METHODS.federationStartRemoteRun, federation.startRemoteRun(input), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationCancelRemoteRun]: (input) => + observeRpcEffect( + WS_METHODS.federationCancelRemoteRun, + federation.cancelRemoteRun(input), + { + "rpc.aggregate": "federation", + }, + ), + [WS_METHODS.federationSubscribeRemoteRuns]: (_input) => + observeRpcStream( + WS_METHODS.federationSubscribeRemoteRuns, + Stream.unwrap( + Effect.map(federation.remoteRuns, (latest) => + Stream.concat(Stream.make(latest), federation.remoteRunChanges), + ), + ), + { "rpc.aggregate": "federation" }, + ), + [WS_METHODS.federationDescribeRemoteArtifacts]: (input) => + observeRpcEffect( + WS_METHODS.federationDescribeRemoteArtifacts, + federation.describeRemoteArtifacts(input), + { "rpc.aggregate": "federation" }, + ), + [WS_METHODS.federationFetchRemoteArtifact]: (input) => + observeRpcEffect( + WS_METHODS.federationFetchRemoteArtifact, + federation.fetchRemoteArtifact(input), + { "rpc.aggregate": "federation" }, + ), }); }), ); @@ -2723,6 +2836,11 @@ export const websocketRpcRouteLayer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); + if (session.subject.startsWith(FEDERATION_SESSION_SUBJECT_PREFIX)) { + // Federation peers speak the versioned HTTP protocol only; the RPC + // surface is for this environment's own clients. + return yield* failEnvironmentAuthInvalid("invalid_credential"); + } const clientOrigin = readClientConnectionOrigin(request); const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); From d6e03350b81fece01e85403703730abf1e4c25f4 Mon Sep 17 00:00:00 2001 From: Bear Huddleston Date: Thu, 3 Sep 2026 19:08:16 -0500 Subject: [PATCH 05/12] feat(cli): add t3 remote tailcat and t3 peer commands Co-Authored-By: Claude Fable 5.1 --- apps/server/src/bin.ts | 4 + apps/server/src/cli/peer.test.ts | 617 +++++++++++++++++++++++++++++ apps/server/src/cli/peer.ts | 472 ++++++++++++++++++++++ apps/server/src/cli/remote.test.ts | 479 ++++++++++++++++++++++ apps/server/src/cli/remote.ts | 559 ++++++++++++++++++++++++++ 5 files changed, 2131 insertions(+) create mode 100644 apps/server/src/cli/peer.test.ts create mode 100644 apps/server/src/cli/peer.ts create mode 100644 apps/server/src/cli/remote.test.ts create mode 100644 apps/server/src/cli/remote.ts diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 0a2e4091560b..901355707f85 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -11,10 +11,12 @@ import { authCommand } from "./cli/auth.ts"; import { appCommand } from "./cli/app.ts"; import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; +import { peerCommand } from "./cli/peer.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; +import { remoteCommand } from "./cli/remote.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; @@ -56,6 +58,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, appCommand, pairCommand, + remoteCommand, + peerCommand, authCommand, projectCommand, serviceCommand, diff --git a/apps/server/src/cli/peer.test.ts b/apps/server/src/cli/peer.test.ts new file mode 100644 index 000000000000..44c4567387a1 --- /dev/null +++ b/apps/server/src/cli/peer.test.ts @@ -0,0 +1,617 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + FederationError, + FederationPeer, + type FederationPeerCodeResult, + type FederationRemoteRun, + type FederationRemoteRunsSnapshot, + type FederationRunEvent, + type FederationRunStatus, + type FederationSnapshot, + ProjectId, + ProviderInstanceId, + ThreadId, + WS_METHODS, + WsFederationAddPeerRpc, + WsFederationCreatePeerCodeRpc, + WsFederationListRemoteProjectsRpc, + WsFederationRemovePeerRpc, + WsFederationStartRemoteRunRpc, + WsFederationSubscribePeersRpc, + WsFederationSubscribeRemoteRunsRpc, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { cli } from "../bin.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { runningServerWsUrl } from "./peer.ts"; + +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); + +const provideCliTestLayers = (effect: Effect.Effect) => + Effect.provide(effect, Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)); + +// The test console is shared and accumulates across CLI runs, so each capture +// keeps only the entries its own run appended. +const captureNewLogLines = (args: ReadonlyArray) => + provideCliTestLayers( + Effect.gen(function* () { + const before = (yield* TestConsole.logLines).length; + yield* runCli(args); + return (yield* TestConsole.logLines) + .slice(before) + .filter((line): line is string => typeof line === "string"); + }), + ); + +/** Everything one CLI run logged, joined; `run --wait` logs as events arrive. */ +const captureStdout = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => lines.join("\n")); + +/** `--json` output has to be one clean entry: nothing logged before or after it. */ +const captureJson = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => { + assert.equal(lines.length, 1, `Expected exactly one JSON entry, got ${String(lines)}`); + return lines[0] ?? ""; + }); + +const flipCli = (args: ReadonlyArray) => + provideCliTestLayers(runCli(args).pipe(Effect.flip)); + +const expectShowHelpError = (error: unknown, expectedTag: string) => { + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + assert.equal(error.errors[0]?._tag, expectedTag); + return error.errors[0]; +}; + +const makeTempBaseDir = (prefix: string) => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), `t3-peer-cli-${prefix}-`)); + +const testDescriptor = { + environmentId: "peer-test-environment", + label: "peer-test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, +}; + +const descriptorRouteLayer = HttpRouter.add( + "GET", + "/.well-known/t3/environment", + HttpServerResponse.jsonUnsafe(testDescriptor), +); + +const makeCliTestServerConfig = (baseDir: string) => + Effect.gen(function* () { + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + return { + logLevel: "Warn", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + devAllowedOrigins: [], + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, + } satisfies ServerConfig.ServerConfig["Service"]; + }); + +const LOCAL_ID = EnvironmentId.make("env-local"); +const PEER_ID = EnvironmentId.make("env-peer-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-remote-1"); +const UPDATED_AT = "2026-06-21T08:30:00.000Z"; + +const peer: FederationPeer = { + peerId: PEER_ID, + label: "Build box", + publicKeyFingerprint: "SHA256:peer-fingerprint", + grantedScopes: ["environment.read", "projects.read", "runs.read"], + allowedScopes: ["environment.read", "projects.read", "runs.read", "runs.start"], + transport: { tailcat: { address: "tcPeerAddressAbCdEfGhIj", port: 3773 } }, + remoteServerVersion: "0.9.0", + remoteProtocolVersion: 1, + remoteCapabilities: ["hello", "projects.list", "runs.start"], + status: "online", + lastSeenAt: UPDATED_AT, + lastError: null, + createdAt: "2026-06-20T00:00:00.000Z", +}; + +const peersSnapshot: FederationSnapshot = { + environmentId: LOCAL_ID, + publicKeyFingerprint: "SHA256:local-fingerprint", + protocolVersion: 1, + peers: [peer], + updatedAt: UPDATED_AT, +}; + +const peerCode: FederationPeerCodeResult = { + code: "t3c://peer/test-code", + payload: { + v: 1, + kind: "peer", + protocolVersion: 1, + environmentId: LOCAL_ID, + publicKey: "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAtest\n-----END PUBLIC KEY-----", + label: "local", + transport: { tailcat: { address: "tcLocalAddressAbCdEfGhIj", port: 3773 } }, + token: "one-time-token", + scopes: ["environment.read", "projects.read", "runs.read"], + expiresAt: "2026-06-21T08:35:00.000Z", + }, + expiresAt: "2026-06-21T08:35:00.000Z", +}; + +const runEvent = (sequence: number, type: string, summary: string): FederationRunEvent => ({ + sequence, + at: UPDATED_AT, + type, + summary, +}); + +const remoteRun = ( + status: FederationRunStatus, + events: ReadonlyArray, + assistantPreview: string | null = null, +): FederationRemoteRun => ({ + peerId: PEER_ID, + peerLabel: peer.label, + run: { + environmentId: PEER_ID, + projectId: PROJECT_ID, + threadId: THREAD_ID, + turnId: null, + title: "Fix the flaky test", + status, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + requestedAt: UPDATED_AT, + startedAt: null, + completedAt: null, + assistantPreview, + turnCount: 0, + }, + events, + lastSyncedAt: null, + syncError: null, +}); + +const remoteRunsSnapshot = (run: FederationRemoteRun): FederationRemoteRunsSnapshot => ({ + runs: [run], + updatedAt: UPDATED_AT, +}); + +const turnStarted = runEvent(1, "turn.started", "Turn started"); +const assistantMessage = runEvent(2, "assistant.message", "Working on it"); +const turnCompleted = runEvent(3, "turn.completed", "Turn completed"); + +/** Only the federation RPCs the CLI drives; the client is built from the full group and dispatches by tag. */ +const PeerCliRpcs = RpcGroup.make( + WsFederationSubscribePeersRpc, + WsFederationCreatePeerCodeRpc, + WsFederationAddPeerRpc, + WsFederationRemovePeerRpc, + WsFederationListRemoteProjectsRpc, + WsFederationStartRemoteRunRpc, + WsFederationSubscribeRemoteRunsRpc, +); + +interface RecordedCall { + readonly method: string; + readonly input: unknown; +} + +const makeFederationHandlersLayer = (calls: Ref.Ref>) => { + const record = (method: string, input: unknown) => + Ref.update(calls, (recorded) => [...recorded, { method, input }]); + return PeerCliRpcs.toLayer({ + [WS_METHODS.federationSubscribePeers]: () => Stream.make(peersSnapshot), + [WS_METHODS.federationCreatePeerCode]: (input) => + record("createPeerCode", input).pipe( + Effect.as({ ...peerCode, payload: { ...peerCode.payload, scopes: input.scopes } }), + ), + [WS_METHODS.federationAddPeer]: (input) => + input.code === peerCode.code + ? record("addPeer", input).pipe(Effect.as({ ...peer, grantedScopes: input.grantedScopes })) + : Effect.fail( + new FederationError({ code: "code-invalid", message: "That peer code is not valid." }), + ), + [WS_METHODS.federationRemovePeer]: (input) => + input.peerId === PEER_ID + ? record("removePeer", input) + : Effect.fail( + new FederationError({ + code: "peer-unknown", + message: `No peer ${input.peerId} is paired with this server.`, + }), + ), + [WS_METHODS.federationListRemoteProjects]: () => + Effect.succeed({ + environmentId: PEER_ID, + projects: [ + { + id: PROJECT_ID, + title: "t3code", + workspaceRoot: "/srv/t3code", + repositoryIdentity: null, + defaultModelSelection: null, + }, + ], + }), + [WS_METHODS.federationStartRemoteRun]: (input) => + record("startRemoteRun", input).pipe(Effect.as(remoteRun("queued", []))), + [WS_METHODS.federationSubscribeRemoteRuns]: () => + Stream.make( + remoteRunsSnapshot(remoteRun("running", [turnStarted])), + remoteRunsSnapshot(remoteRun("running", [turnStarted, assistantMessage])), + remoteRunsSnapshot( + remoteRun("completed", [turnStarted, assistantMessage, turnCompleted], "Done."), + ), + ), + }); +}; + +// The production `/ws` route in miniature: authenticate the upgrade with the +// server's auth (the CLI sends its session as a bearer header), then hand the +// socket to an RPC server over the scripted federation handlers. +const wsRouteLayer = (calls: Ref.Ref>) => + HttpRouter.add( + "GET", + "/ws", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const authenticated = yield* Effect.result(serverAuth.authenticateWebSocketUpgrade(request)); + if (authenticated._tag === "Failure") { + return HttpServerResponse.empty({ status: 401 }); + } + return yield* RpcServer.toHttpEffectWebsocket(PeerCliRpcs, { disableTracing: true }).pipe( + Effect.provide( + makeFederationHandlersLayer(calls).pipe(Layer.provideMerge(RpcSerialization.layerJson)), + ), + Effect.flatMap((httpEffect) => httpEffect), + ); + }), + ); + +const withLiveFederationServer = ( + baseDir: string, + run: (calls: Ref.Ref>) => Effect.Effect, +) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(baseDir); + const calls = yield* Ref.make>([]); + const appLayer = HttpRouter.serve(Layer.mergeAll(descriptorRouteLayer, wsRouteLayer(calls)), { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge( + EnvironmentAuth.layer.pipe( + Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(ServerSecretStore.layer), + ), + ), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(ServerConfig.layer(config)), + // The server shares the test console with the CLI under test; keep its + // own startup chatter out of the captured output. + Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), + ); + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return yield* Effect.die(new Error(`Expected TCP address, got ${String(address)}`)); + } + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: yield* makePersistedServerRuntimeState({ config, port: address.port }), + }); + return yield* run(calls); + }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), + ); + }); + +const decodePeersJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(FederationPeer)), +); +const isFederationError = Schema.is(FederationError); + +const countOccurrences = (haystack: string, needle: string) => haystack.split(needle).length - 1; + +it("derives the RPC socket URL from the server origin", () => { + assert.equal(runningServerWsUrl("http://127.0.0.1:3773"), "ws://127.0.0.1:3773/ws"); + assert.equal(runningServerWsUrl("https://[fd7a:115c::1]:3773"), "wss://[fd7a:115c::1]:3773/ws"); +}); + +it.layer(NodeServices.layer)("t3 peer", (it) => { + it.effect("registers every peer subcommand", () => + Effect.gen(function* () { + const output = yield* captureStdout(["peer", "--help"]); + + for (const subcommand of ["code", "add", "list", "remove", "projects", "run"]) { + assert.include(output, subcommand); + } + assert.include(output, "Pair with other T3 Code servers and delegate runs to them."); + }), + ); + + it.effect("validates arguments before contacting any server", () => + Effect.gen(function* () { + expectShowHelpError(yield* flipCli(["peer", "add"]), "MissingArgument"); + + const badGrant = expectShowHelpError( + yield* flipCli(["peer", "add", "t3c://peer/x", "--grant", "nope"]), + "InvalidValue", + ); + if (badGrant?._tag !== "InvalidValue") { + assert.fail("Expected InvalidValue"); + } + assert.equal(badGrant.option, "grant"); + + // A variadic argument with a minimum reports "0 occurrences" as an invalid value. + const noPrompt = expectShowHelpError( + yield* flipCli(["peer", "run", "env-peer-1", "project-1"]), + "InvalidValue", + ); + if (noPrompt?._tag !== "InvalidValue") { + assert.fail("Expected InvalidValue"); + } + assert.equal(noPrompt.option, "prompt"); + + expectShowHelpError(yield* flipCli(["peer", "remove", " "]), "InvalidValue"); + }), + ); + + it.effect("lists peers from the running server", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("list"); + + yield* withLiveFederationServer(baseDir, () => + Effect.gen(function* () { + const output = yield* captureStdout(["peer", "list", "--base-dir", baseDir]); + assert.include( + output, + "This environment: env-local (fingerprint SHA256:local-fingerprint)", + ); + assert.include(output, "Build box (env-peer-1) online"); + assert.include(output, "fingerprint: SHA256:peer-fingerprint"); + assert.include( + output, + "granted (they may do here): environment.read projects.read runs.read", + ); + assert.include( + output, + "allowed (we may do there): environment.read projects.read runs.read runs.start", + ); + assert.include(output, "transport: tailcat tcPeerAddressAbCdEfGhIj:3773"); + assert.include(output, `last seen: ${UPDATED_AT}`); + + const json = yield* captureJson(["peer", "list", "--base-dir", baseDir, "--json"]); + assert.deepEqual(yield* decodePeersJson(json), [peer]); + }), + ); + }), + ); + + it.effect("issues and redeems peer codes, browses projects, and removes peers", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("pairing"); + + yield* withLiveFederationServer(baseDir, (calls) => + Effect.gen(function* () { + const code = yield* captureStdout(["peer", "code", "--base-dir", baseDir]); + assert.include(code, "Peer code (expires 2026-06-21T08:35:00.000Z, single use):"); + assert.include(code, "t3c://peer/test-code"); + assert.include(code, "Offered scopes: environment.read projects.read runs.read"); + assert.include(code, "one-time pairing credential"); + + const scoped = yield* captureStdout([ + "peer", + "code", + "--base-dir", + baseDir, + "--scope", + "runs.start", + "--scope", + "runs.start", + "--ttl", + "10m", + ]); + assert.include(scoped, "Offered scopes: runs.start"); + + const added = yield* captureStdout([ + "peer", + "add", + peerCode.code, + "--base-dir", + baseDir, + "--grant", + "runs.start", + ]); + assert.include(added, "Paired with a new peer."); + assert.include(added, "Build box (env-peer-1) online"); + assert.include(added, "granted (they may do here): runs.start"); + + const rejected = yield* flipCli([ + "peer", + "add", + "t3c://peer/bogus", + "--base-dir", + baseDir, + ]); + if (!isFederationError(rejected)) { + assert.fail(`Expected FederationError, got ${String(rejected)}`); + } + assert.equal(rejected.code, "code-invalid"); + assert.equal(rejected.message, "That peer code is not valid."); + + const projects = yield* captureStdout([ + "peer", + "projects", + "env-peer-1", + "--base-dir", + baseDir, + ]); + assert.include(projects, "t3code (project-1)"); + assert.include(projects, "path: /srv/t3code"); + + const removed = yield* captureStdout([ + "peer", + "remove", + "env-peer-1", + "--base-dir", + baseDir, + ]); + assert.include(removed, "Removed peer env-peer-1."); + + const unknown = yield* flipCli(["peer", "remove", "env-other", "--base-dir", baseDir]); + if (!isFederationError(unknown)) { + assert.fail(`Expected FederationError, got ${String(unknown)}`); + } + assert.equal(unknown.code, "peer-unknown"); + + const recorded = yield* Ref.get(calls); + assert.deepEqual( + recorded.map((call) => call.method), + ["createPeerCode", "createPeerCode", "addPeer", "removePeer"], + ); + assert.deepEqual(recorded[0]?.input, { + scopes: ["environment.read", "projects.read", "runs.read"], + }); + // Repeated scopes collapse; --ttl arrives in whole seconds. + assert.deepEqual(recorded[1]?.input, { scopes: ["runs.start"], ttlSeconds: 600 }); + assert.deepEqual(recorded[2]?.input, { + code: peerCode.code, + grantedScopes: ["runs.start"], + }); + }), + ); + }), + ); + + it.effect("starts a remote run and can follow it to completion", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("run"); + + yield* withLiveFederationServer(baseDir, (calls) => + Effect.gen(function* () { + const started = yield* captureStdout([ + "peer", + "run", + "env-peer-1", + "project-1", + "fix", + "the", + "flaky", + "test", + "--title", + "Flaky", + "--base-dir", + baseDir, + ]); + assert.include(started, "Run thread-remote-1 on Build box: queued"); + assert.include(started, "title: Fix the flaky test"); + assert.include(started, "model: codex/gpt-5-codex"); + + const followed = yield* captureStdout([ + "peer", + "run", + "env-peer-1", + "project-1", + "fix the flaky test", + "--wait", + "--base-dir", + baseDir, + ]); + assert.include(followed, "Started run thread-remote-1 on Build box (queued)."); + // Each event prints exactly once even though every snapshot repeats the history. + assert.equal(countOccurrences(followed, "turn.started: Turn started"), 1); + assert.equal(countOccurrences(followed, "assistant.message: Working on it"), 1); + assert.equal(countOccurrences(followed, "turn.completed: Turn completed"), 1); + assert.include(followed, "Run thread-remote-1 on Build box: completed"); + assert.include(followed, "assistant: Done."); + + const recorded = yield* Ref.get(calls); + assert.deepEqual( + recorded.map((call) => call.input), + [ + { + peerId: "env-peer-1", + projectId: "project-1", + prompt: "fix the flaky test", + title: "Flaky", + }, + { peerId: "env-peer-1", projectId: "project-1", prompt: "fix the flaky test" }, + ], + ); + }), + ); + }), + ); +}); diff --git a/apps/server/src/cli/peer.ts b/apps/server/src/cli/peer.ts new file mode 100644 index 000000000000..3ae37eb7995e --- /dev/null +++ b/apps/server/src/cli/peer.ts @@ -0,0 +1,472 @@ +/** + * `t3 peer ` - federation between T3 Code servers: issue and + * redeem peer codes, list peers, browse a peer's projects, and start or follow + * runs on a peer. + * + * Federation management lives on the WebSocket RPC surface (the HTTP + * federation group is the peer-to-peer protocol, not the operator API), so + * this command opens an RPC connection to the running server with the same + * short-lived administrative session `t3 remote` uses, carried as a bearer + * header on the upgrade request. + */ +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { + EnvironmentId, + FEDERATION_DEFAULT_SCOPES, + FederationError, + type FederationPeer, + type FederationPeerCodeResult, + type FederationProjectSummary, + type FederationRemoteRun, + type FederationRunEvent, + type FederationRunStatus, + FederationScope, + type FederationSnapshot, + ProjectId, + TrimmedNonEmptyString, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Argument, Command, Flag } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { baseDirFlag, DurationFromString } from "./config.ts"; +import { + RUNNING_SERVER_REQUEST_TIMEOUT, + RunningServerRequestError, + type RunningServerSession, + withRunningServerSession, +} from "./remote.ts"; + +const RPC_OPEN_TIMEOUT = Duration.seconds(10); + +const isFederationError = Schema.is(FederationError); + +const TERMINAL_RUN_STATUSES: ReadonlySet = new Set([ + "completed", + "interrupted", + "error", +]); + +const isTerminalRunStatus = (status: FederationRunStatus): boolean => + TERMINAL_RUN_STATUSES.has(status); + +/** The server's `/ws` route on the origin it recorded; the dev proxy is not involved on loopback. */ +export const runningServerWsUrl = (origin: string): string => { + const url = new URL("/ws", origin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +}; + +// Node's `ws` client rather than the global WebSocket: the administrative +// bearer token has to ride on the upgrade request, and only `ws` takes headers. +const bearerWebSocketConstructorLayer = (token: string) => + Layer.succeed( + Socket.WebSocketConstructor, + (url, protocols) => + new NodeSocket.NodeWS.WebSocket(url, protocols, { + headers: { authorization: `Bearer ${token}` }, + }) as unknown as globalThis.WebSocket, + ); + +const rpcProtocolLayer = (session: RunningServerSession) => + RpcClient.layerProtocolSocket().pipe( + Layer.provide( + Socket.layerWebSocket(runningServerWsUrl(session.origin), { + openTimeout: RPC_OPEN_TIMEOUT, + }).pipe(Layer.provide(bearerWebSocketConstructorLayer(session.token))), + ), + Layer.provide(RpcSerialization.layerJson), + ); + +const makeRpcClient = RpcClient.make(WsRpcGroup); +type WsRpcClient = Effect.Success; + +const runPeerCommand = ( + flags: { readonly baseDir: Option.Option; readonly json?: boolean }, + run: (client: WsRpcClient) => Effect.Effect, +) => + withRunningServerSession({ + baseDir: flags.baseDir, + label: "t3 peer", + quietLogs: flags.json === true, + run: (session) => + Effect.scoped( + makeRpcClient.pipe(Effect.flatMap(run), Effect.provide(rpcProtocolLayer(session))), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + +// Typed federation failures are worded for the user by the server; anything +// else (authorization, transport, no answer) gets the generic wrapper. +const call = (operation: string, request: Effect.Effect) => + request.pipe( + Effect.timeout(RUNNING_SERVER_REQUEST_TIMEOUT), + Effect.mapError((cause) => + isFederationError(cause) ? cause : new RunningServerRequestError({ operation, cause }), + ), + ); + +const scopeList = (scopes: ReadonlyArray): string => + scopes.length === 0 ? "none" : scopes.join(" "); + +const uniqueScopesOrDefault = ( + scopes: ReadonlyArray, +): ReadonlyArray => + scopes.length === 0 ? FEDERATION_DEFAULT_SCOPES : Array.from(new Set(scopes)); + +export const formatPeer = (peer: FederationPeer): string => + [ + `${peer.label} (${peer.peerId}) ${peer.status}`, + ` fingerprint: ${peer.publicKeyFingerprint}`, + ` granted (they may do here): ${scopeList(peer.grantedScopes)}`, + ` allowed (we may do there): ${scopeList(peer.allowedScopes)}`, + ` transport: ${ + peer.transport === null + ? "none" + : `tailcat ${peer.transport.tailcat.address}:${String(peer.transport.tailcat.port)}` + }`, + ` server: ${peer.remoteServerVersion ?? "unknown"}`, + ` last seen: ${peer.lastSeenAt ?? "never"}`, + ...(peer.lastError === null ? [] : [` last error: ${peer.lastError}`]), + ].join("\n"); + +export const formatPairedPeer = ( + peer: FederationPeer, + options: { readonly json: boolean }, +): string => + options.json ? JSON.stringify(peer, null, 2) : `Paired with a new peer.\n\n${formatPeer(peer)}`; + +export const formatPeerList = ( + snapshot: FederationSnapshot, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(snapshot.peers, null, 2); + } + const header = `This environment: ${snapshot.environmentId} (fingerprint ${snapshot.publicKeyFingerprint})`; + if (snapshot.peers.length === 0) { + return `${header}\n\nNo peers. Create a code with \`t3 peer code\` or redeem one with \`t3 peer add \`.`; + } + return [header, "", snapshot.peers.map(formatPeer).join("\n\n")].join("\n"); +}; + +export const formatPeerCode = ( + issued: FederationPeerCodeResult, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(issued, null, 2); + } + return [ + `Peer code (expires ${issued.expiresAt}, single use):`, + issued.code, + "", + `Offered scopes: ${scopeList(issued.payload.scopes)}`, + "On the other server, run `t3 peer add ` to pair it with this one.", + "Warning: this code embeds a one-time pairing credential. Share it only with the server you are pairing.", + ].join("\n"); +}; + +export const formatRemoteProjects = ( + projects: ReadonlyArray, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(projects, null, 2); + } + if (projects.length === 0) { + return "The peer has no projects."; + } + return projects + .map((project) => + [`${project.title} (${project.id})`, ` path: ${project.workspaceRoot}`].join("\n"), + ) + .join("\n\n"); +}; + +export const formatRunEvent = (event: FederationRunEvent): string => + `[${event.at}] ${event.type}${event.summary.length > 0 ? `: ${event.summary}` : ""}`; + +export const formatRemoteRun = ( + remoteRun: FederationRemoteRun, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(remoteRun, null, 2); + } + return [ + `Run ${remoteRun.run.threadId} on ${remoteRun.peerLabel}: ${remoteRun.run.status}`, + ` title: ${remoteRun.run.title}`, + ` project: ${remoteRun.run.projectId}`, + ` model: ${remoteRun.run.modelSelection.instanceId}/${remoteRun.run.modelSelection.model}`, + ...(remoteRun.run.assistantPreview === null + ? [] + : [` assistant: ${remoteRun.run.assistantPreview}`]), + ...(remoteRun.syncError === null ? [] : [` sync error: ${remoteRun.syncError}`]), + ].join("\n"); +}; + +/** + * Follow one remote run through the remote-runs subscription, printing each + * event once as it lands, until the run reaches a terminal status. Resolves + * with the last snapshot of the run, or none if the server stopped tracking it. + */ +const followRemoteRun = Effect.fn("peer.followRemoteRun")(function* ( + client: WsRpcClient, + started: FederationRemoteRun, + options: { readonly json: boolean }, +) { + const printedThrough = yield* Ref.make(-1); + const latest = yield* Ref.make(Option.none()); + yield* client[WS_METHODS.federationSubscribeRemoteRuns]({}).pipe( + Stream.map((snapshot) => + snapshot.runs.find( + (candidate) => + candidate.peerId === started.peerId && candidate.run.threadId === started.run.threadId, + ), + ), + Stream.filter(Predicate.isNotUndefined), + Stream.takeUntil((remoteRun) => isTerminalRunStatus(remoteRun.run.status)), + Stream.runForEach((remoteRun) => + Effect.gen(function* () { + yield* Ref.set(latest, Option.some(remoteRun)); + if (options.json) { + return; + } + const seen = yield* Ref.get(printedThrough); + const fresh = remoteRun.events.filter((event) => event.sequence > seen); + const last = fresh.at(-1); + if (last === undefined) { + return; + } + yield* Ref.set(printedThrough, last.sequence); + yield* Console.log(fresh.map(formatRunEvent).join("\n")); + }), + ), + Effect.mapError((cause) => + isFederationError(cause) + ? cause + : new RunningServerRequestError({ operation: "federation.subscribeRemoteRuns", cause }), + ), + ); + return yield* Ref.get(latest); +}); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +const peerIdArgument = Argument.string("peer-id").pipe( + Argument.withDescription("Peer environment id, as listed by `t3 peer list`."), + Argument.withSchema(EnvironmentId), +); + +const scopeDescription = `Repeat for several; defaults to ${FEDERATION_DEFAULT_SCOPES.join(", ")}.`; + +const peerCodeCommand = Command.make("code", { + baseDir: baseDirFlag, + scope: Flag.choice("scope", FederationScope.literals).pipe( + Flag.withDescription(`Scope offered to the server that redeems the code. ${scopeDescription}`), + Flag.atLeast(0), + ), + ttl: Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription( + "How long the code stays redeemable, for example `5m` or `1h`. Defaults to 5 minutes.", + ), + Flag.optional, + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Create a one-time peer code another T3 Code server can redeem."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const issued = yield* call( + "federation.createPeerCode", + client[WS_METHODS.federationCreatePeerCode]({ + scopes: uniqueScopesOrDefault(flags.scope), + ...(Option.isSome(flags.ttl) + ? { ttlSeconds: Math.max(1, Math.round(Duration.toSeconds(flags.ttl.value))) } + : {}), + }), + ); + yield* Console.log(formatPeerCode(issued, { json: flags.json })); + }), + ), + ), +); + +const peerAddCommand = Command.make("add", { + baseDir: baseDirFlag, + code: Argument.string("code").pipe( + Argument.withDescription("Peer code issued by `t3 peer code` on the other server."), + Argument.withSchema(TrimmedNonEmptyString), + ), + grant: Flag.choice("grant", FederationScope.literals).pipe( + Flag.withDescription(`Scope this server grants the new peer. ${scopeDescription}`), + Flag.atLeast(0), + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Redeem a peer code and pair this server with the one that issued it."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const peer = yield* call( + "federation.addPeer", + client[WS_METHODS.federationAddPeer]({ + code: flags.code, + grantedScopes: uniqueScopesOrDefault(flags.grant), + }), + ); + yield* Console.log(formatPairedPeer(peer, { json: flags.json })); + }), + ), + ), +); + +const peerListCommand = Command.make("list", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("List the servers this environment is paired with."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const snapshot = yield* call( + "federation.subscribePeers", + Stream.runHead(client[WS_METHODS.federationSubscribePeers]({})), + ); + if (Option.isNone(snapshot)) { + return yield* new RunningServerRequestError({ + operation: "federation.subscribePeers", + cause: "The server closed the peer subscription before sending a snapshot.", + }); + } + yield* Console.log(formatPeerList(snapshot.value, { json: flags.json })); + }), + ), + ), +); + +const peerRemoveCommand = Command.make("remove", { + baseDir: baseDirFlag, + peerId: peerIdArgument, +}).pipe( + Command.withDescription( + "Remove a peer. Its sessions here end and runs it delegated stop syncing.", + ), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + yield* call( + "federation.removePeer", + client[WS_METHODS.federationRemovePeer]({ peerId: flags.peerId }), + ); + yield* Console.log(`Removed peer ${flags.peerId}.`); + }), + ), + ), +); + +const peerProjectsCommand = Command.make("projects", { + baseDir: baseDirFlag, + peerId: peerIdArgument, + json: jsonFlag, +}).pipe( + Command.withDescription("List the projects a peer exposes."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const response = yield* call( + "federation.listRemoteProjects", + client[WS_METHODS.federationListRemoteProjects]({ peerId: flags.peerId }), + ); + yield* Console.log(formatRemoteProjects(response.projects, { json: flags.json })); + }), + ), + ), +); + +const peerRunCommand = Command.make("run", { + baseDir: baseDirFlag, + peerId: peerIdArgument, + projectId: Argument.string("project-id").pipe( + Argument.withDescription("Project on the peer, as listed by `t3 peer projects`."), + Argument.withSchema(ProjectId), + ), + prompt: Argument.string("prompt").pipe( + Argument.withDescription("Prompt for the run; several words are joined with spaces."), + Argument.withSchema(TrimmedNonEmptyString), + Argument.variadic({ min: 1 }), + ), + title: Flag.string("title").pipe( + Flag.withDescription("Optional thread title on the peer."), + Flag.optional, + ), + wait: Flag.boolean("wait").pipe( + Flag.withDescription("Follow the run and print its events until it finishes."), + Flag.withDefault(false), + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Start a run on a peer's project."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const started = yield* call( + "federation.startRemoteRun", + client[WS_METHODS.federationStartRemoteRun]({ + peerId: flags.peerId, + projectId: flags.projectId, + prompt: flags.prompt.join(" "), + ...(Option.isSome(flags.title) ? { title: flags.title.value } : {}), + }), + ); + if (!flags.wait) { + yield* Console.log(formatRemoteRun(started, { json: flags.json })); + return; + } + + if (!flags.json) { + yield* Console.log( + `Started run ${started.run.threadId} on ${started.peerLabel} (${started.run.status}). Following until it finishes; Ctrl-C stops following, not the run.`, + ); + } + const final = yield* followRemoteRun(client, started, { json: flags.json }); + if (Option.isNone(final)) { + return yield* new FederationError({ + code: "run-not-found", + message: `The server stopped tracking run ${started.run.threadId} before it finished.`, + }); + } + yield* Console.log(formatRemoteRun(final.value, { json: flags.json })); + }), + ), + ), +); + +export const peerCommand = Command.make("peer").pipe( + Command.withDescription("Pair with other T3 Code servers and delegate runs to them."), + Command.withSubcommands([ + peerCodeCommand, + peerAddCommand, + peerListCommand, + peerRemoveCommand, + peerProjectsCommand, + peerRunCommand, + ]), +); diff --git a/apps/server/src/cli/remote.test.ts b/apps/server/src/cli/remote.test.ts new file mode 100644 index 000000000000..2fb133ab06e9 --- /dev/null +++ b/apps/server/src/cli/remote.test.ts @@ -0,0 +1,479 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentTailcatHttpApi, + type TailcatConnectionCodeResult, + TailcatRemoteAccessError, + TailcatRemoteAccessState, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { environmentAuthenticatedAuthLayer } from "../auth/http.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { cli } from "../bin.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { tailcatHttpApiLayer } from "../tailcat/http.ts"; +import * as TailcatRemoteAccess from "../tailcat/TailcatRemoteAccess.ts"; +import { NoRunningServerError } from "./pair.ts"; +import { TailcatUnavailableError } from "./remote.ts"; + +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); + +const provideCliTestLayers = (effect: Effect.Effect) => + Effect.provide(effect, Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)); + +// The test console is shared and accumulates across CLI runs, so each capture +// keeps only the entries its own run appended. +const captureNewLogLines = (args: ReadonlyArray) => + provideCliTestLayers( + Effect.gen(function* () { + const before = (yield* TestConsole.logLines).length; + yield* runCli(args); + return (yield* TestConsole.logLines) + .slice(before) + .filter((line): line is string => typeof line === "string"); + }), + ); + +/** Everything one CLI run logged, joined; commands may log more than once (`enable` adds a hint). */ +const captureStdout = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => lines.join("\n")); + +/** `--json` output has to be one clean entry: nothing logged before or after it. */ +const captureJson = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => { + assert.equal(lines.length, 1, `Expected exactly one JSON entry, got ${String(lines)}`); + return lines[0] ?? ""; + }); + +const flipCli = (args: ReadonlyArray) => + provideCliTestLayers(runCli(args).pipe(Effect.flip)); + +const expectShowHelpError = (error: unknown, expectedTag: string) => { + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + assert.equal(error.errors[0]?._tag, expectedTag); +}; + +const makeTempBaseDir = (prefix: string) => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), `t3-remote-cli-${prefix}-`)); + +const testDescriptor = { + environmentId: "remote-test-environment", + label: "remote-test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, +}; + +// Discovery probes the well-known descriptor before trusting runtime state; +// the tailcat API itself is the real handler layer over a scripted service. +const descriptorRouteLayer = HttpRouter.add( + "GET", + "/.well-known/t3/environment", + HttpServerResponse.jsonUnsafe(testDescriptor), +); + +class RemoteCliHttpApi extends HttpApi.make("environment").add(EnvironmentTailcatHttpApi) {} + +const makeCliTestServerConfig = (baseDir: string) => + Effect.gen(function* () { + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + return { + logLevel: "Warn", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + devAllowedOrigins: [], + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, + } satisfies ServerConfig.ServerConfig["Service"]; + }); + +const TAILCAT_ADDRESS = "tcAbCdEfGhIjKlMnOpQrStUv"; +const NODE_KEY = `nodekey:${"0123456789abcdef".repeat(4)}`; + +const readyState: TailcatRemoteAccessState = { + enabled: true, + status: "ready", + address: TAILCAT_ADDRESS, + remotePort: 3773, + pairingOpen: false, + trustedPeers: [ + { + id: "peer-phone", + nodeKey: NODE_KEY, + label: "Phone", + createdAt: "2026-06-20T00:00:00.000Z", + lastSeenAt: "2026-06-21T08:30:00.000Z", + sessionIds: [], + }, + ], + runtime: { + executablePath: "/opt/t3/tailcat", + source: "bundled", + version: "1.4.0", + pinnedVersion: "1.4.0", + compatible: true, + }, + identityFingerprint: "SHA256:remote-test", + lastError: null, + updatedAt: "2026-06-21T08:30:00.000Z", +}; + +const disabledState: TailcatRemoteAccessState = { + ...readyState, + enabled: false, + status: "disabled", + address: null, + remotePort: null, +}; + +const unavailableState: TailcatRemoteAccessState = { + ...disabledState, + status: "unavailable", + runtime: null, + lastError: { + code: "binary-missing", + message: "The tailcat binary was not found.", + at: "2026-06-21T08:30:00.000Z", + }, +}; + +const connectionCode: TailcatConnectionCodeResult = { + code: "t3c://tailcat/remote-test-code", + payload: { v: 1, transport: "tailcat", address: TAILCAT_ADDRESS, port: 3773 }, + pairingLinkId: "pairing-link-1", + expiresAt: "2026-06-21T08:35:00.000Z", +}; + +const makeScriptedRemoteAccess = (initial: TailcatRemoteAccessState) => + Effect.map(Ref.make(initial), (stateRef) => ({ + stateRef, + service: TailcatRemoteAccess.TailcatRemoteAccess.of({ + state: Ref.get(stateRef), + changes: Stream.empty, + readyEndpoint: Effect.succeed(Option.none()), + start: () => Effect.void, + setEnabled: (enabled) => + Ref.updateAndGet(stateRef, (state) => + enabled + ? { + ...state, + enabled: true, + status: "ready", + address: TAILCAT_ADDRESS, + remotePort: 3773, + } + : { ...state, enabled: false, status: "disabled", address: null, remotePort: null }, + ), + createConnectionCode: () => Effect.succeed(connectionCode), + recordTrustedPeer: () => Effect.void, + revokeTrustedPeer: (peerId) => + Effect.gen(function* () { + const current = yield* Ref.get(stateRef); + if (!current.trustedPeers.some((peer) => peer.id === peerId)) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + return yield* Ref.updateAndGet(stateRef, (state) => ({ + ...state, + trustedPeers: state.trustedPeers.filter((peer) => peer.id !== peerId), + })); + }), + renameTrustedPeer: () => Ref.get(stateRef), + regenerateIdentity: Ref.get(stateRef), + }), + })); + +/** + * A server the CLI can discover: descriptor route, the real tailcat HTTP + * handlers over a scripted service, and the real auth middleware backed by + * the sqlite database the CLI mints its session into. + */ +const withLiveTailcatServer = ( + baseDir: string, + remoteAccess: TailcatRemoteAccess.TailcatRemoteAccess["Service"], + run: () => Effect.Effect, +) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(baseDir); + const routesLayer = Layer.mergeAll( + HttpApiBuilder.layer(RemoteCliHttpApi).pipe( + Layer.provide( + tailcatHttpApiLayer.pipe( + Layer.provide(Layer.succeed(TailcatRemoteAccess.TailcatRemoteAccess, remoteAccess)), + ), + ), + Layer.provide(environmentAuthenticatedAuthLayer), + ), + descriptorRouteLayer, + ); + const appLayer = HttpRouter.serve(routesLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge( + EnvironmentAuth.layer.pipe( + Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(ServerSecretStore.layer), + ), + ), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(ServerConfig.layer(config)), + // The server shares the test console with the CLI under test; keep its + // own startup chatter out of the captured output. + Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), + ); + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return yield* Effect.die(new Error(`Expected TCP address, got ${String(address)}`)); + } + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: yield* makePersistedServerRuntimeState({ config, port: address.port }), + }); + return yield* run(); + }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), + ); + }); + +const decodeStateJson = Schema.decodeUnknownEffect(Schema.fromJsonString(TailcatRemoteAccessState)); +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); +const isTailcatUnavailableError = Schema.is(TailcatUnavailableError); +const isNoRunningServerError = Schema.is(NoRunningServerError); + +it.layer(NodeServices.layer)("t3 remote tailcat", (it) => { + it.effect("registers every tailcat subcommand", () => + Effect.gen(function* () { + const output = yield* captureStdout(["remote", "tailcat", "--help"]); + + for (const subcommand of ["status", "enable", "disable", "code", "peers", "revoke"]) { + assert.include(output, subcommand); + } + assert.include(output, "Manage Tailcat remote access on the running server."); + }), + ); + + it.effect("rejects a missing or blank peer id before contacting any server", () => + Effect.gen(function* () { + expectShowHelpError(yield* flipCli(["remote", "tailcat", "revoke"]), "MissingArgument"); + expectShowHelpError(yield* flipCli(["remote", "tailcat", "revoke", " "]), "InvalidValue"); + }), + ); + + it.effect("reports remote access state and trusted peers from the running server", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("status"); + const remoteAccess = yield* makeScriptedRemoteAccess(readyState); + + yield* withLiveTailcatServer(baseDir, remoteAccess.service, () => + Effect.gen(function* () { + const status = yield* captureStdout([ + "remote", + "tailcat", + "status", + "--base-dir", + baseDir, + ]); + assert.include(status, "Tailcat remote access"); + assert.include(status, "Enabled: yes"); + assert.include(status, "Status: ready"); + assert.include(status, `Address: ${TAILCAT_ADDRESS}`); + assert.include(status, "Pairing window: closed"); + assert.include(status, "Runtime: bundled 1.4.0 (compatible) at /opt/t3/tailcat"); + assert.include(status, "Trusted peers: 1"); + assert.include(status, "Last error: none"); + + const json = yield* captureJson([ + "remote", + "tailcat", + "status", + "--base-dir", + baseDir, + "--json", + ]); + assert.deepEqual(yield* decodeStateJson(json), readyState); + + const peers = yield* captureStdout(["remote", "tailcat", "peers", "--base-dir", baseDir]); + assert.include(peers, "peer-phone (Phone)"); + // Last 8 hex characters of the node key. + assert.include(peers, "node key: …89abcdef"); + assert.include(peers, "created: 2026-06-20T00:00:00.000Z"); + assert.include(peers, "last seen: 2026-06-21T08:30:00.000Z"); + }), + ); + }), + ); + + it.effect("enables, mints a connection code, revokes a peer, and disables again", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("toggle"); + const remoteAccess = yield* makeScriptedRemoteAccess(disabledState); + + yield* withLiveTailcatServer(baseDir, remoteAccess.service, () => + Effect.gen(function* () { + const enabled = yield* captureStdout([ + "remote", + "tailcat", + "enable", + "--base-dir", + baseDir, + ]); + assert.include(enabled, "Status: ready"); + assert.include(enabled, `Address: ${TAILCAT_ADDRESS}`); + assert.include(enabled, "Next: run `t3 remote tailcat code`"); + assert.isTrue((yield* Ref.get(remoteAccess.stateRef)).enabled); + + const code = yield* captureStdout([ + "remote", + "tailcat", + "code", + "--base-dir", + baseDir, + "--label", + "Laptop", + ]); + assert.include(code, "Connection code (expires 2026-06-21T08:35:00.000Z, single use):"); + assert.include(code, "t3c://tailcat/remote-test-code"); + assert.isTrue(code.includes("█") || code.includes("▀") || code.includes("▄")); + assert.include(code, "one-time pairing credential"); + + const revoked = yield* captureStdout([ + "remote", + "tailcat", + "revoke", + "peer-phone", + "--base-dir", + baseDir, + ]); + assert.include(revoked, "Revoked trusted peer peer-phone. 0 trusted peer(s) remain."); + + // The server's typed failure surfaces with its own wording. + const revokedAgain = yield* flipCli([ + "remote", + "tailcat", + "revoke", + "peer-phone", + "--base-dir", + baseDir, + ]); + if (!isTailcatRemoteAccessError(revokedAgain)) { + assert.fail(`Expected TailcatRemoteAccessError, got ${String(revokedAgain)}`); + } + assert.equal(revokedAgain.message, "That device is no longer in the trusted list."); + + const disabled = yield* captureStdout([ + "remote", + "tailcat", + "disable", + "--base-dir", + baseDir, + ]); + assert.include(disabled, "Tailcat remote access is disabled."); + assert.isFalse((yield* Ref.get(remoteAccess.stateRef)).enabled); + }), + ); + }), + ); + + it.effect("fails with the binary override hint when the server reports Tailcat unavailable", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("unavailable"); + const remoteAccess = yield* makeScriptedRemoteAccess(unavailableState); + + yield* withLiveTailcatServer(baseDir, remoteAccess.service, () => + Effect.gen(function* () { + const error = yield* flipCli(["remote", "tailcat", "status", "--base-dir", baseDir]); + + if (!isTailcatUnavailableError(error)) { + assert.fail(`Expected TailcatUnavailableError, got ${String(error)}`); + } + assert.equal(error.code, "binary-missing"); + assert.include(error.message, "The tailcat binary was not found."); + assert.include(error.message, "T3CODE_TAILCAT_BINARY"); + }), + ); + }), + ); + + it.effect("directs to t3 serve when no server is running", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("none"); + + const error = yield* flipCli(["remote", "tailcat", "status", "--base-dir", baseDir]); + + if (!isNoRunningServerError(error)) { + assert.fail(`Expected NoRunningServerError, got ${String(error)}`); + } + assert.include(error.message, "No running T3 Code server found."); + assert.include(error.message, "npx t3 serve"); + }), + ); +}); diff --git a/apps/server/src/cli/remote.ts b/apps/server/src/cli/remote.ts new file mode 100644 index 000000000000..9286c0dc91c8 --- /dev/null +++ b/apps/server/src/cli/remote.ts @@ -0,0 +1,559 @@ +/** + * `t3 remote tailcat ` - manage Tailcat remote access on the + * running T3 Code server: status, enable/disable, connection codes, and the + * trusted device list. + * + * Discovery and credentials mirror `t3 pair`: the running server is found + * through the runtime state it persists next to its database, and every + * invocation mints a short-lived administrative session in that database, + * revoked when the command finishes. Calls go over the server's HTTP API, + * which exists for exactly this purpose; the UIs drive the same service over + * RPC. + */ +import { + AuthAdministrativeScopes, + EnvironmentAuthorizationError, + EnvironmentHttpApi, + EnvironmentHttpCommonError, + type TailcatConnectionCodeResult, + type TailcatCreateConnectionCodeInput, + TailcatFailureCode, + TailcatRemoteAccessError, + type TailcatRemoteAccessState, + TailcatServeStatus, + type TailcatTrustedPeer, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import { TAILCAT_BINARY_OVERRIDE_ENV } from "@t3tools/tailcat/runtime"; +import * as Cause from "effect/Cause"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; +import { RpcClientError } from "effect/unstable/rpc"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { renderTerminalQrCode } from "../startupAccess.ts"; +import { baseDirFlag, DurationFromString } from "./config.ts"; +import { type DiscoveredPairTarget, discoverPairTarget, makePairServerConfig } from "./pair.ts"; + +/** + * Bound for one unary call against the running server: generous for a busy + * disk, short enough that a wedged server does not hang the terminal. + */ +export const RUNNING_SERVER_REQUEST_TIMEOUT = Duration.seconds(10); + +// Enabling starts the tailcat process and waits for it to report an address; +// a cold start with a DERP handshake is a few seconds, so poll for up to 30s. +const ENABLE_POLL_INTERVAL = Duration.millis(500); +const ENABLE_POLL_ATTEMPTS = 60; + +const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); +const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); +const isRpcClientError = Schema.is(RpcClientError.RpcClientError); +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); + +/** Failure codes that mean the tailcat binary itself is the problem, not this server's state. */ +const TAILCAT_RUNTIME_FAILURE_CODES: ReadonlySet = new Set([ + "binary-missing", + "binary-not-executable", + "version-incompatible", +]); + +/** The running server plus the administrative session minted for one CLI invocation. */ +export interface RunningServerSession { + readonly target: DiscoveredPairTarget; + /** Origin the server listens on; HTTP and the RPC WebSocket both live here. */ + readonly origin: string; + readonly token: string; +} + +/** + * Anything the running server answered with that is not a typed Tailcat or + * federation failure: rejected credentials, an internal error, a transport + * failure, or no answer at all. The cause stays attached for logs. + */ +export class RunningServerRequestError extends Schema.TaggedErrorClass()( + "RunningServerRequestError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const cause = this.cause; + if (isEnvironmentHttpCommonError(cause) || isEnvironmentAuthorizationError(cause)) { + return `The running server rejected ${this.operation}: ${cause.message}`; + } + if (Cause.isTimeoutError(cause)) { + return `The running server did not answer ${this.operation} within ${Duration.format(RUNNING_SERVER_REQUEST_TIMEOUT)}.`; + } + if (isRpcClientError(cause)) { + return `Lost the connection to the running server during ${this.operation}.`; + } + return `Failed to call the running server (${this.operation}).`; + } +} + +export class TailcatUnavailableError extends Schema.TaggedErrorClass()( + "TailcatUnavailableError", + { + code: TailcatFailureCode, + detail: Schema.String, + }, +) { + override get message(): string { + return [ + `Tailcat is unavailable on this server (${this.code}): ${this.detail}`, + `Install tailcat and point ${TAILCAT_BINARY_OVERRIDE_ENV} at the binary, or reinstall T3 Code to restore the bundled runtime.`, + ].join("\n"); + } +} + +export class TailcatNotReadyError extends Schema.TaggedErrorClass()( + "TailcatNotReadyError", + { + status: TailcatServeStatus, + detail: Schema.String, + }, +) { + override get message(): string { + return `Tailcat remote access did not become ready (${this.status}): ${this.detail}`; + } +} + +/** + * Discover the running server, mint an administrative session in its database + * and run `run` with it. The session is revoked on the way out, including on + * interruption, so a Ctrl-C leaves nothing behind. + */ +export const withRunningServerSession = Effect.fn("remote.withRunningServerSession")(function* < + A, + E, + R, +>(input: { + readonly baseDir: Option.Option; + readonly label: string; + /** Machine-readable output must stay parseable, so `--json` raises the log floor to Error. */ + readonly quietLogs: boolean; + readonly run: (session: RunningServerSession) => Effect.Effect; +}) { + const cliLogLevel = yield* GlobalFlag.LogLevel; + // Default to Warn so storage/migration chatter cannot bury the output; an + // explicit --log-level still wins unless the output has to be JSON. + const logLevel = input.quietLogs + ? ("Error" as const) + : Option.getOrElse(cliLogLevel, () => "Warn" as const); + const target = yield* discoverPairTarget(Option.getOrUndefined(input.baseDir)); + const config = yield* makePairServerConfig({ target, logLevel }); + + return yield* Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* Effect.acquireUseRelease( + environmentAuth.issueSession({ scopes: AuthAdministrativeScopes, label: input.label }), + (issued) => input.run({ target, origin: target.state.origin, token: issued.token }), + (issued) => + environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, logLevel)), + ), + ), + ); +}); + +type TailcatCliError = + | TailcatRemoteAccessError + | TailcatUnavailableError + | RunningServerRequestError; + +interface TailcatApi { + readonly state: Effect.Effect; + readonly setEnabled: ( + enabled: boolean, + ) => Effect.Effect; + readonly createConnectionCode: ( + input: TailcatCreateConnectionCodeInput, + ) => Effect.Effect; + readonly revokeTrustedPeer: ( + peerId: string, + ) => Effect.Effect; +} + +// A missing or incompatible binary is the one Tailcat failure the user fixes +// on their own machine, so it gets the override hint; everything else is +// already worded for them by the server. +const tailcatCliErrorFromServer = ( + error: TailcatRemoteAccessError, +): TailcatRemoteAccessError | TailcatUnavailableError => + TAILCAT_RUNTIME_FAILURE_CODES.has(error.code) + ? new TailcatUnavailableError({ code: error.code, detail: error.message }) + : error; + +const tailcatUnavailableFromState = ( + state: TailcatRemoteAccessState, +): Option.Option => { + if (state.status !== "unavailable") { + return Option.none(); + } + const runtimeDetail = + state.runtime !== null && !state.runtime.compatible + ? `tailcat ${state.runtime.version} at ${state.runtime.executablePath} is not compatible with this server (wants ${state.runtime.pinnedVersion}).` + : "The Tailcat runtime is not available."; + return Option.some( + new TailcatUnavailableError({ + code: state.lastError?.code ?? "unknown", + detail: state.lastError?.message ?? runtimeDetail, + }), + ); +}; + +const makeTailcatApi = Effect.fn("remote.makeTailcatApi")(function* ( + session: RunningServerSession, +) { + const client = yield* HttpApiClient.make(EnvironmentHttpApi, { baseUrl: session.origin }); + const headers = { authorization: `Bearer ${session.token}` }; + const call = (operation: string, request: Effect.Effect) => + request.pipe( + Effect.timeout(RUNNING_SERVER_REQUEST_TIMEOUT), + Effect.mapError((cause) => + isTailcatRemoteAccessError(cause) + ? tailcatCliErrorFromServer(cause) + : new RunningServerRequestError({ operation, cause }), + ), + ); + + return { + state: call("tailcat.remoteAccess", client.tailcat.remoteAccess({ headers })), + setEnabled: (enabled) => + call( + "tailcat.setRemoteAccess", + client.tailcat.setRemoteAccess({ headers, payload: { enabled } }), + ), + createConnectionCode: (payload) => + call( + "tailcat.createConnectionCode", + client.tailcat.createConnectionCode({ headers, payload }), + ), + revokeTrustedPeer: (peerId) => + call( + "tailcat.revokeTrustedPeer", + client.tailcat.revokeTrustedPeer({ headers, payload: { peerId } }), + ), + } satisfies TailcatApi; +}); + +const runTailcatCommand = ( + flags: { readonly baseDir: Option.Option; readonly json?: boolean }, + run: (api: TailcatApi) => Effect.Effect, +) => + withRunningServerSession({ + baseDir: flags.baseDir, + label: "t3 remote tailcat", + quietLogs: flags.json === true, + run: (session) => Effect.flatMap(makeTailcatApi(session), run), + }).pipe(Effect.provide(FetchHttpClient.layer)); + +/** Last 8 hex characters of a `nodekey:<64 hex>`: enough to tell devices apart by eye. */ +export const nodeKeyFingerprint = (nodeKey: string): string => nodeKey.slice(-8); + +const formatRuntime = (state: TailcatRemoteAccessState): string => { + if (state.runtime === null) { + return "not detected"; + } + const compatibility = state.runtime.compatible + ? "compatible" + : `incompatible, wants ${state.runtime.pinnedVersion}`; + return `${state.runtime.source} ${state.runtime.version} (${compatibility}) at ${state.runtime.executablePath}`; +}; + +export const formatTailcatStatus = ( + state: TailcatRemoteAccessState, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(state, null, 2); + } + const lastError = + state.lastError === null + ? "none" + : `${state.lastError.message} (${state.lastError.code}, ${state.lastError.at})`; + return [ + "Tailcat remote access", + ` Enabled: ${state.enabled ? "yes" : "no"}`, + ` Status: ${state.status}`, + ` Address: ${state.address ?? "none"}`, + ` Remote port: ${state.remotePort === null ? "none" : String(state.remotePort)}`, + ` Pairing window: ${state.pairingOpen ? "open (a connection code is active)" : "closed"}`, + ` Runtime: ${formatRuntime(state)}`, + ` Identity: ${state.identityFingerprint ?? "none"}`, + ` Trusted peers: ${String(state.trustedPeers.length)}`, + ` Last error: ${lastError}`, + ].join("\n"); +}; + +export const formatTrustedPeers = ( + peers: ReadonlyArray, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify( + peers.map((peer) => ({ + id: peer.id, + label: peer.label, + nodeKeyFingerprint: nodeKeyFingerprint(peer.nodeKey), + createdAt: peer.createdAt, + lastSeenAt: peer.lastSeenAt, + })), + null, + 2, + ); + } + if (peers.length === 0) { + return "No trusted peers."; + } + return peers + .map((peer) => + [ + `${peer.id} (${peer.label})`, + ` node key: …${nodeKeyFingerprint(peer.nodeKey)}`, + ` created: ${peer.createdAt}`, + ` last seen: ${peer.lastSeenAt ?? "never"}`, + ].join("\n"), + ) + .join("\n\n"); +}; + +// Same shape as the `t3 serve --tailcat` startup output, so the code reads +// the same wherever the user sees it. +export const formatConnectionCode = ( + issued: TailcatConnectionCodeResult, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(issued, null, 2); + } + return [ + `Connection code (expires ${issued.expiresAt}, single use):`, + issued.code, + "", + renderTerminalQrCode(issued.code), + "", + "Paste the code in T3 Code under Add Environment → Tailcat, or scan it with the mobile app.", + "Warning: this code embeds a one-time pairing credential. Share it only with the device you are pairing.", + ].join("\n"); +}; + +// Right after enabling, the service still reports "disabled" until its +// reconcile debounce fires, so an enabled-but-disabled state is not settled. +const isSettledTailcatState = (state: TailcatRemoteAccessState): boolean => + state.status !== "starting" && + state.status !== "restarting" && + !(state.enabled && state.status === "disabled"); + +const awaitSettledTailcatState = (api: TailcatApi) => + api.state.pipe( + Effect.repeat({ + schedule: Schedule.max([ + Schedule.spaced(ENABLE_POLL_INTERVAL), + Schedule.recurs(ENABLE_POLL_ATTEMPTS), + ]), + until: isSettledTailcatState, + }), + ); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +const tailcatStatusCommand = Command.make("status", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Show Tailcat remote access state on the running server."), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const state = yield* api.state; + yield* Console.log(formatTailcatStatus(state, { json: flags.json })); + const unavailable = tailcatUnavailableFromState(state); + if (Option.isSome(unavailable)) { + return yield* unavailable.value; + } + }), + ), + ), +); + +const tailcatEnableCommand = Command.make("enable", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Enable Tailcat remote access and wait until the listener is ready or has failed.", + ), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const enabled = yield* api.setEnabled(true); + const settled = isSettledTailcatState(enabled) + ? enabled + : yield* awaitSettledTailcatState(api); + yield* Console.log(formatTailcatStatus(settled, { json: flags.json })); + + const unavailable = tailcatUnavailableFromState(settled); + if (Option.isSome(unavailable)) { + return yield* unavailable.value; + } + switch (settled.status) { + case "ready": + if (!flags.json) { + yield* Console.log( + "\nNext: run `t3 remote tailcat code` to pair a device through this address.", + ); + } + return; + case "error": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: settled.lastError?.message ?? "The server reported an error.", + }); + case "disabled": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: settled.enabled + ? "The listener has not started yet; check `t3 remote tailcat status` in a moment." + : "Remote access was disabled again before the listener came up.", + }); + case "starting": + case "restarting": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: `still ${settled.status} after ${Duration.format( + Duration.times(ENABLE_POLL_INTERVAL, ENABLE_POLL_ATTEMPTS), + )}; check \`t3 remote tailcat status\` in a moment.`, + }); + case "unavailable": + // Handled above; kept so the switch stays exhaustive. + return; + } + }), + ), + ), +); + +const tailcatDisableCommand = Command.make("disable", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Disable Tailcat remote access on the running server."), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const state = yield* api.setEnabled(false); + yield* Console.log( + flags.json + ? formatTailcatStatus(state, { json: true }) + : "Tailcat remote access is disabled. Trusted devices keep their entries and reconnect once it is enabled again.", + ); + }), + ), + ), +); + +const tailcatCodeCommand = Command.make("code", { + baseDir: baseDirFlag, + label: Flag.string("label").pipe( + Flag.withDescription("Optional label for the device that will redeem the code."), + Flag.optional, + ), + ttl: Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription( + "How long the code stays redeemable, for example `5m` or `1h`. Defaults to 5 minutes.", + ), + Flag.optional, + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Create a one-time Tailcat connection code and print it as a QR code."), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const issued = yield* api.createConnectionCode({ + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + ...(Option.isSome(flags.ttl) + ? { ttlSeconds: Math.max(1, Math.round(Duration.toSeconds(flags.ttl.value))) } + : {}), + }); + yield* Console.log(formatConnectionCode(issued, { json: flags.json })); + }), + ), + ), +); + +const tailcatPeersCommand = Command.make("peers", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("List the devices trusted to reach this server over Tailcat."), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const state = yield* api.state; + yield* Console.log(formatTrustedPeers(state.trustedPeers, { json: flags.json })); + }), + ), + ), +); + +const tailcatRevokeCommand = Command.make("revoke", { + baseDir: baseDirFlag, + peerId: Argument.string("peer-id").pipe( + Argument.withDescription("Trusted peer id to revoke, as listed by `peers`."), + Argument.withSchema(TrimmedNonEmptyString), + ), +}).pipe( + Command.withDescription( + "Revoke a trusted device. Its Tailcat access and the sessions it paired with end together.", + ), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const state = yield* api.revokeTrustedPeer(flags.peerId); + yield* Console.log( + `Revoked trusted peer ${flags.peerId}. ${String(state.trustedPeers.length)} trusted peer(s) remain.`, + ); + }), + ), + ), +); + +const tailcatCommand = Command.make("tailcat").pipe( + Command.withDescription("Manage Tailcat remote access on the running server."), + Command.withSubcommands([ + tailcatStatusCommand, + tailcatEnableCommand, + tailcatDisableCommand, + tailcatCodeCommand, + tailcatPeersCommand, + tailcatRevokeCommand, + ]), +); + +export const remoteCommand = Command.make("remote").pipe( + Command.withDescription("Manage how remote devices reach the running T3 Code server."), + Command.withSubcommands([tailcatCommand]), +); From 71bd32e4ce2ac82b686e9b09e52c7cf679c9f583 Mon Sep 17 00:00:00 2001 From: Bear Huddleston Date: Thu, 3 Sep 2026 19:08:17 -0500 Subject: [PATCH 06/12] feat(desktop): manage Tailcat forwards and the client identity in the main process Co-Authored-By: Claude Fable 5.1 --- .../backend/DesktopBackendConfiguration.ts | 18 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 5 + apps/desktop/src/ipc/channels.ts | 7 + .../src/ipc/methods/tailcatEnvironment.ts | 93 +++ apps/desktop/src/main.ts | 12 + apps/desktop/src/preload.ts | 12 + .../tailcat/DesktopTailcatEnvironment.test.ts | 537 ++++++++++++++++ .../src/tailcat/DesktopTailcatEnvironment.ts | 591 ++++++++++++++++++ .../tailcat/DesktopTailcatIdentity.test.ts | 262 ++++++++ .../src/tailcat/DesktopTailcatIdentity.ts | 272 ++++++++ .../src/tailcat/DesktopTailcatRuntime.ts | 65 ++ 11 files changed, 1873 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/ipc/methods/tailcatEnvironment.ts create mode 100644 apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts create mode 100644 apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts create mode 100644 apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts create mode 100644 apps/desktop/src/tailcat/DesktopTailcatIdentity.ts create mode 100644 apps/desktop/src/tailcat/DesktopTailcatRuntime.ts diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4c43070b5f97..44c3b7a583bd 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -16,6 +16,7 @@ import serverPackageJson from "../../../server/package.json" with { type: "json" import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { resolveDesktopTailcatBinaryPath } from "../tailcat/DesktopTailcatRuntime.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -471,6 +472,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv function* ( input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; + readonly tailcatBinaryPath: Option.Option; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -496,6 +498,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv onNone: () => ({}), onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), }), + ...Option.match(input.tailcatBinaryPath, { + onNone: () => ({}), + onSome: (tailcatBinaryPath) => ({ tailcatBinaryPath }), + }), ...buildObservabilityFragment(input.observabilitySettings), }; @@ -809,7 +815,17 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( + // The bundled Tailcat binary is shared with the backend so the server's + // remote access and the desktop's forwards run the same pinned build. + const tailcatBinaryPath = yield* resolveDesktopTailcatBinaryPath().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolvePrimaryStartConfig({ + ...shared, + resourceMonitorPath, + tailcatBinaryPath, + }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2cdffbefb7ad..5d3b272bbab1 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,6 +46,7 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as TailcatIpc from "./methods/tailcatEnvironment.ts"; import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; @@ -78,6 +79,10 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(issueSshWebSocketTicket); yield* ipc.handle(resolveSshPasswordPrompt); + for (const tailcatMethod of TailcatIpc.methods) { + yield* ipc.handle(tailcatMethod); + } + yield* ipc.handle(getServerExposureState); yield* ipc.handle(setServerExposureMode); yield* ipc.handle(setTailscaleServeEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 81b50d165d24..de4ddb0d1d3f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -47,6 +47,13 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled" export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const GET_TAILCAT_RUNTIME_AVAILABILITY_CHANNEL = "desktop:get-tailcat-runtime-availability"; +export const ENSURE_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:ensure-tailcat-environment"; +export const RESTART_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:restart-tailcat-environment"; +export const DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:disconnect-tailcat-environment"; +export const GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL = + "desktop:get-tailcat-connection-diagnostics"; +export const PROBE_TAILCAT_CONNECTION_PATH_CHANNEL = "desktop:probe-tailcat-connection-path"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; diff --git a/apps/desktop/src/ipc/methods/tailcatEnvironment.ts b/apps/desktop/src/ipc/methods/tailcatEnvironment.ts new file mode 100644 index 000000000000..8747bac255fc --- /dev/null +++ b/apps/desktop/src/ipc/methods/tailcatEnvironment.ts @@ -0,0 +1,93 @@ +import { + DesktopTailcatConnectionIdInputSchema, + DesktopTailcatEnvironmentBootstrapSchema, + DesktopTailcatEnvironmentEnsureInputSchema, + TailcatConnectionDiagnostics, + TailcatRuntimeAvailability, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as DesktopTailcatEnvironment from "../../tailcat/DesktopTailcatEnvironment.ts"; + +/** + * Renderer-facing Tailcat transport methods. The renderer never touches the + * private key or the child process; it receives a loopback endpoint and + * diagnostics, and asks for lifecycle changes by connection id. + */ + +export const getTailcatRuntimeAvailability = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_TAILCAT_RUNTIME_AVAILABILITY_CHANNEL, + payload: Schema.Void, + result: TailcatRuntimeAvailability, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.runtimeAvailability")(function* () { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return yield* tailcat.runtimeAvailability; + }), +}); + +export const ensureTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.ENSURE_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatEnvironmentEnsureInputSchema, + result: DesktopTailcatEnvironmentBootstrapSchema, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.ensureEnvironment")(function* (input) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return yield* tailcat.ensureEnvironment(input); + }), +}); + +export const restartTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESTART_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: DesktopTailcatEnvironmentBootstrapSchema, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.restartEnvironment")(function* ({ + connectionId, + }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return yield* tailcat.restartEnvironment(connectionId); + }), +}); + +export const disconnectTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.disconnectEnvironment")(function* ({ + connectionId, + }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + yield* tailcat.disconnectEnvironment(connectionId); + }), +}); + +export const getTailcatConnectionDiagnostics = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.NullOr(TailcatConnectionDiagnostics), + handler: Effect.fn("desktop.ipc.tailcatEnvironment.diagnostics")(function* ({ connectionId }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return Option.getOrNull(yield* tailcat.diagnostics(connectionId)); + }), +}); + +export const probeTailcatConnectionPath = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PROBE_TAILCAT_CONNECTION_PATH_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.NullOr(TailcatConnectionDiagnostics), + handler: Effect.fn("desktop.ipc.tailcatEnvironment.probePath")(function* ({ connectionId }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return Option.getOrNull(yield* tailcat.probePath(connectionId)); + }), +}); + +export const methods = [ + getTailcatRuntimeAvailability, + ensureTailcatEnvironment, + restartTailcatEnvironment, + disconnectTailcatEnvironment, + getTailcatConnectionDiagnostics, + probeTailcatConnectionPath, +] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3337228aa962..0cdc6d894e66 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -54,6 +54,9 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; +import * as DesktopTailcatEnvironment from "./tailcat/DesktopTailcatEnvironment.ts"; +import * as DesktopTailcatIdentity from "./tailcat/DesktopTailcatIdentity.ts"; +import * as DesktopTailcatRuntime from "./tailcat/DesktopTailcatRuntime.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; @@ -145,6 +148,14 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( Layer.provideMerge(DesktopSshPasswordPrompts.layer()), ); +// Tailcat forwards for saved Tailcat environments, plus this device's client +// identity (encrypted with safeStorage). Rides on the foundation for paths. +const desktopTailcatLayer = DesktopTailcatEnvironment.layer.pipe( + Layer.provideMerge(DesktopTailcatIdentity.layer), + Layer.provideMerge(DesktopTailcatRuntime.layer), + Layer.provide(desktopFoundationLayer), +); + const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopFoundationLayer), @@ -199,6 +210,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, + desktopTailcatLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 685a9b1204db..decdc8ec1967 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -94,6 +94,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, resolveSshPasswordPrompt: (requestId, password) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), + getTailcatRuntimeAvailability: () => + ipcRenderer.invoke(IpcChannels.GET_TAILCAT_RUNTIME_AVAILABILITY_CHANNEL), + ensureTailcatEnvironment: (input) => + ipcRenderer.invoke(IpcChannels.ENSURE_TAILCAT_ENVIRONMENT_CHANNEL, input), + restartTailcatEnvironment: (connectionId) => + ipcRenderer.invoke(IpcChannels.RESTART_TAILCAT_ENVIRONMENT_CHANNEL, { connectionId }), + disconnectTailcatEnvironment: (connectionId) => + ipcRenderer.invoke(IpcChannels.DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL, { connectionId }), + getTailcatConnectionDiagnostics: (connectionId) => + ipcRenderer.invoke(IpcChannels.GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL, { connectionId }), + probeTailcatConnectionPath: (connectionId) => + ipcRenderer.invoke(IpcChannels.PROBE_TAILCAT_CONNECTION_PATH_CHANNEL, { connectionId }), getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), diff --git a/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts new file mode 100644 index 000000000000..bc5f2856a02f --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts @@ -0,0 +1,537 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { + DesktopTailcatEnvironmentEnsureInput, + TailcatPathProbe, + TailcatRuntimeInfo, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { tailcatBackoffDelayMs } from "@t3tools/tailcat/backoff"; +import { TailcatBinaryMissingError } from "@t3tools/tailcat/errors"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as DesktopTailcatEnvironment from "./DesktopTailcatEnvironment.ts"; +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +const CONNECTION_ID = "connection-1"; +// Captured from a real `tailcat serve` run; the fake runtime never decodes it. +const ADDRESS = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; +const OTHER_ADDRESS = "tcAnotherServer_0123456789abcdefABCDEF"; +const REMOTE_PORT = 3773; +const FIRST_PORT = 41000; +const NODE_KEY = `nodekey:${"7f".repeat(32)}`; +const KEY_PATH = "/tmp/fake.key"; +// The TestClock starts at the epoch, so every recorded timestamp is fixed. +const EPOCH_ISO = "1970-01-01T00:00:00.000Z"; +const RECENT_OUTPUT = ["forward: tunnel established"]; +const FIRST_BACKOFF_MAX_MS = tailcatBackoffDelayMs(1, 1); +const SECOND_BACKOFF_MIN_MS = tailcatBackoffDelayMs(2, 0); +const SECOND_BACKOFF_MAX_MS = tailcatBackoffDelayMs(2, 1); + +const ENSURE_INPUT = { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, +} satisfies DesktopTailcatEnvironmentEnsureInput; + +const RUNTIME_INFO: TailcatRuntimeInfo = { + executablePath: "/opt/t3/resources/tailcat/linux-x64/tailcat", + source: "bundled", + version: "0.3.0", + pinnedVersion: "0.3.0", + compatible: true, +}; + +const PATH_PROBE: TailcatPathProbe = { + kind: "direct", + via: "203.0.113.5:41641", + latencyMs: 12.5, + measuredAt: EPOCH_ISO, +}; + +const DESCRIPTOR = { + environmentId: "env-remote", + label: "Remote Devbox", + platform: { os: "linux", arch: "x64" }, + serverVersion: "1.2.3", + capabilities: {}, +}; + +const httpBaseUrlFor = (localPort: number) => `http://127.0.0.1:${localPort}/`; +const probeUrlFor = (localPort: number) => `${httpBaseUrlFor(localPort)}.well-known/t3/environment`; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +interface FakeForward { + readonly pid: number; + readonly input: { + readonly keyPath: string | null; + readonly address: string; + readonly remotePort: number; + readonly localPort: number; + }; + /** Settle to simulate the forwarder process exiting on its own. */ + readonly exit: Deferred.Deferred>; + readonly state: { + running: boolean; + /** Set when the owning scope closes, which is how the runtime stops a forwarder. */ + stopped: boolean; + }; + readonly handle: TailcatRuntime.TailcatForwardHandle; +} + +interface Harness { + readonly layer: Layer.Layer; + /** Every `forward` call in order, whether or not it became ready. */ + readonly forwards: ReadonlyArray; + readonly probeRequests: ReadonlyArray; + readonly pings: ReadonlyArray<{ readonly keyPath: string | null; readonly address: string }>; + /** Whether the fake T3 server behind the tunnel answers the readiness probe. */ + readonly setRemoteHealthy: (healthy: boolean) => void; + /** Settles once the n-th (1-based) forward has been spawned. */ + readonly spawned: (count: number) => Effect.Effect; +} + +function makeHarness(options?: { + readonly resolve?: Effect.Effect; +}): Harness { + const forwards: Array = []; + const probeRequests: Array = []; + const pings: Array<{ readonly keyPath: string | null; readonly address: string }> = []; + const spawnSignals = new Map>(); + let remoteHealthy = true; + let nextPort = FIRST_PORT; + + const spawnSignal = (count: number) => { + const existing = spawnSignals.get(count); + if (existing !== undefined) { + return existing; + } + const created = Deferred.makeUnsafe(); + spawnSignals.set(count, created); + return created; + }; + + const runtimeLayer = Layer.mock(TailcatRuntime.TailcatRuntime)({ + resolve: options?.resolve ?? Effect.succeed(RUNTIME_INFO), + forward: (input) => + Effect.gen(function* () { + const exit = yield* Deferred.make>(); + const state = { running: true, stopped: false }; + const handle: TailcatRuntime.TailcatForwardHandle = { + pid: 5000 + forwards.length + 1, + address: input.address, + remotePort: input.remotePort, + localPort: input.localPort, + httpBaseUrl: httpBaseUrlFor(input.localPort), + wsBaseUrl: `ws://127.0.0.1:${input.localPort}/`, + exit: Deferred.await(exit), + isRunning: Effect.sync(() => state.running), + recentOutput: Effect.succeed(RECENT_OUTPUT), + stop: Effect.sync(() => { + state.running = false; + }), + }; + forwards.push({ + pid: handle.pid, + input: { + keyPath: input.keyPath, + address: input.address, + remotePort: input.remotePort, + localPort: input.localPort, + }, + exit, + state, + handle, + }); + yield* Deferred.done(spawnSignal(forwards.length), Exit.void); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + state.running = false; + state.stopped = true; + }), + ); + if (input.readiness !== undefined) { + // Like the runtime, a failed probe kills the forwarder before the error surfaces. + yield* input.readiness({ httpBaseUrl: handle.httpBaseUrl }).pipe( + Effect.onError(() => + Effect.sync(() => { + state.running = false; + }), + ), + ); + } + return handle; + }), + ping: (input) => + Effect.sync(() => { + pings.push({ keyPath: input.keyPath, address: input.address }); + return PATH_PROBE; + }), + }); + + const identityLayer = Layer.mock(DesktopTailcatIdentity.DesktopTailcatIdentity)({ + nodeKey: Effect.succeed(NODE_KEY), + encrypted: Effect.succeed(true), + withKeyFile: (use) => use(KEY_PATH), + }); + + const netLayer = Layer.mock(NetService.NetService)({ + reserveLoopbackPort: () => + Effect.sync(() => { + const port = nextPort; + nextPort += 1; + return port; + }), + }); + + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + probeRequests.push(request.url); + return remoteHealthy + ? jsonResponse(request, DESCRIPTOR) + : jsonResponse(request, { error: "server offline" }, 503); + }), + ), + ); + + return { + layer: DesktopTailcatEnvironment.layer.pipe( + Layer.provide(Layer.mergeAll(runtimeLayer, identityLayer, netLayer, httpClientLayer)), + ), + forwards, + probeRequests, + pings, + setRemoteHealthy: (healthy) => { + remoteHealthy = healthy; + }, + spawned: (count) => Deferred.await(spawnSignal(count)), + }; +} + +describe("DesktopTailcatEnvironment", () => { + it.effect("ensureEnvironment forwards a reserved loopback port and reports the bootstrap", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const bootstrap = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.deepEqual(bootstrap, { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT, + httpBaseUrl: httpBaseUrlFor(FIRST_PORT), + wsBaseUrl: `ws://127.0.0.1:${FIRST_PORT}/`, + clientNodeKey: NODE_KEY, + }); + assert.equal(harness.forwards.length, 1); + assert.deepEqual(harness.forwards[0]?.input, { + keyPath: KEY_PATH, + address: ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT, + }); + assert.deepEqual(harness.probeRequests, [probeUrlFor(FIRST_PORT)]); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.deepEqual(diagnostics.value, { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, + status: "ready", + localEndpoint: httpBaseUrlFor(FIRST_PORT), + pid: 5001, + runtime: RUNTIME_INFO, + clientNodeKey: NODE_KEY, + path: null, + startedAt: EPOCH_ISO, + restartCount: 0, + lastError: null, + recentOutput: RECENT_OUTPUT, + }); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("reuses a healthy forward instead of spawning again", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const second = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(harness.forwards.length, 1); + assert.isFalse(harness.forwards[0]?.state.stopped); + assert.equal(second.localPort, first.localPort); + assert.deepEqual(second, first); + // The second call only re-probes the tunnel that is already up. + assert.deepEqual(harness.probeRequests, [probeUrlFor(FIRST_PORT), probeUrlFor(FIRST_PORT)]); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("moves a connection to a new address by replacing its forward", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const moved = yield* environment.ensureEnvironment({ + ...ENSURE_INPUT, + address: OTHER_ADDRESS, + }); + + assert.equal(harness.forwards.length, 2); + assert.isTrue(harness.forwards[0]?.state.stopped); + assert.isFalse(harness.forwards[1]?.state.stopped); + assert.deepEqual(harness.forwards[1]?.input, { + keyPath: KEY_PATH, + address: OTHER_ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT + 1, + }); + assert.equal(moved.address, OTHER_ADDRESS); + assert.equal(moved.localPort, FIRST_PORT + 1); + assert.notEqual(moved.localPort, first.localPort); + assert.equal(moved.httpBaseUrl, httpBaseUrlFor(FIRST_PORT + 1)); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.address, OTHER_ADDRESS); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.pid, 5002); + assert.equal(diagnostics.value.restartCount, 0); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("fails ensureEnvironment when the remote never answers through the tunnel", () => { + const harness = makeHarness(); + harness.setRemoteHealthy(false); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const error = yield* environment.ensureEnvironment(ENSURE_INPUT).pipe(Effect.flip); + + assert.instanceOf(error, DesktopTailcatEnvironment.DesktopTailcatEnvironmentError); + assert.equal(error.code, "remote-unavailable"); + assert.equal(error.connectionId, CONNECTION_ID); + assert.isTrue(error.message.startsWith("[tailcat:remote-unavailable] ")); + assert.include(error.message, "not trusted"); + assert.include(error.message, "offline"); + // The failed attempt's forwarder went down with its scope. + assert.equal(harness.forwards.length, 1); + assert.isTrue(harness.forwards[0]?.state.stopped); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "failed"); + assert.equal(diagnostics.value.pid, null); + assert.equal(diagnostics.value.localEndpoint, null); + assert.equal(diagnostics.value.startedAt, null); + assert.deepEqual(diagnostics.value.lastError, { + code: "remote-unavailable", + message: error.detail, + at: EPOCH_ISO, + }); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("restarts a forward that exits on its own", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const [initial] = harness.forwards; + assert(initial !== undefined); + + yield* Deferred.succeed(initial.exit, Option.some(1)); + // Let the exit monitor run, then cover the longest first backoff step. + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(FIRST_BACKOFF_MAX_MS)); + yield* harness.spawned(2); + // The next ensure waits behind the connection lock until the restart has settled. + const after = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(harness.forwards.length, 2); + assert.equal(after.localPort, first.localPort); + assert.deepEqual(harness.forwards[1]?.input, initial.input); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.restartCount, 1); + assert.equal(diagnostics.value.pid, 5002); + assert.equal(diagnostics.value.lastError, null); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("backs off before restarting a forward that already failed once", () => { + const harness = makeHarness(); + harness.setRemoteHealthy(false); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + // The first attempt fails and counts as the connection's first consecutive failure. + yield* environment.ensureEnvironment(ENSURE_INPUT).pipe(Effect.flip); + harness.setRemoteHealthy(true); + const bootstrap = yield* environment.ensureEnvironment(ENSURE_INPUT); + assert.equal(bootstrap.localPort, FIRST_PORT); + assert.equal(harness.forwards.length, 2); + const running = harness.forwards[1]; + assert(running !== undefined); + + yield* Deferred.succeed(running.exit, Option.some(137)); + // Let the exit monitor record the failure and schedule the restart. + yield* Effect.yieldNow; + + const failed = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(failed)); + assert.equal(failed.value.status, "failed"); + assert.equal(failed.value.pid, null); + assert.equal(failed.value.restartCount, 0); + assert.deepEqual(failed.value.lastError, { + code: "process-exited", + message: "The Tailcat forwarder exited with code 137.", + at: EPOCH_ISO, + }); + + // This is the second consecutive failure, so nothing restarts before the + // shortest jittered second step has passed. + yield* TestClock.adjust(Duration.millis(SECOND_BACKOFF_MIN_MS - 1)); + assert.equal(harness.forwards.length, 2); + // The longest jittered second step is enough for any random sample. + yield* TestClock.adjust(Duration.millis(SECOND_BACKOFF_MAX_MS - SECOND_BACKOFF_MIN_MS + 1)); + assert.equal(harness.forwards.length, 3); + yield* harness.spawned(3); + // The next ensure waits behind the connection lock until the restart has settled. + const after = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(after.localPort, FIRST_PORT); + assert.equal(harness.forwards.length, 3); + const restarted = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(restarted)); + assert.equal(restarted.value.status, "ready"); + assert.equal(restarted.value.restartCount, 1); + assert.equal(restarted.value.pid, 5003); + assert.equal(restarted.value.lastError, null); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("disconnectEnvironment stops the forward and forgets the connection", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + yield* environment.ensureEnvironment(ENSURE_INPUT); + const [initial] = harness.forwards; + assert(initial !== undefined); + + yield* environment.disconnectEnvironment(CONNECTION_ID); + + assert.isTrue(initial.state.stopped); + assert.isFalse(yield* initial.handle.isRunning); + assert.isTrue(Option.isNone(yield* environment.diagnostics(CONNECTION_ID))); + + // The stopped forwarder's exit arrives afterwards and must not restart anything. + yield* Deferred.succeed(initial.exit, Option.some(0)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(FIRST_BACKOFF_MAX_MS)); + assert.equal(harness.forwards.length, 1); + assert.isTrue(Option.isNone(yield* environment.diagnostics(CONNECTION_ID))); + + // Disconnecting an unknown connection is a no-op. + yield* environment.disconnectEnvironment(CONNECTION_ID); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("restartEnvironment replaces the forward and counts the restart", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + + const restarted = yield* environment.restartEnvironment(CONNECTION_ID); + + assert.equal(harness.forwards.length, 2); + assert.isTrue(harness.forwards[0]?.state.stopped); + assert.isFalse(harness.forwards[1]?.state.stopped); + assert.deepEqual(restarted, first); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.restartCount, 1); + assert.equal(diagnostics.value.pid, 5002); + + const missing = yield* environment.restartEnvironment("unknown-connection").pipe(Effect.flip); + assert.equal(missing.code, "unknown"); + assert.equal(missing.connectionId, "unknown-connection"); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("probePath records the measured path in diagnostics", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + assert.isTrue(Option.isNone(yield* environment.probePath(CONNECTION_ID))); + assert.equal(harness.pings.length, 0); + + yield* environment.ensureEnvironment(ENSURE_INPUT); + const probed = yield* environment.probePath(CONNECTION_ID); + + assert(Option.isSome(probed)); + assert.deepEqual(probed.value.path, PATH_PROBE); + assert.deepEqual(harness.pings, [{ keyPath: KEY_PATH, address: ADDRESS }]); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.deepEqual(diagnostics.value.path, PATH_PROBE); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("runtimeAvailability maps a missing binary to an unavailable runtime", () => { + const available = makeHarness(); + const missing = makeHarness({ + resolve: Effect.fail( + new TailcatBinaryMissingError({ + candidates: ["/opt/t3/resources/tailcat/linux-x64/tailcat"], + detail: "The Tailcat runtime is not available.", + }), + ), + }); + return Effect.gen(function* () { + const availability = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment.pipe( + Effect.flatMap((environment) => environment.runtimeAvailability), + Effect.provide(available.layer), + ); + assert.deepEqual(availability, { available: true, runtime: RUNTIME_INFO }); + + const unavailable = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment.pipe( + Effect.flatMap((environment) => environment.runtimeAvailability), + Effect.provide(missing.layer), + ); + assert.deepEqual(unavailable, { + available: false, + code: "binary-missing", + message: "The Tailcat runtime is not available.", + }); + }); + }); +}); diff --git a/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts new file mode 100644 index 000000000000..83a8fd75999f --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts @@ -0,0 +1,591 @@ +import type { + DesktopTailcatEnvironmentBootstrap, + DesktopTailcatEnvironmentEnsureInput, + TailcatAddress, + TailcatConnectionDiagnostics, + TailcatFailure, + TailcatFailureCode, + TailcatForwardStatus, + TailcatPathProbe, + TailcatRuntimeAvailability, + TailcatRuntimeInfo, +} from "@t3tools/contracts"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import * as NetService from "@t3tools/shared/Net"; +import { tailcatBackoffDelayMs, TAILCAT_BACKOFF_RESET_AFTER_MS } from "@t3tools/tailcat/backoff"; +import type { TailcatRuntimeError } from "@t3tools/tailcat/errors"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +/** + * Desktop-side Tailcat transport: one `tailcat forward` per saved Tailcat + * environment, bound to a reserved loopback port, supervised for the lifetime + * of the app. The renderer only ever sees `http://127.0.0.1:`; T3 auth, + * pairing, and RPC run over that unchanged. + * + * Failure policy: a forward that exits on its own is restarted with jittered + * exponential backoff; a forward that starts but never passes the readiness + * probe (typical for "not trusted yet" or "server offline") fails the + * `ensure` call so the connection supervisor in the client can decide, and the + * probe result is kept for diagnostics. + */ + +export const TAILCAT_FORWARD_READINESS_TIMEOUT = Duration.seconds(20); +export const TAILCAT_FORWARD_MAX_RESTARTS = 8; + +export class DesktopTailcatEnvironmentError extends Schema.TaggedErrorClass()( + "DesktopTailcatEnvironmentError", + { + code: Schema.Literals([ + "binary-missing", + "binary-not-executable", + "version-incompatible", + "identity-failed", + "startup-failed", + "process-exited", + "timeout", + "address-invalid", + "port-in-use", + "remote-unavailable", + "unknown", + ]), + detail: Schema.String, + connectionId: Schema.optionalKey(Schema.String), + }, +) { + /** + * The message crosses the IPC boundary as plain text, so it carries a + * machine-readable prefix the renderer can map back to a failure code. + */ + override get message(): string { + return `[tailcat:${this.code}] ${this.detail}`; + } +} + +export class DesktopTailcatEnvironment extends Context.Service< + DesktopTailcatEnvironment, + { + readonly runtimeAvailability: Effect.Effect; + readonly ensureEnvironment: ( + input: DesktopTailcatEnvironmentEnsureInput, + ) => Effect.Effect; + readonly restartEnvironment: ( + connectionId: string, + ) => Effect.Effect; + readonly disconnectEnvironment: (connectionId: string) => Effect.Effect; + readonly diagnostics: ( + connectionId: string, + ) => Effect.Effect>; + readonly probePath: ( + connectionId: string, + ) => Effect.Effect, DesktopTailcatEnvironmentError>; + } +>()("@t3tools/desktop/tailcat/DesktopTailcatEnvironment") {} + +interface RunningForward { + readonly scope: Scope.Closeable; + readonly handle: TailcatRuntime.TailcatForwardHandle; + readonly startedAt: string; + readonly monitor: Fiber.Fiber; +} + +interface ForwardEntry { + readonly connectionId: string; + readonly address: TailcatAddress; + readonly remotePort: number; + readonly localPort: number; + readonly status: TailcatForwardStatus; + readonly running: RunningForward | null; + readonly restartCount: number; + readonly consecutiveFailures: number; + readonly lastError: TailcatFailure | null; + readonly path: TailcatPathProbe | null; + /** Set while a disconnect is in progress so the monitor does not restart. */ + readonly stopping: boolean; + /** Bumped per spawned forward so a stale exit monitor never acts on a newer one. */ + readonly generation: number; +} + +const describe = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const isDesktopTailcatEnvironmentError = Schema.is(DesktopTailcatEnvironmentError); + +function failureCodeOf( + error: TailcatRuntimeError | DesktopTailcatIdentity.DesktopTailcatIdentityError, +): TailcatFailureCode { + switch (error._tag) { + case "TailcatBinaryMissingError": + return "binary-missing"; + case "TailcatBinaryNotExecutableError": + return "binary-not-executable"; + case "TailcatVersionIncompatibleError": + return "version-incompatible"; + case "TailcatAddressInvalidError": + return "address-invalid"; + case "TailcatPortInUseError": + return "port-in-use"; + case "TailcatStartupError": + return "startup-failed"; + case "TailcatTimeoutError": + return "timeout"; + case "TailcatProcessExitedError": + return "process-exited"; + case "TailcatCommandError": + return "unknown"; + case "DesktopTailcatIdentityError": + return "identity-failed"; + } +} + +export const make = Effect.gen(function* () { + const runtime = yield* TailcatRuntime.TailcatRuntime; + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + const net = yield* NetService.NetService; + const httpClient = yield* HttpClient.HttpClient; + const serviceScope = yield* Scope.Scope; + const entries = yield* Ref.make>(new Map()); + const locks = yield* Ref.make>(new Map()); + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const lockFor = (connectionId: string) => + Effect.gen(function* () { + const current = (yield* Ref.get(locks)).get(connectionId); + if (current !== undefined) { + return current; + } + const created = yield* Semaphore.make(1); + yield* Ref.update(locks, (map) => new Map(map).set(connectionId, created)); + return created; + }); + + const withLock = (connectionId: string, effect: Effect.Effect) => + lockFor(connectionId).pipe(Effect.flatMap((lock) => lock.withPermits(1)(effect))); + + const getEntry = (connectionId: string) => + Ref.get(entries).pipe(Effect.map((map) => Option.fromUndefinedOr(map.get(connectionId)))); + + const setEntry = (entry: ForwardEntry) => + Ref.update(entries, (map) => new Map(map).set(entry.connectionId, entry)); + + const patchEntry = (connectionId: string, patch: (entry: ForwardEntry) => ForwardEntry) => + Ref.update(entries, (map) => { + const current = map.get(connectionId); + return current === undefined ? map : new Map(map).set(connectionId, patch(current)); + }); + + const failure = (code: TailcatFailureCode, message: string): Effect.Effect => + nowIso.pipe(Effect.map((at) => ({ code, message, at }))); + + const runtimeAvailability: DesktopTailcatEnvironment["Service"]["runtimeAvailability"] = + runtime.resolve.pipe( + Effect.map((info): TailcatRuntimeAvailability => ({ available: true, runtime: info })), + Effect.catch((error) => + Effect.succeed({ + available: false, + code: failureCodeOf(error), + message: error.message, + }), + ), + ); + + const runtimeInfo: Effect.Effect = runtime.resolve.pipe( + Effect.map((info): TailcatRuntimeInfo | null => info), + Effect.orElseSucceed(() => null), + ); + + const readiness = (endpoint: { readonly httpBaseUrl: string }) => + fetchRemoteEnvironmentDescriptor({ httpBaseUrl: endpoint.httpBaseUrl, timeoutMs: 4_000 }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.asVoid, + Effect.mapError( + (cause) => + new DesktopTailcatEnvironmentError({ + code: "remote-unavailable", + detail: `The T3 server did not answer through the tunnel: ${describe(cause)}`, + }), + ), + ); + + const stopRunning = (running: RunningForward) => + Fiber.interrupt(running.monitor).pipe( + Effect.andThen(Scope.close(running.scope, Exit.void).pipe(Effect.ignore)), + ); + + /** Starts (or restarts) the forward for an entry; the entry must be locked. */ + const startForward = ( + entry: ForwardEntry, + ): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Scope.make("sequential"); + yield* setEntry({ ...entry, status: "starting", running: null, stopping: false }); + const started = yield* identity + .withKeyFile((keyPath) => + runtime.forward({ + keyPath, + address: entry.address, + remotePort: entry.remotePort, + localPort: entry.localPort, + readiness, + readinessTimeout: TAILCAT_FORWARD_READINESS_TIMEOUT, + }), + ) + .pipe( + Scope.provide(scope), + Effect.mapError((error) => + isDesktopTailcatEnvironmentError(error) + ? new DesktopTailcatEnvironmentError({ + code: error.code, + detail: `${error.detail} The environment may be offline, or this device is not trusted yet: redeem a fresh connection code.`, + connectionId: entry.connectionId, + }) + : new DesktopTailcatEnvironmentError({ + code: failureCodeOf(error), + detail: error.message, + connectionId: entry.connectionId, + }), + ), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + Effect.tapError((error) => + failure(error.code, error.detail).pipe( + Effect.flatMap((recorded) => + patchEntry(entry.connectionId, (current) => ({ + ...current, + status: "failed", + running: null, + consecutiveFailures: current.consecutiveFailures + 1, + lastError: recorded, + })), + ), + ), + ), + ); + const startedAt = yield* nowIso; + const generation = entry.generation + 1; + // The monitor only starts watching once the ready entry is published, so + // an immediate exit cannot be recorded and then overwritten by "ready". + const published = yield* Deferred.make(); + const monitor = yield* Deferred.await(published).pipe( + Effect.andThen(started.exit), + Effect.flatMap((exitCode) => onForwardExit(entry.connectionId, generation, exitCode)), + Effect.forkIn(serviceScope), + ); + const next: ForwardEntry = { + ...entry, + status: "ready", + running: { scope, handle: started, startedAt, monitor }, + stopping: false, + lastError: null, + generation, + }; + yield* setEntry(next); + yield* Deferred.succeed(published, undefined); + yield* Effect.logInfo("Tailcat forward ready.", { + connectionId: entry.connectionId, + localPort: entry.localPort, + remotePort: entry.remotePort, + pid: started.pid, + }); + return next; + }); + + /** + * Unexpected exit: record it and restart with backoff unless stopping. The + * generation guard makes a monitor from an older forward a no-op once the + * connection was re-ensured or restarted. + */ + const onForwardExit = ( + connectionId: string, + generation: number, + exitCode: Option.Option, + ) => + Effect.gen(function* () { + const current = yield* getEntry(connectionId); + if ( + Option.isNone(current) || + current.value.stopping || + current.value.generation !== generation + ) { + return; + } + const recorded = yield* failure( + "process-exited", + `The Tailcat forwarder exited${Option.isSome(exitCode) ? ` with code ${exitCode.value}` : ""}.`, + ); + // This exit is the connection's next consecutive failure (1 = first). + const failures = current.value.consecutiveFailures + 1; + yield* patchEntry(connectionId, (entry) => ({ + ...entry, + status: "failed", + running: null, + consecutiveFailures: failures, + lastError: recorded, + })); + yield* Effect.logWarning("Tailcat forward exited unexpectedly.", { + connectionId, + exitCode: Option.getOrNull(exitCode), + failures, + }); + if (failures > TAILCAT_FORWARD_MAX_RESTARTS) { + yield* Effect.logWarning("Tailcat forward gave up restarting; waiting for the client.", { + connectionId, + failures, + }); + return; + } + const random = yield* Random.next; + yield* Effect.sleep(Duration.millis(tailcatBackoffDelayMs(failures, random))); + yield* withLock( + connectionId, + Effect.gen(function* () { + const latest = yield* getEntry(connectionId); + if ( + Option.isNone(latest) || + latest.value.stopping || + latest.value.running !== null || + latest.value.generation !== generation + ) { + return; + } + yield* startForward({ + ...latest.value, + restartCount: latest.value.restartCount + 1, + }).pipe(Effect.ignore); + }), + ); + }); + + const bootstrapOf = (entry: ForwardEntry, running: RunningForward) => + identity.nodeKey.pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: "identity-failed", + detail: error.message, + connectionId: entry.connectionId, + }), + ), + Effect.map((clientNodeKey): DesktopTailcatEnvironmentBootstrap => ({ + connectionId: entry.connectionId, + address: entry.address, + remotePort: entry.remotePort, + localPort: entry.localPort, + httpBaseUrl: running.handle.httpBaseUrl, + wsBaseUrl: running.handle.wsBaseUrl, + clientNodeKey, + })), + ); + + const ensureEnvironment: DesktopTailcatEnvironment["Service"]["ensureEnvironment"] = (input) => + withLock( + input.connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(input.connectionId); + const running = Option.isSome(existing) ? existing.value.running : null; + if (Option.isSome(existing) && running !== null) { + const entry = existing.value; + const sameTarget = + entry.address === input.address && entry.remotePort === input.remotePort; + const alive = yield* running.handle.isRunning; + if (sameTarget && alive) { + // A healthy forward stays; a stale readiness only costs one probe. + const healthy = yield* readiness({ httpBaseUrl: running.handle.httpBaseUrl }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (healthy) { + // Fresh use resets the failure budget for the supervisor. + yield* patchEntry(input.connectionId, (current) => ({ + ...current, + consecutiveFailures: 0, + })); + return yield* bootstrapOf(entry, running); + } + } + yield* patchEntry(input.connectionId, (current) => ({ ...current, stopping: true })); + yield* stopRunning(running); + } + const localPort = + Option.isSome(existing) && existing.value.address === input.address + ? existing.value.localPort + : yield* net.reserveLoopbackPort().pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: "port-in-use", + detail: `Could not reserve a loopback port: ${error.message}`, + connectionId: input.connectionId, + }), + ), + ); + const resetFailures = + Option.isSome(existing) && + existing.value.lastError !== null && + DateTime.toEpochMillis(DateTime.makeUnsafe(existing.value.lastError.at)) + + TAILCAT_BACKOFF_RESET_AFTER_MS < + (yield* DateTime.now.pipe(Effect.map(DateTime.toEpochMillis))); + const base: ForwardEntry = { + connectionId: input.connectionId, + address: input.address, + remotePort: input.remotePort, + localPort, + status: "starting", + running: null, + restartCount: Option.isSome(existing) ? existing.value.restartCount : 0, + consecutiveFailures: + Option.isSome(existing) && !resetFailures ? existing.value.consecutiveFailures : 0, + lastError: Option.isSome(existing) ? existing.value.lastError : null, + path: Option.isSome(existing) ? existing.value.path : null, + stopping: false, + generation: Option.isSome(existing) ? existing.value.generation : 0, + }; + const started = yield* startForward(base); + return yield* bootstrapOf(started, started.running!); + }), + ); + + const restartEnvironment: DesktopTailcatEnvironment["Service"]["restartEnvironment"] = ( + connectionId, + ) => + withLock( + connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return yield* new DesktopTailcatEnvironmentError({ + code: "unknown", + detail: "This Tailcat environment has no active tunnel to restart.", + connectionId, + }); + } + if (existing.value.running !== null) { + yield* patchEntry(connectionId, (current) => ({ ...current, stopping: true })); + yield* stopRunning(existing.value.running); + } + const started = yield* startForward({ + ...existing.value, + restartCount: existing.value.restartCount + 1, + consecutiveFailures: 0, + }); + return yield* bootstrapOf(started, started.running!); + }), + ); + + const disconnectEnvironment: DesktopTailcatEnvironment["Service"]["disconnectEnvironment"] = ( + connectionId, + ) => + withLock( + connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return; + } + yield* patchEntry(connectionId, (current) => ({ ...current, stopping: true })); + if (existing.value.running !== null) { + yield* stopRunning(existing.value.running); + } + yield* Ref.update(entries, (map) => { + const next = new Map(map); + next.delete(connectionId); + return next; + }); + yield* Effect.logInfo("Tailcat forward stopped.", { connectionId }); + }), + ); + + const diagnosticsOf = (entry: ForwardEntry) => + Effect.gen(function* () { + const recentOutput = entry.running === null ? [] : yield* entry.running.handle.recentOutput; + const clientNodeKey = yield* identity.nodeKey.pipe(Effect.option); + return { + connectionId: entry.connectionId, + address: entry.address, + remotePort: entry.remotePort, + status: entry.status, + localEndpoint: entry.running === null ? null : entry.running.handle.httpBaseUrl, + pid: entry.running === null ? null : entry.running.handle.pid, + runtime: yield* runtimeInfo, + clientNodeKey: Option.getOrNull(clientNodeKey), + path: entry.path, + startedAt: entry.running === null ? null : entry.running.startedAt, + restartCount: entry.restartCount, + lastError: entry.lastError, + recentOutput, + } satisfies TailcatConnectionDiagnostics; + }); + + const diagnostics: DesktopTailcatEnvironment["Service"]["diagnostics"] = (connectionId) => + getEntry(connectionId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (entry) => diagnosticsOf(entry).pipe(Effect.map(Option.some)), + }), + ), + ); + + const probePath: DesktopTailcatEnvironment["Service"]["probePath"] = (connectionId) => + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return Option.none(); + } + const probe = yield* identity + .withKeyFile((keyPath) => runtime.ping({ keyPath, address: existing.value.address })) + .pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: failureCodeOf(error), + detail: error.message, + connectionId, + }), + ), + ); + yield* patchEntry(connectionId, (entry) => ({ ...entry, path: probe })); + return yield* diagnostics(connectionId); + }); + + // App shutdown takes every forwarder down with it. + yield* Effect.addFinalizer(() => + Ref.get(entries).pipe( + Effect.flatMap((map) => + Effect.forEach( + map.values(), + (entry) => + entry.running === null + ? Effect.void + : Scope.close(entry.running.scope, Exit.void).pipe(Effect.ignore), + { discard: true }, + ), + ), + ), + ); + + return DesktopTailcatEnvironment.of({ + runtimeAvailability, + ensureEnvironment, + restartEnvironment, + disconnectEnvironment, + diagnostics, + probePath, + }); +}); + +export const layer = Layer.effect(DesktopTailcatEnvironment, make); diff --git a/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts b/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts new file mode 100644 index 000000000000..c5d3f527fcc4 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts @@ -0,0 +1,262 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +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 Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +const NODE_KEY = `nodekey:${"3c".repeat(32)}`; +const KEY_FILE_TEXT = `privkey:${"5a".repeat(32)}\n`; +const ENCRYPTED_PREFIX = "enc:"; +// The TestClock starts at the epoch, so the stored record's timestamp is fixed. +const EPOCH_ISO = "1970-01-01T00:00:00.000Z"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const IdentityRecordJson = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + nodeKey: Schema.String, + keyFile: Schema.String, + createdAt: Schema.String, + }), +); +const decodeIdentityRecord = Schema.decodeUnknownEffect(IdentityRecordJson); + +function makeSafeStorageLayer(encryptionAvailable: boolean) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: Effect.succeed(encryptionAvailable), + encryptString: (value) => Effect.succeed(textEncoder.encode(`${ENCRYPTED_PREFIX}${value}`)), + decryptString: (value) => { + const decoded = textDecoder.decode(value); + return decoded.startsWith(ENCRYPTED_PREFIX) + ? Effect.succeed(decoded.slice(ENCRYPTED_PREFIX.length)) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("not encrypted by this test"), + }), + ); + }, + selectedStorageBackend: Effect.succeed(Option.none()), + } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); +} + +/** Only `stateDir` and `path` matter to the identity; the rest is a plausible desktop. */ +function makeEnvironmentLayer(baseDir: string) { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "linux", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); +} + +/** A tailcat that writes a fixed private key wherever it is asked to. */ +function makeRuntimeLayer(generatedKeyPaths: Array) { + return Layer.unwrap( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return Layer.mock(TailcatRuntime.TailcatRuntime)({ + generateClientIdentity: ({ keyPath }) => + Effect.gen(function* () { + generatedKeyPaths.push(keyPath); + yield* fileSystem + .writeFileString(keyPath, KEY_FILE_TEXT, { mode: 0o600 }) + .pipe(Effect.orDie); + return { nodeKey: NODE_KEY }; + }), + }); + }), + ).pipe(Layer.provide(NodeServices.layer)); +} + +/** One fresh identity service instance over the desktop state below `baseDir`. */ +function makeIdentityLayer( + baseDir: string, + options: { + readonly encryptionAvailable: boolean; + readonly generatedKeyPaths: Array; + }, +) { + return DesktopTailcatIdentity.layer.pipe( + Layer.provide( + Layer.mergeAll( + makeEnvironmentLayer(baseDir), + makeSafeStorageLayer(options.encryptionAvailable), + makeRuntimeLayer(options.generatedKeyPaths), + NodeServices.layer, + ), + ), + ); +} + +const readIdentity = Effect.gen(function* () { + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + return { nodeKey: yield* identity.nodeKey, encrypted: yield* identity.encrypted }; +}); + +const withTempStateDirectory = ( + use: (paths: { + readonly baseDir: string; + readonly identityDir: string; + readonly tempDir: string; + readonly encryptedPath: string; + readonly plaintextPath: string; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-tailcat-identity-" }); + // The desktop keeps packaged state under `/userdata`. + const identityDir = path.join( + baseDir, + "userdata", + DesktopTailcatIdentity.DESKTOP_TAILCAT_IDENTITY_DIRECTORY, + ); + return yield* use({ + baseDir, + identityDir, + tempDir: path.join(identityDir, "tmp"), + encryptedPath: path.join(identityDir, "client-identity.enc"), + plaintextPath: path.join(identityDir, "client-identity.private.json"), + }); + }).pipe(Effect.provide(NodeServices.layer)); + +describe("DesktopTailcatIdentity", () => { + it.effect("generates the identity once and stores it encrypted", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const generatedKeyPaths: Array = []; + const options = { encryptionAvailable: true, generatedKeyPaths }; + + const first = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.equal(first.nodeKey, NODE_KEY); + assert.isTrue(first.encrypted); + const [generatedKeyPath] = generatedKeyPaths; + assert(generatedKeyPath !== undefined); + assert.equal(generatedKeyPaths.length, 1); + assert.equal(path.dirname(generatedKeyPath), paths.tempDir); + assert.isFalse(yield* fileSystem.exists(generatedKeyPath)); + assert.isTrue(yield* fileSystem.exists(paths.encryptedPath)); + assert.isFalse(yield* fileSystem.exists(paths.plaintextPath)); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + + const stored = textDecoder.decode(yield* fileSystem.readFile(paths.encryptedPath)); + assert.isTrue(stored.startsWith(ENCRYPTED_PREFIX)); + const record = yield* decodeIdentityRecord(stored.slice(ENCRYPTED_PREFIX.length)); + assert.deepEqual(record, { + version: 1, + nodeKey: NODE_KEY, + keyFile: KEY_FILE_TEXT, + createdAt: EPOCH_ISO, + }); + + // A key file left behind by a crashed process is swept when the next instance starts. + yield* fileSystem.writeFileString(path.join(paths.tempDir, "stale.key"), "privkey:stale"); + const second = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.deepEqual(second, first); + assert.equal(generatedKeyPaths.length, 1); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + }), + ), + ); + + it.effect("materializes a private key file only for the duration of use", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + + const observed = yield* identity.withKeyFile((keyPath) => + Effect.gen(function* () { + const info = yield* fileSystem.stat(keyPath); + return { + keyPath, + contents: yield* fileSystem.readFileString(keyPath), + mode: info.mode & 0o777, + }; + }), + ); + + assert.equal(path.dirname(observed.keyPath), paths.tempDir); + assert.equal(observed.contents, KEY_FILE_TEXT); + assert.equal(observed.mode, 0o600); + assert.isFalse(yield* fileSystem.exists(observed.keyPath)); + + const failure = yield* identity + .withKeyFile((keyPath) => Effect.fail({ _tag: "UseFailed" as const, keyPath })) + .pipe(Effect.flip); + + assert(failure._tag === "UseFailed"); + assert.notEqual(failure.keyPath, observed.keyPath); + assert.isFalse(yield* fileSystem.exists(failure.keyPath)); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + }).pipe( + Effect.provide( + makeIdentityLayer(paths.baseDir, { encryptionAvailable: true, generatedKeyPaths: [] }), + ), + ), + ), + ); + + it.effect("falls back to a private plaintext file when OS encryption is unavailable", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const generatedKeyPaths: Array = []; + const options = { encryptionAvailable: false, generatedKeyPaths }; + + const first = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.equal(first.nodeKey, NODE_KEY); + assert.isFalse(first.encrypted); + assert.isFalse(yield* fileSystem.exists(paths.encryptedPath)); + assert.isTrue(yield* fileSystem.exists(paths.plaintextPath)); + const info = yield* fileSystem.stat(paths.plaintextPath); + assert.equal(info.mode & 0o777, 0o600); + const record = yield* decodeIdentityRecord( + yield* fileSystem.readFileString(paths.plaintextPath), + ); + assert.equal(record.nodeKey, NODE_KEY); + assert.equal(record.keyFile, KEY_FILE_TEXT); + + const second = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.deepEqual(second, first); + assert.equal(generatedKeyPaths.length, 1); + }), + ), + ); +}); diff --git a/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts b/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts new file mode 100644 index 000000000000..1038ef335330 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts @@ -0,0 +1,272 @@ +import type { TailcatNodeKey } from "@t3tools/contracts"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +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 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 * as Semaphore from "effect/Semaphore"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; + +/** + * The desktop's Tailcat client identity: the private key T3 servers trust + * after a connection code is redeemed. It is stored encrypted with Electron's + * safeStorage (OS keychain / DPAPI / libsecret) and only materialized as a + * 0600 temp file for the moments a `tailcat` process needs to read it. + * + * When the OS offers no encryption backend, the key falls back to a 0600 + * plaintext file inside the desktop state directory, which is still private to + * the user account; the fallback is logged so support can see it. + */ + +export const DESKTOP_TAILCAT_IDENTITY_DIRECTORY = "tailcat"; +const ENCRYPTED_IDENTITY_FILE = "client-identity.enc"; +const PLAINTEXT_IDENTITY_FILE = "client-identity.private.json"; +const TEMP_DIRECTORY = "tmp"; + +const IdentityRecord = Schema.Struct({ + version: Schema.Literal(1), + nodeKey: Schema.String, + keyFile: Schema.String, + createdAt: Schema.String, +}); +type IdentityRecord = typeof IdentityRecord.Type; +const IdentityRecordJson = Schema.fromJsonString(IdentityRecord); +const decodeIdentityRecord = Schema.decodeUnknownEffect(IdentityRecordJson); +const encodeIdentityRecord = Schema.encodeEffect(IdentityRecordJson); + +export class DesktopTailcatIdentityError extends Schema.TaggedErrorClass()( + "DesktopTailcatIdentityError", + { + operation: Schema.Literals(["load", "generate", "store", "materialize"]), + detail: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Tailcat identity ${this.operation} failed: ${this.detail}`; + } +} + +export class DesktopTailcatIdentity extends Context.Service< + DesktopTailcatIdentity, + { + /** Public node key of this device, generating the identity on first use. */ + readonly nodeKey: Effect.Effect; + /** Whether the private key is protected by the OS encryption backend. */ + readonly encrypted: Effect.Effect; + /** + * Runs `use` with a temporary 0600 key file that is deleted afterwards, + * whatever the outcome. + */ + readonly withKeyFile: ( + use: (keyPath: string) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/desktop/tailcat/DesktopTailcatIdentity") {} + +const describe = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const runtime = yield* TailcatRuntime.TailcatRuntime; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + + const directory = path.join(environment.stateDir, DESKTOP_TAILCAT_IDENTITY_DIRECTORY); + const tempDirectory = path.join(directory, TEMP_DIRECTORY); + const encryptedPath = path.join(directory, ENCRYPTED_IDENTITY_FILE); + const plaintextPath = path.join(directory, PLAINTEXT_IDENTITY_FILE); + const cached = yield* Ref.make>( + Option.none(), + ); + + const ensureDirectories = Effect.gen(function* () { + yield* fileSystem.makeDirectory(tempDirectory, { recursive: true }).pipe(Effect.ignore); + yield* fileSystem.chmod(directory, 0o700).pipe(Effect.ignore); + yield* fileSystem.chmod(tempDirectory, 0o700).pipe(Effect.ignore); + }); + + // Temp key files from a previous crash must not outlive the process that + // needed them. + const sweepTempFiles = fileSystem.readDirectory(tempDirectory).pipe( + Effect.flatMap((entries) => + Effect.forEach( + entries, + (entry) => fileSystem.remove(path.join(tempDirectory, entry)).pipe(Effect.ignore), + { discard: true }, + ), + ), + Effect.ignore, + ); + yield* ensureDirectories; + yield* sweepTempFiles; + + const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( + Effect.orElseSucceed(() => false), + ); + + const readStored = Effect.gen(function* () { + const encryptedExists = yield* fileSystem + .exists(encryptedPath) + .pipe(Effect.orElseSucceed(() => false)); + if (encryptedExists) { + const bytes = yield* fileSystem.readFile(encryptedPath); + const json = yield* safeStorage.decryptString(bytes); + const record = yield* decodeIdentityRecord(json); + return Option.some({ record, encrypted: true }); + } + const plaintextExists = yield* fileSystem + .exists(plaintextPath) + .pipe(Effect.orElseSucceed(() => false)); + if (plaintextExists) { + const json = yield* fileSystem.readFileString(plaintextPath); + const record = yield* decodeIdentityRecord(json); + return Option.some({ record, encrypted: false }); + } + return Option.none<{ record: IdentityRecord; encrypted: boolean }>(); + }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "load", + detail: describe(cause), + cause, + }), + ), + ); + + const tempPath = crypto.randomUUIDv4.pipe( + Effect.map((uuid) => path.join(tempDirectory, `${uuid.replace(/-/g, "")}.key`)), + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "materialize", + detail: "Secure randomness is unavailable.", + cause, + }), + ), + ); + + const writePrivate = (filePath: string, contents: string) => + fileSystem + .writeFileString(filePath, contents, { mode: 0o600 }) + .pipe(Effect.andThen(fileSystem.chmod(filePath, 0o600).pipe(Effect.ignore))); + + const store = (record: IdentityRecord) => + Effect.gen(function* () { + const json = yield* encodeIdentityRecord(record); + if (yield* encryptionAvailable) { + const bytes = yield* safeStorage.encryptString(json); + yield* fileSystem.writeFile(encryptedPath, bytes, { mode: 0o600 }); + yield* fileSystem.chmod(encryptedPath, 0o600).pipe(Effect.ignore); + yield* fileSystem.remove(plaintextPath).pipe(Effect.ignore); + return true; + } + yield* Effect.logWarning( + "OS encryption is unavailable; the Tailcat client identity is stored as a private file.", + { path: plaintextPath }, + ); + yield* writePrivate(plaintextPath, json); + return false; + }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "store", + detail: describe(cause), + cause, + }), + ), + ); + + const generate = Effect.gen(function* () { + const keyPath = yield* tempPath; + const generated = yield* runtime.generateClientIdentity({ keyPath }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "generate", + detail: cause.message, + cause, + }), + ), + ); + const keyFile = yield* fileSystem.readFileString(keyPath).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "generate", + detail: describe(cause), + cause, + }), + ), + Effect.ensuring(fileSystem.remove(keyPath).pipe(Effect.ignore)), + ); + const record: IdentityRecord = { + version: 1, + nodeKey: generated.nodeKey, + keyFile, + createdAt: yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)), + }; + const encrypted = yield* store(record); + yield* Effect.logInfo("Created the Tailcat client identity.", { + nodeKeyFingerprint: generated.nodeKey.slice(-8), + encrypted, + }); + return { record, encrypted }; + }); + + const load = lock.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(cached); + if (Option.isSome(current)) { + return current.value; + } + const stored = yield* readStored; + const identity = Option.isSome(stored) ? stored.value : yield* generate; + yield* Ref.set(cached, Option.some(identity)); + return identity; + }), + ); + + const withKeyFile: DesktopTailcatIdentity["Service"]["withKeyFile"] = (use) => + Effect.gen(function* () { + const identity = yield* load; + const keyPath = yield* tempPath; + yield* writePrivate(keyPath, identity.record.keyFile).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "materialize", + detail: describe(cause), + cause, + }), + ), + ); + return yield* use(keyPath).pipe( + Effect.ensuring(fileSystem.remove(keyPath).pipe(Effect.ignore)), + ); + }); + + return DesktopTailcatIdentity.of({ + nodeKey: load.pipe(Effect.map((identity) => identity.record.nodeKey as TailcatNodeKey)), + encrypted: load.pipe( + Effect.map((identity) => identity.encrypted), + Effect.orElseSucceed(() => false), + ), + withKeyFile, + }); +}); + +export const layer = Layer.effect(DesktopTailcatIdentity, make); diff --git a/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts b/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts new file mode 100644 index 000000000000..3a1e4f1172e6 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts @@ -0,0 +1,65 @@ +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +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 DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +/** + * Where the desktop app looks for the Tailcat executable, in preference order: + * the developer override, the packaged `resources/tailcat//` + * directory, the dev `prod-resources` staging directory, and the monorepo's + * `native/tailcat/dist` output. A `tailcat` on PATH is the last resort and is + * still version-checked against the pinned manifest. + */ +export function desktopTailcatBundledCandidates( + environment: DesktopEnvironment.DesktopEnvironment["Service"], +): ReadonlyArray { + const architecture = environment.processArch as NodeJS.Architecture; + const executable = environment.platform === "win32" ? "tailcat.exe" : "tailcat"; + const platformKey = `${environment.platform}-${architecture}`; + const packaged = TailcatRuntime.bundledTailcatCandidates({ + platform: environment.platform, + architecture, + joinPath: (...segments) => environment.path.join(...segments), + moduleDirectory: environment.resourcesPath, + repoRootCandidates: environment.isDevelopment ? [environment.rootDir] : [], + }); + const staged = environment.resolveResourcePathCandidates( + environment.path.join("tailcat", platformKey, executable), + ); + return Array.from(new Set([...packaged, ...staged])); +} + +/** First bundled candidate that exists on disk, for the backend bootstrap. */ +export const resolveDesktopTailcatBinaryPath = Effect.fn("desktop.tailcat.resolveBinaryPath")( + function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const override = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + if (override !== undefined) { + return Option.some(override); + } + for (const candidate of desktopTailcatBundledCandidates(environment)) { + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return Option.some(candidate); + } + } + return Option.none(); + }, +); + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const overridePath = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + return TailcatRuntime.layer({ + resolution: { + overridePath, + bundledCandidates: desktopTailcatBundledCandidates(environment), + allowSystem: true, + }, + }); + }), +); From d947ae789f8429883429d07624c1014c5c402969 Mon Sep 17 00:00:00 2001 From: Bear Huddleston Date: Thu, 3 Sep 2026 19:08:18 -0500 Subject: [PATCH 07/12] feat(web,mobile): Tailcat remote access, Add environment, and federation UI Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/connection/platform.ts | 23 + .../src/features/connection/pairing.test.ts | 38 + .../mobile/src/features/connection/pairing.ts | 36 + .../state/use-remote-environment-registry.ts | 16 + .../settings/ConnectionsSettings.tsx | 97 +- .../settings/FederationSection.logic.test.ts | 152 +++ .../settings/FederationSection.logic.ts | 191 +++ .../components/settings/FederationSection.tsx | 1165 +++++++++++++++++ .../settings/ProviderSettingsPanel.tsx | 5 + .../settings/TailcatConnectForm.tsx | 215 +++ .../settings/TailcatEnvironmentDetails.tsx | 341 +++++ .../TailcatRemoteAccess.logic.test.ts | 164 +++ .../settings/TailcatRemoteAccess.logic.ts | 173 +++ .../settings/TailcatRemoteAccessSection.tsx | 636 +++++++++ .../settings/settingsSearch.test.ts | 43 + .../src/components/settings/settingsSearch.ts | 60 +- .../useAvailableSettingsSearchItems.ts | 3 + apps/web/src/connection/onboarding.ts | 16 + apps/web/src/connection/platform.test.ts | 58 + apps/web/src/connection/platform.ts | 217 ++- apps/web/src/state/desktopTailcat.test.ts | 131 ++ apps/web/src/state/desktopTailcat.ts | 165 +++ apps/web/src/state/environmentRpcStream.ts | 94 ++ apps/web/src/state/federation.ts | 61 + apps/web/src/state/tailcat.ts | 44 + apps/web/src/state/tailcatProvisioning.ts | 26 + 26 files changed, 4161 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/settings/FederationSection.logic.test.ts create mode 100644 apps/web/src/components/settings/FederationSection.logic.ts create mode 100644 apps/web/src/components/settings/FederationSection.tsx create mode 100644 apps/web/src/components/settings/TailcatConnectForm.tsx create mode 100644 apps/web/src/components/settings/TailcatEnvironmentDetails.tsx create mode 100644 apps/web/src/components/settings/TailcatRemoteAccess.logic.test.ts create mode 100644 apps/web/src/components/settings/TailcatRemoteAccess.logic.ts create mode 100644 apps/web/src/components/settings/TailcatRemoteAccessSection.tsx create mode 100644 apps/web/src/state/desktopTailcat.test.ts create mode 100644 apps/web/src/state/desktopTailcat.ts create mode 100644 apps/web/src/state/environmentRpcStream.ts create mode 100644 apps/web/src/state/federation.ts create mode 100644 apps/web/src/state/tailcat.ts create mode 100644 apps/web/src/state/tailcatProvisioning.ts diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index d6b50e50a6a7..981bb5cd4cb2 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -6,6 +6,7 @@ import { PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, + TailcatEnvironmentGateway, } from "@t3tools/client-runtime/platform"; import { ConnectionBlockedError, @@ -191,6 +192,28 @@ const capabilitiesLayer = Layer.effectContext( disconnect: () => Effect.void, }), ), + // A phone has no process to run the tailcat forwarder in, so Tailcat + // environments are set up (and saved) from the desktop app. + Context.add( + TailcatEnvironmentGateway, + TailcatEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "Tailcat environments are managed from the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "Tailcat environments are managed from the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), ); }), ); diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 193927684794..23fce3b440e2 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -3,10 +3,15 @@ import { describe, expect, it } from "vite-plus/test"; import { buildPairingUrl, extractPairingUrlFromQrPayload, + PairingInputNotPairableError, PairingQrPayloadEmptyError, parsePairingUrl, + unsupportedPairingInputMessage, } from "./pairing"; +const TAILCAT_CODE = "t3c://tailcat/eyJ2IjoxfQ"; +const PEER_CODE = "t3c://peer/eyJ2IjoxfQ"; + describe("buildPairingUrl", () => { it("uses HTTP for a schemeless IP address", () => { expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( @@ -42,6 +47,15 @@ describe("extractPairingUrlFromQrPayload", () => { ).toBe("https://remote.example.com/pair#token=pairing-token"); }); + it("explains where a scanned Tailcat connection code belongs", () => { + expect(() => extractPairingUrlFromQrPayload(TAILCAT_CODE)).toThrowError( + PairingInputNotPairableError, + ); + expect(() => extractPairingUrlFromQrPayload(TAILCAT_CODE)).toThrowError( + "This is a Tailcat connection code. Paste it in the desktop app under Add environment → Tailcat.", + ); + }); + it("rejects empty qr payloads", () => { expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError(PairingQrPayloadEmptyError); expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError( @@ -62,3 +76,27 @@ describe("parsePairingUrl", () => { }); }); }); + +describe("unsupportedPairingInputMessage", () => { + it("guides Tailcat and peer codes to the desktop app", () => { + expect(unsupportedPairingInputMessage(` ${TAILCAT_CODE} `)).toBe( + "This is a Tailcat connection code. Paste it in the desktop app under Add environment → Tailcat.", + ); + expect(unsupportedPairingInputMessage(PEER_CODE)).toBe( + "This is a federation peer code. Add it in the desktop app under Settings → Connections → Federation.", + ); + expect(unsupportedPairingInputMessage("t3c://mystery/abc")).toBe( + "This is a T3 connection code, not a pairing URL. Use it in the desktop app.", + ); + }); + + it("leaves pairing urls and hosts alone", () => { + expect(unsupportedPairingInputMessage("https://remote.example.com/#token=abc")).toBeNull(); + expect(unsupportedPairingInputMessage("192.168.1.100:3773")).toBeNull(); + expect(unsupportedPairingInputMessage("")).toBeNull(); + }); + + it("keeps a pasted connection code intact instead of mangling it into a host", () => { + expect(parsePairingUrl(TAILCAT_CODE)).toEqual({ host: TAILCAT_CODE, code: "" }); + }); +}); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 569d00cbdd36..16d773d5cbf9 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -1,4 +1,5 @@ import { readHostedPairingRequest } from "@t3tools/shared/remote"; +import { isT3ConnectionCode, peekT3ConnectionCodeKind } from "@t3tools/shared/t3ConnectionCode"; import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; @@ -27,6 +28,36 @@ export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( + "PairingInputNotPairableError", + { + kind: Schema.NullOr(Schema.String), + }, +) { + override get message(): string { + switch (this.kind) { + case "tailcat": + return "This is a Tailcat connection code. Paste it in the desktop app under Add environment → Tailcat."; + case "peer": + return "This is a federation peer code. Add it in the desktop app under Settings → Connections → Federation."; + default: + return "This is a T3 connection code, not a pairing URL. Use it in the desktop app."; + } + } +} + +/** Guidance for inputs that are T3 connection codes rather than pairing URLs; null for everything else. */ +export function unsupportedPairingInputMessage(input: string): string | null { + const trimmed = input.trim(); + if (!isT3ConnectionCode(trimmed)) return null; + return new PairingInputNotPairableError({ kind: peekT3ConnectionCodeKind(trimmed) }).message; +} + export function buildPairingUrl(host: string, code: string): string { const h = host.trim(); const c = code.trim(); @@ -45,6 +76,8 @@ export function buildPairingUrl(host: string, code: string): string { export function parsePairingUrl(url: string): { host: string; code: string } { const trimmed = url.trim(); if (!trimmed) return { host: "", code: "" }; + // Keep a pasted connection code intact so the guidance error matches what the user sees. + if (isT3ConnectionCode(trimmed)) return { host: trimmed, code: "" }; try { const parsed = new URL(trimmed); @@ -75,6 +108,9 @@ export function extractPairingUrlFromQrPayload(payload: string): string { if (!trimmed) { throw new PairingQrPayloadEmptyError({}); } + if (isT3ConnectionCode(trimmed)) { + throw new PairingInputNotPairableError({ kind: peekT3ConnectionCodeKind(trimmed) }); + } try { const url = new URL(trimmed); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 4f5f455522bc..89119c61b2cb 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,10 +1,16 @@ import { useAtomValue } from "@effect/atom-react"; +import { + type ConnectionAttemptError, + ConnectionBlockedError, +} from "@t3tools/client-runtime/connection"; +import type { ConnectionPersistenceError } from "@t3tools/client-runtime/platform"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; +import { unsupportedPairingInputMessage } from "../features/connection/pairing"; import { useConnectionController } from "../features/connection/useConnectionController"; import { environmentPresentations } from "./presentation"; import { useWorkspaceState } from "../state/workspace"; @@ -122,6 +128,16 @@ export function useRemoteConnections() { async (pairingUrl?: string) => { const nextPairingUrl = pairingUrl ?? connectionPairingUrl; setPendingConnectionError(null); + // Tailcat and peer codes are redeemed by the desktop app; say so instead + // of letting the pairing resolver report an invalid URL. + const guidance = unsupportedPairingInputMessage(nextPairingUrl); + if (guidance !== null) { + setPendingConnectionError(guidance); + return AsyncResult.failure< + EnvironmentId, + ConnectionAttemptError | ConnectionPersistenceError + >(Cause.fail(new ConnectionBlockedError({ reason: "configuration", detail: guidance }))); + } const result = await controller.connectPairingUrl(nextPairingUrl); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 6b81a7f70dda..b4109712d492 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,4 +1,10 @@ -import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react"; +import { + ChevronsLeftRightEllipsisIcon, + PlusIcon, + QrCodeIcon, + RadioTowerIcon, + TerminalIcon, +} from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { type KeyboardEvent, @@ -59,6 +65,13 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; +import { FederationSection } from "./FederationSection"; +import { TailcatConnectForm } from "./TailcatConnectForm"; +import { + TailcatEnvironmentDetailsDialog, + useTailcatEnvironmentSubtitle, +} from "./TailcatEnvironmentDetails"; +import { TailcatRemoteAccessRow } from "./TailcatRemoteAccessSection"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; import { @@ -137,6 +150,7 @@ import { refreshDesktopNetworkAccessState, } from "~/state/desktopNetworkAccess"; import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; +import { isDesktopTailcatAvailable } from "~/state/desktopTailcat"; import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; import { type EnvironmentPresentation, @@ -157,6 +171,9 @@ import { } from "../../keybindings"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; + +/** How a new saved environment is added: pairing link, desktop SSH, or a Tailcat connection code. */ +type SavedBackendMode = "remote" | "ssh" | "tailcat"; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; @@ -1435,8 +1452,16 @@ function SavedBackendListRow({ environment.entry.profile.value._tag === "SshConnectionProfile" ? environment.entry.profile.value.target : null; + const tailcatProfile = + environment.entry.target._tag === "TailcatConnectionTarget" && + Option.isSome(environment.entry.profile) && + environment.entry.profile.value._tag === "TailcatConnectionProfile" + ? environment.entry.profile.value + : null; + const tailcatSubtitle = useTailcatEnvironmentSubtitle(tailcatProfile?.connectionId ?? null); const metadataBits = [ sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, + tailcatSubtitle, environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); @@ -1546,6 +1571,15 @@ function SavedBackendListRow({ ) : ( <> + {tailcatProfile ? ( + + ) : null} {!isConnected ? ( ); }; + const renderTailcatModeBody = () => ( + { + setSavedBackendError(null); + setAddBackendDialogOpen(false); + }} + /> + ); + const renderTailcatRemoteAccessRow = () => + supportsTailcatRemoteAccess && primaryEnvironmentId !== null ? ( + + ) : null; const renderRemoteFields = () => (
@@ -3168,12 +3228,14 @@ export function ConnectionsSettings() { {renderNetworkAccessRow()} {renderEndpointRows("endpoint-rail")} {renderTailscaleRow()} + {renderTailcatRemoteAccessRow()} {renderWslRow()} ) : ( <> {renderDisabledNetworkAccessRow()} + {renderTailcatRemoteAccessRow()} )} @@ -3522,7 +3584,12 @@ export function ConnectionsSettings() {
-
+
{renderConnectionModeCard({ mode: "remote", title: "Remote link", @@ -3537,9 +3604,23 @@ export function ConnectionsSettings() { icon: , }) : null} + {renderConnectionModeCard({ + mode: "tailcat", + title: "Tailcat", + description: + "Paste a connection code from the other machine. Tunnels with relay fallback, no VPN account.", + icon: , + ...(isDesktopTailcatReady + ? {} + : { unavailableReason: "Desktop app required" }), + })}
- {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} + {savedBackendMode === "ssh" + ? renderSshFields() + : savedBackendMode === "tailcat" + ? renderTailcatModeBody() + : renderRemoteModeBody()}
@@ -3561,6 +3642,10 @@ export function ConnectionsSettings() { savedEnvironments={savedEnvironments} /> + + {canManageLocalBackend && supportsFederation && primaryEnvironmentId !== null ? ( + + ) : null} ); } diff --git a/apps/web/src/components/settings/FederationSection.logic.test.ts b/apps/web/src/components/settings/FederationSection.logic.test.ts new file mode 100644 index 000000000000..9cfcb742a0f8 --- /dev/null +++ b/apps/web/src/components/settings/FederationSection.logic.test.ts @@ -0,0 +1,152 @@ +import { EnvironmentId, FederationRemoteRun } from "@t3tools/contracts"; +import { + encodeFederationPeerCode, + encodeTailcatConnectionCode, +} from "@t3tools/shared/t3ConnectionCode"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + describeFederationPeerCode, + isRemoteRunActive, + remoteRunLastEventSummary, + remoteRunStatusBadgeVariant, + remoteRunStatusLabel, + sortRemoteRuns, + toggleFederationScope, +} from "./FederationSection.logic"; + +const ADDRESS = `tc${"b".repeat(40)}`; +const NOW_MS = Date.parse("2026-09-03T12:00:00.000Z"); + +const decodeRemoteRun = Schema.decodeUnknownSync(FederationRemoteRun); + +function remoteRun(overrides: { + readonly requestedAt: string; + readonly events?: ReadonlyArray<{ + readonly sequence: number; + readonly at: string; + readonly type: string; + readonly summary: string; + }>; + readonly assistantPreview?: string | null; +}): FederationRemoteRun { + return decodeRemoteRun({ + peerId: "env-peer", + peerLabel: "Build box", + run: { + environmentId: "env-peer", + projectId: "project-1", + threadId: "thread-1", + turnId: null, + title: "Fix flaky test", + status: "running", + runtimeMode: "full-access", + modelSelection: { instanceId: "codex", model: "gpt-5" }, + requestedAt: overrides.requestedAt, + startedAt: null, + completedAt: null, + assistantPreview: overrides.assistantPreview ?? null, + turnCount: 1, + }, + events: overrides.events ?? [], + lastSyncedAt: null, + syncError: null, + }); +} + +describe("remote run presentation", () => { + it("labels statuses and knows which ones can still be cancelled", () => { + expect(remoteRunStatusLabel("queued")).toBe("Queued"); + expect(remoteRunStatusLabel("error")).toBe("Failed"); + expect(remoteRunStatusBadgeVariant("running")).toBe("warning"); + expect(remoteRunStatusBadgeVariant("completed")).toBe("success"); + expect(isRemoteRunActive("queued")).toBe(true); + expect(isRemoteRunActive("running")).toBe(true); + expect(isRemoteRunActive("completed")).toBe(false); + expect(isRemoteRunActive("interrupted")).toBe(false); + }); + + it("prefers the newest event summary, then the assistant preview", () => { + const at = "2026-09-03T12:00:00.000Z"; + expect( + remoteRunLastEventSummary( + remoteRun({ + requestedAt: at, + events: [ + { sequence: 2, at, type: "turn.completed", summary: "Turn completed" }, + { sequence: 1, at, type: "turn.started", summary: "Turn started" }, + ], + assistantPreview: "Working on it", + }), + ), + ).toBe("Turn completed"); + expect( + remoteRunLastEventSummary(remoteRun({ requestedAt: at, assistantPreview: " Working " })), + ).toBe("Working"); + expect(remoteRunLastEventSummary(remoteRun({ requestedAt: at }))).toBeNull(); + }); + + it("sorts runs newest first", () => { + const older = remoteRun({ requestedAt: "2026-09-03T11:00:00.000Z" }); + const newer = remoteRun({ requestedAt: "2026-09-03T12:00:00.000Z" }); + expect(sortRemoteRuns([older, newer])).toEqual([newer, older]); + }); +}); + +describe("toggleFederationScope", () => { + it("adds once and removes cleanly", () => { + expect(toggleFederationScope(["runs.read"], "runs.start", true)).toEqual([ + "runs.read", + "runs.start", + ]); + expect(toggleFederationScope(["runs.read"], "runs.read", true)).toEqual(["runs.read"]); + expect(toggleFederationScope(["runs.read", "runs.start"], "runs.read", false)).toEqual([ + "runs.start", + ]); + }); +}); + +describe("describeFederationPeerCode", () => { + it("previews a valid peer code and detects expiry", () => { + const code = encodeFederationPeerCode({ + v: 1, + kind: "peer", + protocolVersion: 1, + environmentId: EnvironmentId.make("env-2"), + publicKey: "pem", + label: "Build box", + transport: { tailcat: { address: ADDRESS, port: 3773 } }, + token: "one-time", + scopes: ["environment.read", "runs.start"], + expiresAt: "2026-09-03T12:05:00.000Z", + }); + expect(describeFederationPeerCode(code, NOW_MS)).toEqual({ + kind: "valid", + payload: expect.objectContaining({ + label: "Build box", + scopes: ["environment.read", "runs.start"], + }), + expired: false, + }); + expect(describeFederationPeerCode(code, Date.parse("2026-09-03T12:06:00.000Z"))).toMatchObject({ + kind: "valid", + expired: true, + }); + }); + + it("redirects Tailcat codes and rejects everything else", () => { + const tailcatCode = encodeTailcatConnectionCode({ + v: 1, + transport: "tailcat", + address: ADDRESS, + port: 3773, + }); + expect(describeFederationPeerCode(tailcatCode, NOW_MS)).toMatchObject({ kind: "tailcat-code" }); + expect(describeFederationPeerCode(" ", NOW_MS)).toEqual({ kind: "empty" }); + expect(describeFederationPeerCode("nope", NOW_MS)).toMatchObject({ + kind: "invalid", + message: expect.stringContaining("t3c://peer/"), + }); + }); +}); diff --git a/apps/web/src/components/settings/FederationSection.logic.ts b/apps/web/src/components/settings/FederationSection.logic.ts new file mode 100644 index 000000000000..8338f107b1b6 --- /dev/null +++ b/apps/web/src/components/settings/FederationSection.logic.ts @@ -0,0 +1,191 @@ +import type { + FederationPeerCodePayload, + FederationPeerStatus, + FederationRemoteRun, + FederationRunStatus, + FederationScope, +} from "@t3tools/contracts"; +import { + T3ConnectionCodeInvalidError, + decodeFederationPeerCode, + isT3ConnectionCode, + peekT3ConnectionCodeKind, +} from "@t3tools/shared/t3ConnectionCode"; +import * as Schema from "effect/Schema"; + +const isCodeInvalidError = Schema.is(T3ConnectionCodeInvalidError); + +export const FEDERATION_SCOPE_OPTIONS: ReadonlyArray<{ + readonly scope: FederationScope; + readonly title: string; + readonly description: string; +}> = [ + { + scope: "environment.read", + title: "See environment", + description: "Read this environment's name, version, and capabilities.", + }, + { + scope: "projects.read", + title: "List projects", + description: "See which projects can be targeted for a run.", + }, + { + scope: "runs.read", + title: "Follow runs", + description: "Read status and event summaries of runs it started here.", + }, + { + scope: "runs.start", + title: "Start runs", + description: "Start agent runs in this environment's projects.", + }, + { + scope: "runs.cancel", + title: "Cancel runs", + description: "Interrupt runs it started here.", + }, + { + scope: "artifacts.read", + title: "Read changes", + description: "Fetch the diffs produced by runs it started here.", + }, +]; + +export function toggleFederationScope( + scopes: ReadonlyArray, + scope: FederationScope, + checked: boolean, +): ReadonlyArray { + if (checked) { + return scopes.includes(scope) ? scopes : [...scopes, scope]; + } + return scopes.filter((candidate) => candidate !== scope); +} + +export type RemoteRunBadgeVariant = "outline" | "warning" | "success" | "error" | "info"; + +export function remoteRunStatusLabel(status: FederationRunStatus): string { + switch (status) { + case "queued": + return "Queued"; + case "running": + return "Running"; + case "completed": + return "Completed"; + case "interrupted": + return "Interrupted"; + case "error": + return "Failed"; + } +} + +export function remoteRunStatusBadgeVariant(status: FederationRunStatus): RemoteRunBadgeVariant { + switch (status) { + case "queued": + return "info"; + case "running": + return "warning"; + case "completed": + return "success"; + case "interrupted": + return "outline"; + case "error": + return "error"; + } +} + +/** Queued and running runs can still be cancelled on the peer. */ +export function isRemoteRunActive(status: FederationRunStatus): boolean { + return status === "queued" || status === "running"; +} + +/** The freshest one-line description of a remote run: its latest event, else the assistant preview. */ +export function remoteRunLastEventSummary(remoteRun: FederationRemoteRun): string | null { + const latest = remoteRun.events.reduce( + (best, event) => (best === null || event.sequence > best.sequence ? event : best), + null, + ); + const summary = latest?.summary.trim(); + if (summary) return summary; + const preview = remoteRun.run.assistantPreview?.trim(); + return preview ? preview : null; +} + +/** Newest request first, so the run just started is at the top. */ +export function sortRemoteRuns( + runs: ReadonlyArray, +): ReadonlyArray { + return [...runs].toSorted( + (left, right) => Date.parse(right.run.requestedAt) - Date.parse(left.run.requestedAt), + ); +} + +export function peerStatusDotClassName(status: FederationPeerStatus): string { + switch (status) { + case "online": + return "bg-success"; + case "offline": + return "bg-destructive"; + case "unknown": + return "bg-muted-foreground/40"; + } +} + +export function peerStatusLabel(status: FederationPeerStatus): string { + switch (status) { + case "online": + return "Online"; + case "offline": + return "Offline"; + case "unknown": + return "Not checked yet"; + } +} + +export type FederationPeerCodePreview = + | { readonly kind: "empty" } + | { readonly kind: "invalid"; readonly message: string } + | { readonly kind: "tailcat-code"; readonly message: string } + | { + readonly kind: "valid"; + readonly payload: FederationPeerCodePayload; + readonly expired: boolean; + }; + +/** Live feedback for the peer-code field; a Tailcat connection code is redirected, not rejected. */ +export function describeFederationPeerCode(raw: string, nowMs: number): FederationPeerCodePreview { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return { kind: "empty" }; + } + if (!isT3ConnectionCode(trimmed)) { + return { kind: "invalid", message: "Paste the full peer code. It starts with t3c://peer/." }; + } + if (peekT3ConnectionCodeKind(trimmed) === "tailcat") { + return { + kind: "tailcat-code", + message: + "This is a Tailcat connection code for a device. Use Add environment → Tailcat instead.", + }; + } + try { + const payload = decodeFederationPeerCode(trimmed); + return { kind: "valid", payload, expired: Date.parse(payload.expiresAt) <= nowMs }; + } catch (cause) { + return { + kind: "invalid", + message: isCodeInvalidError(cause) ? cause.message : "This peer code could not be read.", + }; + } +} + +const timestampFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +export function formatFederationTimestamp(value: string): string { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : timestampFormatter.format(parsed); +} diff --git a/apps/web/src/components/settings/FederationSection.tsx b/apps/web/src/components/settings/FederationSection.tsx new file mode 100644 index 000000000000..7e776f7b0c47 --- /dev/null +++ b/apps/web/src/components/settings/FederationSection.tsx @@ -0,0 +1,1165 @@ +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + type EnvironmentId, + FEDERATION_DEFAULT_SCOPES, + type FederationArtifactFetchResponse, + type FederationArtifactRef, + type FederationPeer, + type FederationPeerCodeResult, + type FederationProjectSummary, + type FederationRemoteRun, + type FederationScope, + type RuntimeMode, +} from "@t3tools/contracts"; +import type * as Cause from "effect/Cause"; +import { CopyIcon, PlayIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { memo, useCallback, useMemo, useState } from "react"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { formatExpiresInLabel } from "../../timestampFormat"; +import { federationEnvironment } from "~/state/federation"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { QRCodeSvg } from "../ui/qr-code"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Textarea } from "../ui/textarea"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + FEDERATION_SCOPE_OPTIONS, + describeFederationPeerCode, + formatFederationTimestamp, + isRemoteRunActive, + peerStatusDotClassName, + peerStatusLabel, + remoteRunLastEventSummary, + remoteRunStatusBadgeVariant, + remoteRunStatusLabel, + sortRemoteRuns, + toggleFederationScope, +} from "./FederationSection.logic"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { SettingsRow, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +const EMPTY_REMOTE_RUNS: ReadonlyArray = []; +const EMPTY_PEERS: ReadonlyArray = []; + +const RUNTIME_MODE_OPTIONS: ReadonlyArray<{ readonly value: RuntimeMode; readonly label: string }> = + [ + { value: "approval-required", label: "Approval required" }, + { value: "auto-accept-edits", label: "Auto-accept edits" }, + { value: "auto", label: "Auto" }, + { value: "full-access", label: "Full access" }, + ]; + +const PEER_DEFAULT_RUNTIME_MODE = "peer-default"; + +function commandFailureMessage( + result: { readonly cause: Cause.Cause }, + fallback: string, +): string { + const error = squashAtomCommandFailure(result); + return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback; +} + +function ScopeChecklist({ + scopes, + disabled, + heading, + onToggle, +}: { + readonly scopes: ReadonlyArray; + readonly disabled: boolean; + readonly heading: string; + readonly onToggle: (scope: FederationScope, checked: boolean) => void; +}) { + return ( +
+

{heading}

+
+ {FEDERATION_SCOPE_OPTIONS.map(({ scope, title, description }) => ( + + ))} +
+
+ ); +} + +function ScopeChips({ + label, + scopes, +}: { + readonly label: string; + readonly scopes: ReadonlyArray; +}) { + return ( + + {label} + {scopes.length === 0 ? ( + none + ) : ( + scopes.map((scope) => ( + + {scope} + + )) + )} + + ); +} + +/** The minted peer code with QR and a countdown; ticks only while shown. */ +const PeerCodeReveal = memo(function PeerCodeReveal({ + issued, +}: { + readonly issued: FederationPeerCodeResult; +}) { + const nowMs = useRelativeTimeTick(1_000); + const { copyToClipboard } = useCopyToClipboard({ + onCopy: () => { + toastManager.add({ + type: "success", + title: "Peer code copied", + description: "Add it on the other environment under Federation → Add peer.", + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy peer code", + description: error.message, + }), + ); + }, + }); + const expired = Date.parse(issued.expiresAt) <= nowMs; + + return ( +
+
+
+