From c502be28f11926d307659c51b8fd6bbd089318ab Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 08:21:32 -0500 Subject: [PATCH 1/7] fix(cli): auth session revoke closes the running server's open connections t3 auth session revoke wrote the revocation to the database and signalled only its own in-process session store. The running server never heard about it, so a WebSocket already open on that session kept its scopes until the session expired. When server-runtime.json names a live server, the CLI now revokes through its /api/auth/clients/revoke endpoint with a short-lived access:write session, which fires the server's invalidation and closes those sockets. With no live server, or when it cannot be reached, the CLI revokes in the database as before and says on stderr that open connections stay open. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/auth.ts | 75 ++++++++++++++++++-- apps/server/src/cli/pair.test.ts | 113 ++++++++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 0aeb7d6a3b5ab..e2fd281511e4c 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -1,10 +1,18 @@ -import { AuthSessionId, AuthStandardClientScopes } from "@t3tools/contracts"; +import { + AuthAccessWriteScope, + AuthSessionId, + AuthStandardClientScopes, + EnvironmentHttpApi, +} 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 References from "effect/References"; import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; +import { FetchHttpClient } from "effect/http"; +import * as HttpApiClient from "effect/http-api/HttpApiClient"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -15,6 +23,7 @@ import { formatSessionList, } from "../cliAuthFormat.ts"; import * as ServerConfig from "../config.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { authScopesFlag } from "./authScopes.ts"; import { authLocationFlags, @@ -23,9 +32,9 @@ import { resolveCliAuthConfig, } from "./config.ts"; -const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* ( +const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* ( flags: CliAuthLocationFlags, - run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect, + run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect, options?: { readonly quietLogs?: boolean; }, @@ -38,8 +47,8 @@ const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function return yield* run(environmentAuth); }).pipe( Effect.provide( - Layer.mergeAll(EnvironmentAuth.layerRuntime).pipe( - Layer.provide(ServerConfig.layer(config)), + Layer.mergeAll(EnvironmentAuth.layerRuntime, FetchHttpClient.layer).pipe( + Layer.provideMerge(ServerConfig.layer(config)), Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), ), ), @@ -211,6 +220,60 @@ const sessionListCommand = Command.make("list", { ), ); +const LIVE_SERVER_TIMEOUT = Duration.seconds(10); +// Bounds how long the helper session outlives a CLI killed before it can revoke it. +const LIVE_SERVER_SESSION_TTL = Duration.minutes(1); + +/** + * Open WebSockets end when the running server's in-memory revocation signal + * fires. A revoke written only to the database never reaches it, so those + * sockets keep their scopes until the session expires. Revoke through the + * running server when there is one. + */ +const revokeSession = Effect.fn("auth.revokeSession")(function* ( + environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], + sessionId: AuthSessionId, +) { + const config = yield* ServerConfig.ServerConfig; + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) { + const origin = runtimeState.value.origin; + const attempted = yield* Effect.result( + Effect.acquireUseRelease( + environmentAuth.issueSession({ + ttl: LIVE_SERVER_SESSION_TTL, + scopes: [AuthAccessWriteScope], + subject: "auth-cli", + label: "t3 auth cli", + }), + (issued) => + HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe( + Effect.flatMap((client) => + client.auth.revokeClient({ + headers: { authorization: `Bearer ${issued.token}` }, + payload: { sessionId }, + }), + ), + Effect.timeout(LIVE_SERVER_TIMEOUT), + ), + (issued) => + environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ), + ); + if (attempted._tag === "Success") { + return attempted.success.revoked; + } + yield* Effect.logDebug("Failed to revoke the session through the running server.", { + origin, + cause: attempted.failure, + }); + yield* Console.error( + `Could not reach the T3 Code server at ${origin}. Connections already open with this session stay open until they close.`, + ); + } + return yield* environmentAuth.revokeSession(sessionId); +}); + const sessionRevokeCommand = Command.make("revoke", { ...authLocationFlags, sessionId: Argument.String("session-id").pipe( @@ -222,7 +285,7 @@ const sessionRevokeCommand = Command.make("revoke", { Command.withHandler((flags) => runWithEnvironmentAuth(flags, (environmentAuth) => Effect.gen(function* () { - const revoked = yield* environmentAuth.revokeSession(flags.sessionId); + const revoked = yield* revokeSession(environmentAuth, flags.sessionId); yield* Console.log( revoked ? `Revoked session ${flags.sessionId}.\n` diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index f1ac9e076ecf2..542a227fd7733 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -4,24 +4,38 @@ 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 { AuthStandardClientScopes, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; +import { + AuthStandardClientScopes, + EnvironmentHttpApi, + ORCHESTRATION_PROTOCOL_VERSION, +} from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; 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 Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { FetchHttpClient } from "effect/http"; +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as TestConsole from "effect/testing/TestConsole"; import { Command, CliError } from "effect/cli"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as AuthHttp from "../auth/http.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import { cli } from "../binCli.ts"; +import * as ServerConfig from "../config.ts"; import { SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, @@ -32,6 +46,7 @@ import { persistServerRuntimeState, type PersistedServerRuntimeState, } from "../serverRuntimeState.ts"; +import { resolveCliAuthConfig } from "./config.ts"; import { DevServerNotProxiableError, resolveTailscaleLocalTarget } from "./pair.ts"; import packageJson from "../../package.json" with { type: "json" }; @@ -488,3 +503,97 @@ describe("auth scope options", () => { }), ); }); + +class AuthRoutesApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} + +describe("auth session revoke", () => { + // Open WebSockets wait on the running server's in-memory revocation signal, + // which a database-only revoke from the CLI never reaches. + it.effect("ends the running server's open connections for the revoked session", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-revoke-test-"), + ); + const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); + const services = yield* Layer.build( + HttpRouter.serve( + HttpApiBuilder.layer(AuthRoutesApi).pipe( + Layer.provide(AuthHttp.layer), + Layer.provide(AuthHttp.layerAuthenticatedAuth), + ), + { disableListenLog: true, disableLogger: true }, + ).pipe( + Layer.provideMerge(EnvironmentAuth.layerRuntime), + Layer.provideMerge(ServerConfig.layer(config)), + Layer.provideMerge(NodeHttpServer.layerTest), + ), + ); + const origin = HttpServer.formatAddress(Context.get(services, HttpServer.HttpServer).address); + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }), + }); + const sessions = Context.get(services, SessionStore.SessionStore); + const session = yield* sessions.issue({ + subject: "cli-issued-session", + client: { label: "revoke-test", deviceType: "bot" }, + }); + // The server closes a session's open WebSockets on this event. Watch the + // event rather than awaitInvalidation, whose database read could see the + // CLI's write and finish without it. + const removed = yield* Deferred.make(); + yield* sessions.streamChanges.pipe( + Stream.filter( + (change) => change.type === "clientRemoved" && change.sessionId === session.sessionId, + ), + Stream.runHead, + Effect.andThen(Deferred.succeed(removed, undefined)), + Effect.forkScoped({ startImmediately: true }), + ); + + const output = yield* captureStdout( + runCli(["auth", "session", "revoke", session.sessionId, "--base-dir", baseDir]), + ); + + assert.include(output, `Revoked session ${session.sessionId}.`); + assert.isTrue(yield* Deferred.isDone(removed)); + }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)), + ); + + it.effect("still revokes in the database when the running server cannot be reached", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-revoke-unreachable-test-"), + ); + const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: { ...baseState, pid: process.pid, origin: "http://127.0.0.1:1" }, + }); + const issued = JSON.parse( + yield* captureStdout(runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"])), + ) as { readonly sessionId: string }; + + const stderr = yield* provideCliTestLayers( + Effect.gen(function* () { + yield* runCli(["auth", "session", "revoke", issued.sessionId, "--base-dir", baseDir]); + return yield* TestConsole.errorLines; + }), + ); + const listed = JSON.parse( + yield* captureStdout(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"])), + ) as ReadonlyArray<{ readonly sessionId: string }>; + + expect(stderr).toContain( + "Could not reach the T3 Code server at http://127.0.0.1:1. Connections already open with this session stay open until they close.", + ); + assert.notInclude( + listed.map((entry) => entry.sessionId), + issued.sessionId, + ); + }).pipe(Effect.provide(layerCliRuntime)), + ); +}); From afb2e8931bb42869a596d0be0b7dce97465bf346 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 25 Oct 2026 07:12:39 -0500 Subject: [PATCH 2/7] fix(server): revoking an already-revoked session still ends its open connections A CLI revoke that falls back to the database when the running server can't be reached marks the row revoked without signalling that server's open WebSockets. A retry against the server then found no row to update and emitted nothing, so the sockets kept their scopes. Server-side revoke now clears connected state and emits the removal for any existing session, while a nonexistent id still reports not found. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/SessionStore.ts | 20 +++-- apps/server/src/cli/pair.test.ts | 115 +++++++++++++++++---------- 2 files changed, 87 insertions(+), 48 deletions(-) diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 1f4ad752d2bdd..883f73a6f53b2 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -997,14 +997,20 @@ const make = Effect.gen(function* () { revokedAt, }) .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); - if (revoked) { - yield* Ref.update(connectedSessionsRef, (current) => { - const next = new Map(current); - next.delete(sessionId); - return next; - }); - yield* emitRemoved(sessionId); + if (!revoked) { + // Another process, such as the CLI's database fallback, can revoke the row + // without reaching this process's open connections. Signal them anyway. + const row = yield* authSessions + .getById({ sessionId }) + .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); + if (Option.isNone(row)) return false; } + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + next.delete(sessionId); + return next; + }); + yield* emitRemoved(sessionId); return revoked; }, ); diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 542a227fd7733..1ea057239e9de 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -506,29 +506,42 @@ describe("auth scope options", () => { class AuthRoutesApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} -describe("auth session revoke", () => { - // Open WebSockets wait on the running server's in-memory revocation signal, - // which a database-only revoke from the CLI never reaches. - it.effect("ends the running server's open connections for the revoked session", () => - Effect.gen(function* () { - const baseDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-revoke-test-"), - ); - const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); - const services = yield* Layer.build( - HttpRouter.serve( - HttpApiBuilder.layer(AuthRoutesApi).pipe( - Layer.provide(AuthHttp.layer), - Layer.provide(AuthHttp.layerAuthenticatedAuth), - ), - { disableListenLog: true, disableLogger: true }, - ).pipe( - Layer.provideMerge(EnvironmentAuth.layerRuntime), - Layer.provideMerge(ServerConfig.layer(config)), - Layer.provideMerge(NodeHttpServer.layerTest), +// Serves the auth routes with their own session store, as a separate server +// process would, and watches that store for the event that closes a session's +// open WebSockets. Watching the event rather than awaitInvalidation matters: +// its database read could see the CLI's write and finish without the event. +const serveAuthRoutesWithWatchedSession = (baseDir: string) => + Effect.gen(function* () { + const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); + const services = yield* Layer.build( + HttpRouter.serve( + HttpApiBuilder.layer(AuthRoutesApi).pipe( + Layer.provide(AuthHttp.layer), + Layer.provide(AuthHttp.layerAuthenticatedAuth), ), - ); - const origin = HttpServer.formatAddress(Context.get(services, HttpServer.HttpServer).address); + { disableListenLog: true, disableLogger: true }, + ).pipe( + Layer.provideMerge(EnvironmentAuth.layerRuntime), + Layer.provideMerge(ServerConfig.layer(config)), + Layer.provideMerge(NodeHttpServer.layerTest), + ), + ); + const origin = HttpServer.formatAddress(Context.get(services, HttpServer.HttpServer).address); + const sessions = Context.get(services, SessionStore.SessionStore); + const session = yield* sessions.issue({ + subject: "cli-issued-session", + client: { label: "revoke-test", deviceType: "bot" }, + }); + const removed = yield* Deferred.make(); + yield* sessions.streamChanges.pipe( + Stream.filter( + (change) => change.type === "clientRemoved" && change.sessionId === session.sessionId, + ), + Stream.runHead, + Effect.andThen(Deferred.succeed(removed, undefined)), + Effect.forkScoped({ startImmediately: true }), + ); + const persistRunningServer = Effect.gen(function* () { yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, state: yield* makePersistedServerRuntimeState({ @@ -536,30 +549,50 @@ describe("auth session revoke", () => { port: Number(new URL(origin).port), }), }); - const sessions = Context.get(services, SessionStore.SessionStore); - const session = yield* sessions.issue({ - subject: "cli-issued-session", - client: { label: "revoke-test", deviceType: "bot" }, - }); - // The server closes a session's open WebSockets on this event. Watch the - // event rather than awaitInvalidation, whose database read could see the - // CLI's write and finish without it. - const removed = yield* Deferred.make(); - yield* sessions.streamChanges.pipe( - Stream.filter( - (change) => change.type === "clientRemoved" && change.sessionId === session.sessionId, - ), - Stream.runHead, - Effect.andThen(Deferred.succeed(removed, undefined)), - Effect.forkScoped({ startImmediately: true }), + }); + return { config, session, removed, persistRunningServer }; + }); + +describe("auth session revoke", () => { + // Open WebSockets wait on the running server's in-memory revocation signal, + // which a database-only revoke from the CLI never reaches. + it.effect("ends the running server's open connections for the revoked session", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-revoke-test-"), ); + const server = yield* serveAuthRoutesWithWatchedSession(baseDir); + yield* server.persistRunningServer; const output = yield* captureStdout( - runCli(["auth", "session", "revoke", session.sessionId, "--base-dir", baseDir]), + runCli(["auth", "session", "revoke", server.session.sessionId, "--base-dir", baseDir]), + ); + + assert.include(output, `Revoked session ${server.session.sessionId}.`); + assert.isTrue(yield* Deferred.isDone(server.removed)); + }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)), + ); + + it.effect("ends open connections when retried after a database-only revoke", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-revoke-retry-test-"), + ); + const server = yield* serveAuthRoutesWithWatchedSession(baseDir); + const revoke = provideCliTestLayers( + runCli(["auth", "session", "revoke", server.session.sessionId, "--base-dir", baseDir]), ); + yield* persistServerRuntimeState({ + path: server.config.serverRuntimeStatePath, + state: { ...baseState, pid: process.pid, origin: "http://127.0.0.1:1" }, + }); + yield* revoke; + assert.isFalse(yield* Deferred.isDone(server.removed)); + + yield* server.persistRunningServer; + yield* revoke; - assert.include(output, `Revoked session ${session.sessionId}.`); - assert.isTrue(yield* Deferred.isDone(removed)); + assert.isTrue(yield* Deferred.isDone(server.removed)); }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)), ); From 75818d1613c44c308817f42133b9994f3aa1206b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 05:27:59 +0000 Subject: [PATCH 3/7] test(cli): verify revoke cleanup and preserve current main --- apps/server/src/cli/auth.ts | 7 +----- apps/server/src/cli/pair.test.ts | 37 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 9b94961d80fbe..882bd99b5509e 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -234,12 +234,7 @@ const LIVE_SERVER_TIMEOUT = Duration.seconds(10); // Bounds how long the helper session outlives a CLI killed before it can revoke it. const LIVE_SERVER_SESSION_TTL = Duration.minutes(1); -/** - * Open WebSockets end when the running server's in-memory revocation signal - * fires. A revoke written only to the database never reaches it, so those - * sockets keep their scopes until the session expires. Revoke through the - * running server when there is one. - */ +// Database-only revocation cannot signal the running server's open sockets. const revokeSession = Effect.fn("auth.revokeSession")(function* ( environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], sessionId: AuthSessionId, diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 081e559eebc05..2394087d35418 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -20,6 +20,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; @@ -506,10 +507,7 @@ describe("auth scope options", () => { class AuthRoutesApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} -// Serves the auth routes with their own session store, as a separate server -// process would, and watches that store for the event that closes a session's -// open WebSockets. Watching the event rather than awaitInvalidation matters: -// its database read could see the CLI's write and finish without the event. +// A separate store must receive the removal event; a database read alone cannot wake open sockets. const serveAuthRoutesWithWatchedSession = (baseDir: string) => Effect.gen(function* () { const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); @@ -554,13 +552,10 @@ const serveAuthRoutesWithWatchedSession = (baseDir: string) => }); describe("auth session revoke", () => { - // Open WebSockets wait on the running server's in-memory revocation signal, - // which a database-only revoke from the CLI never reaches. - it.effect("ends the running server's open connections for the revoked session", () => + it.effect("signals the running server and cleans up its helper session", () => Effect.gen(function* () { - const baseDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-revoke-test-"), - ); + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-revoke-test-" }); const server = yield* serveAuthRoutesWithWatchedSession(baseDir); yield* server.persistRunningServer; @@ -570,14 +565,19 @@ describe("auth session revoke", () => { assert.include(output, `Revoked session ${server.session.sessionId}.`); assert.isTrue(yield* Deferred.isDone(server.removed)); + const listed = yield* captureStdout( + runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), + ); + assert.deepEqual(JSON.parse(listed), []); }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)), ); - it.effect("ends open connections when retried after a database-only revoke", () => + it.effect("signals the running server when retried after a database-only revoke", () => Effect.gen(function* () { - const baseDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-revoke-retry-test-"), - ); + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-auth-revoke-retry-test-", + }); const server = yield* serveAuthRoutesWithWatchedSession(baseDir); const revoke = provideCliTestLayers( runCli(["auth", "session", "revoke", server.session.sessionId, "--base-dir", baseDir]), @@ -598,9 +598,10 @@ describe("auth session revoke", () => { it.effect("still revokes in the database when the running server cannot be reached", () => Effect.gen(function* () { - const baseDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-revoke-unreachable-test-"), - ); + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-auth-revoke-unreachable-test-", + }); const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, @@ -627,7 +628,7 @@ describe("auth session revoke", () => { listed.map((entry) => entry.sessionId), issued.sessionId, ); - }).pipe(Effect.provide(layerCliRuntime)), + }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)), ); }); From 545874cc49d61f32f773ef5c311e91e1b094c95a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 05:42:31 +0000 Subject: [PATCH 4/7] test(cli): await revocation event delivery --- apps/server/src/cli/pair.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 2394087d35418..2e38870284452 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -564,7 +564,7 @@ describe("auth session revoke", () => { ); assert.include(output, `Revoked session ${server.session.sessionId}.`); - assert.isTrue(yield* Deferred.isDone(server.removed)); + yield* Deferred.await(server.removed); const listed = yield* captureStdout( runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), ); @@ -592,7 +592,7 @@ describe("auth session revoke", () => { yield* server.persistRunningServer; yield* revoke; - assert.isTrue(yield* Deferred.isDone(server.removed)); + yield* Deferred.await(server.removed); }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)), ); From 01667fd0e5cea92d88ef443e79f0a0504571686c Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 15:09:20 +0000 Subject: [PATCH 5/7] fix(cli): session revoke only contacts the server that owns this T3 home A live PID in server-runtime.json does not prove that the recorded origin still belongs to the server that wrote the record. After an unclean exit the PID can be reused, and the origin can be served by some other process. Before minting its helper session, the CLI now reads the origin's public environment descriptor and continues only when its environment id matches this T3 home. Otherwise it revokes in the database and prints the existing warning. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/auth.ts | 55 ++++++++++----- apps/server/src/cli/pair.test.ts | 115 +++++++++++++++++++++---------- 2 files changed, 115 insertions(+), 55 deletions(-) diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 7a889b7760d39..cba53367d45b4 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -24,6 +24,7 @@ import { formatSessionList, } from "../cliAuthFormat.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { authScopesFlag } from "./authScopes.ts"; import { @@ -230,6 +231,15 @@ const sessionListCommand = Command.make("list", { ), ); +class OtherEnvironmentAtOriginError extends Schema.TaggedError()( + "OtherEnvironmentAtOriginError", + { origin: Schema.String }, +) { + override get message(): string { + return `The server at ${this.origin} serves another T3 home.`; + } +} + const LIVE_SERVER_TIMEOUT = Duration.seconds(10); // Bounds how long the helper session outlives a CLI killed before it can revoke it. const LIVE_SERVER_SESSION_TTL = Duration.minutes(1); @@ -243,27 +253,34 @@ const revokeSession = Effect.fn("auth.revokeSession")(function* ( const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) { const origin = runtimeState.value.origin; + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + const environmentId = yield* identity.getEnvironmentId; const attempted = yield* Effect.result( - Effect.acquireUseRelease( - environmentAuth.issueSession({ - ttl: LIVE_SERVER_SESSION_TTL, - scopes: [AuthAccessWriteScope], - subject: "auth-cli", - label: "t3 auth cli", - }), - (issued) => - HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe( - Effect.flatMap((client) => - client.auth.revokeClient({ - headers: { authorization: `Bearer ${issued.token}` }, - payload: { sessionId }, - }), - ), - Effect.timeout(LIVE_SERVER_TIMEOUT), + Effect.gen(function* () { + const client = yield* HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }); + // A reused PID can make a stale record look live; only this home's server gets a credential. + yield* client.metadata.descriptor().pipe( + Effect.filterOrFail( + (descriptor) => descriptor.environmentId === environmentId, + () => new OtherEnvironmentAtOriginError({ origin }), ), - (issued) => - environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), - ), + ); + return yield* Effect.acquireUseRelease( + environmentAuth.issueSession({ + ttl: LIVE_SERVER_SESSION_TTL, + scopes: [AuthAccessWriteScope], + subject: "auth-cli", + label: "t3 auth cli", + }), + (issued) => + client.auth.revokeClient({ + headers: { authorization: `Bearer ${issued.token}` }, + payload: { sessionId }, + }), + (issued) => + environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + }).pipe(Effect.timeout(LIVE_SERVER_TIMEOUT)), ); if (attempted._tag === "Success") { return attempted.success.revoked; diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index f13ca63b76be5..78cde62cb014e 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -10,6 +10,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AuthStandardClientScopes, EnvironmentHttpApi, + ExecutionEnvironmentDescriptor, ORCHESTRATION_PROTOCOL_VERSION, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; @@ -24,6 +25,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; @@ -39,6 +41,7 @@ import * as AuthHttp from "../auth/http.ts"; import * as SessionStore from "../auth/SessionStore.ts"; import { cli } from "../binCli.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, @@ -113,14 +116,21 @@ const testDescriptor = { capabilities: { repositoryIdentity: true }, }; +interface RecordedRequest { + readonly url: string | undefined; + readonly authorization: string | undefined; +} + const withDescriptorServer = ( - run: (origin: string) => Effect.Effect, + run: (origin: string, requests: ReadonlyArray) => Effect.Effect, host = "127.0.0.1", descriptor: object = testDescriptor, -) => - Effect.acquireUseRelease( +) => { + const requests: Array = []; + return Effect.acquireUseRelease( Effect.callback((resume) => { const server = NodeHttp.createServer((request, response) => { + requests.push({ url: request.url, authorization: request.headers.authorization }); if (request.url === "/.well-known/t3/environment") { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify(descriptor)); @@ -136,10 +146,11 @@ const withDescriptorServer = ( if (address === null || typeof address === "string") { return Effect.die(new Error("Expected a TCP address")); } - return run(`http://${host === "::1" ? "[::1]" : host}:${String(address.port)}`); + return run(`http://${host === "::1" ? "[::1]" : host}:${String(address.port)}`, requests); }, (server) => Effect.sync(() => server.close()), ); +}; describe("t3 pair", () => { it.effect("publishes an IPv6-only server through Tailscale Serve when pairing", () => @@ -556,7 +567,24 @@ describe("auth scope options", () => { ); }); -class AuthRoutesApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} +class AuthRoutesApi extends HttpApi.make("environment") + .add(EnvironmentHttpApi.groups.metadata) + .add(EnvironmentHttpApi.groups.auth) {} + +const decodeEnvironmentDescriptor = Schema.decodeUnknownEffect(ExecutionEnvironmentDescriptor); + +const layerHomeDescriptor = HttpApiBuilder.group( + AuthRoutesApi, + "metadata", + Effect.fnUntraced(function* (handlers) { + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + const descriptor = yield* decodeEnvironmentDescriptor({ + ...testDescriptor, + environmentId: yield* identity.getEnvironmentId, + }); + return handlers.handle("descriptor", () => Effect.succeed(descriptor)); + }), +); // A separate store must receive the removal event; a database read alone cannot wake open sockets. const serveAuthRoutesWithWatchedSession = (baseDir: string) => @@ -565,6 +593,7 @@ const serveAuthRoutesWithWatchedSession = (baseDir: string) => const services = yield* Layer.build( HttpRouter.serve( HttpApiBuilder.layer(AuthRoutesApi).pipe( + Layer.provide(layerHomeDescriptor), Layer.provide(AuthHttp.layer), Layer.provide(AuthHttp.layerAuthenticatedAuth), ), @@ -602,6 +631,40 @@ const serveAuthRoutesWithWatchedSession = (baseDir: string) => return { config, session, removed, persistRunningServer }; }); +// The recorded server process is alive, but no server for this T3 home answers at its origin. +const expectDatabaseOnlyRevoke = (origin: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-revoke-local-test-" }); + const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: { ...baseState, pid: process.pid, origin }, + }); + + const issued = JSON.parse( + yield* captureStdout(runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"])), + ) as { readonly sessionId: string }; + + const stderr = yield* provideCliTestLayers( + Effect.gen(function* () { + yield* runCli(["auth", "session", "revoke", issued.sessionId, "--base-dir", baseDir]); + return yield* TestConsole.errorLines; + }), + ); + const listed = JSON.parse( + yield* captureStdout(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"])), + ) as ReadonlyArray<{ readonly sessionId: string }>; + + expect(stderr).toContain( + `Could not reach the T3 Code server at ${origin}. Connections already open with this session stay open until they close.`, + ); + assert.notInclude( + listed.map((entry) => entry.sessionId), + issued.sessionId, + ); + }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)); + describe("auth session revoke", () => { it.effect("signals the running server and cleans up its helper session", () => Effect.gen(function* () { @@ -648,38 +711,18 @@ describe("auth session revoke", () => { ); it.effect("still revokes in the database when the running server cannot be reached", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ - prefix: "t3-cli-auth-revoke-unreachable-test-", - }); - const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); - yield* persistServerRuntimeState({ - path: config.serverRuntimeStatePath, - state: { ...baseState, pid: process.pid, origin: "http://127.0.0.1:1" }, - }); - const issued = JSON.parse( - yield* captureStdout(runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"])), - ) as { readonly sessionId: string }; - - const stderr = yield* provideCliTestLayers( - Effect.gen(function* () { - yield* runCli(["auth", "session", "revoke", issued.sessionId, "--base-dir", baseDir]); - return yield* TestConsole.errorLines; - }), - ); - const listed = JSON.parse( - yield* captureStdout(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"])), - ) as ReadonlyArray<{ readonly sessionId: string }>; + expectDatabaseOnlyRevoke("http://127.0.0.1:1"), + ); - expect(stderr).toContain( - "Could not reach the T3 Code server at http://127.0.0.1:1. Connections already open with this session stay open until they close.", - ); - assert.notInclude( - listed.map((entry) => entry.sessionId), - issued.sessionId, - ); - }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)), + it.effect("sends no credential when the recorded origin serves another T3 home", () => + withDescriptorServer((origin, requests) => + Effect.gen(function* () { + yield* expectDatabaseOnlyRevoke(origin); + assert.deepEqual(requests, [ + { url: "/.well-known/t3/environment", authorization: undefined }, + ]); + }), + ), ); }); From cf8bcdb418ddecca539c8ebd85344e590d79f27d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 16:50:22 +0000 Subject: [PATCH 6/7] fix(cli): auth session revoke also clears the local session record After the running server confirms a revoke, `t3 auth session revoke` also revokes the session in this T3 home's database. When the server already did it, this is a no-op. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/auth.ts | 4 +++- apps/server/src/cli/pair.test.ts | 40 ++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index cba53367d45b4..0d1f3d8e64827 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -283,7 +283,9 @@ const revokeSession = Effect.fn("auth.revokeSession")(function* ( }).pipe(Effect.timeout(LIVE_SERVER_TIMEOUT)), ); if (attempted._tag === "Success") { - return attempted.success.revoked; + // Also revoke this home's record; a no-op when the server already did. + const revokedLocally = yield* environmentAuth.revokeSession(sessionId); + return attempted.success.revoked || revokedLocally; } yield* Effect.logDebug("Failed to revoke the session through the running server.", { origin, diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 78cde62cb014e..1311ef1ae4248 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -136,6 +136,11 @@ const withDescriptorServer = ( response.end(JSON.stringify(descriptor)); return; } + if (request.url === "/api/auth/clients/revoke") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ revoked: true })); + return; + } response.writeHead(404); response.end(); }); @@ -631,12 +636,16 @@ const serveAuthRoutesWithWatchedSession = (baseDir: string) => return { config, session, removed, persistRunningServer }; }); -// The recorded server process is alive, but no server for this T3 home answers at its origin. -const expectDatabaseOnlyRevoke = (origin: string) => +// The recorded server process is alive at `origin`; returns what the revoke printed to stderr. +const revokeWithRecordedServer = (origin: string, homeEnvironmentId?: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-revoke-local-test-" }); const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none()); + if (homeEnvironmentId !== undefined) { + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString(config.environmentIdPath, homeEnvironmentId); + } yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, state: { ...baseState, pid: process.pid, origin }, @@ -656,15 +665,23 @@ const expectDatabaseOnlyRevoke = (origin: string) => yield* captureStdout(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"])), ) as ReadonlyArray<{ readonly sessionId: string }>; - expect(stderr).toContain( - `Could not reach the T3 Code server at ${origin}. Connections already open with this session stay open until they close.`, - ); assert.notInclude( listed.map((entry) => entry.sessionId), issued.sessionId, ); + return stderr; }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)); +// No server for this T3 home answers at the recorded origin. +const expectDatabaseOnlyRevoke = (origin: string) => + revokeWithRecordedServer(origin).pipe( + Effect.map((stderr) => + expect(stderr).toContain( + `Could not reach the T3 Code server at ${origin}. Connections already open with this session stay open until they close.`, + ), + ), + ); + describe("auth session revoke", () => { it.effect("signals the running server and cleans up its helper session", () => Effect.gen(function* () { @@ -710,6 +727,19 @@ describe("auth session revoke", () => { }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)), ); + it.effect("revokes in this T3 home's database when the running server reports success", () => + withDescriptorServer((origin, requests) => + Effect.gen(function* () { + const stderr = yield* revokeWithRecordedServer(origin, testDescriptor.environmentId); + assert.deepEqual(stderr, []); + assert.include( + requests.map((request) => request.url), + "/api/auth/clients/revoke", + ); + }), + ), + ); + it.effect("still revokes in the database when the running server cannot be reached", () => expectDatabaseOnlyRevoke("http://127.0.0.1:1"), ); From 0da121824dcc0785f7b1cd30d1fbbc5490e508ad Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 19:07:22 +0000 Subject: [PATCH 7/7] fix(cli): session revoke checks the recorded server process before contacting it `t3 auth session revoke` now reads the start time of the PID in server-runtime.json before it sends anything to the recorded origin. When that process started more than a second after the record was written (ps reports whole seconds), or its start time cannot be read, the CLI sends no request, revokes the session in this T3 home's database and prints the existing warning. The start-time lookup moves from serverOwnership.ts to serverRuntimeState.ts so startup and revoke share it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/auth.ts | 27 +++++++++++-- apps/server/src/cli/pair.test.ts | 58 ++++++++++++++++++++++----- apps/server/src/serverOwnership.ts | 28 ++----------- apps/server/src/serverRuntimeState.ts | 34 ++++++++++++++++ 4 files changed, 108 insertions(+), 39 deletions(-) diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 0d1f3d8e64827..0f0f37c2ccc06 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -25,7 +25,12 @@ import { } from "../cliAuthFormat.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { + isProcessAlive, + readPersistedServerRuntimeState, + recordedProcessStartMatches, +} from "../serverRuntimeState.ts"; import { authScopesFlag } from "./authScopes.ts"; import { authLocationFlags, @@ -240,6 +245,15 @@ class OtherEnvironmentAtOriginError extends Schema.TaggedError()( + "RecordedServerProcessError", + { pid: Schema.Number }, +) { + override get message(): string { + return `Process ${this.pid} may not be the server recorded in server-runtime.json.`; + } +} + const LIVE_SERVER_TIMEOUT = Duration.seconds(10); // Bounds how long the helper session outlives a CLI killed before it can revoke it. const LIVE_SERVER_SESSION_TTL = Duration.minutes(1); @@ -252,13 +266,20 @@ const revokeSession = Effect.fn("auth.revokeSession")(function* ( const config = yield* ServerConfig.ServerConfig; const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); if (Option.isSome(runtimeState) && isProcessAlive(runtimeState.value.pid)) { - const origin = runtimeState.value.origin; + const { origin, pid } = runtimeState.value; const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; const environmentId = yield* identity.getEnvironmentId; const attempted = yield* Effect.result( Effect.gen(function* () { + // A reused PID can make a stale record look live; contact only the recorded process, + // and give a credential only to this home's server. + const matches = yield* recordedProcessStartMatches(runtimeState.value).pipe( + Effect.provide(ProcessRunner.layer), + ); + if (!Option.getOrElse(matches, () => false)) { + return yield* new RecordedServerProcessError({ pid }); + } const client = yield* HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }); - // A reused PID can make a stale record look live; only this home's server gets a credential. yield* client.metadata.descriptor().pipe( Effect.filterOrFail( (descriptor) => descriptor.environmentId === environmentId, diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 1311ef1ae4248..57a65c65f21ef 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -19,6 +19,7 @@ import { assert, describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -69,6 +70,9 @@ const baseState = { startedAt: "2026-06-20T00:00:00.000Z", } as const satisfies PersistedServerRuntimeState; +// Revoke tests record this test process as the running server. +const testProcessStartedAt = DateTime.formatIso(DateTime.makeUnsafe(performance.timeOrigin)); + describe("pair tailscale local target", () => { it("proxies the dev web port for dev servers", () => { expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual( @@ -627,17 +631,26 @@ const serveAuthRoutesWithWatchedSession = (baseDir: string) => const persistRunningServer = Effect.gen(function* () { yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, - state: yield* makePersistedServerRuntimeState({ - config: { host: "127.0.0.1", devUrl: undefined }, - port: Number(new URL(origin).port), - }), + state: { + ...(yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + })), + startedAt: testProcessStartedAt, + }, }); }); return { config, session, removed, persistRunningServer }; }); // The recorded server process is alive at `origin`; returns what the revoke printed to stderr. -const revokeWithRecordedServer = (origin: string, homeEnvironmentId?: string) => +const revokeWithRecordedServer = ( + origin: string, + { + homeEnvironmentId, + startedAt = testProcessStartedAt, + }: { readonly homeEnvironmentId?: string; readonly startedAt?: string } = {}, +) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-revoke-local-test-" }); @@ -648,7 +661,7 @@ const revokeWithRecordedServer = (origin: string, homeEnvironmentId?: string) => } yield* persistServerRuntimeState({ path: config.serverRuntimeStatePath, - state: { ...baseState, pid: process.pid, origin }, + state: { ...baseState, pid: process.pid, origin, startedAt }, }); const issued = JSON.parse( @@ -672,9 +685,12 @@ const revokeWithRecordedServer = (origin: string, homeEnvironmentId?: string) => return stderr; }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)); -// No server for this T3 home answers at the recorded origin. -const expectDatabaseOnlyRevoke = (origin: string) => - revokeWithRecordedServer(origin).pipe( +// The revoke stays in this T3 home's database and warns that open connections stay open. +const expectDatabaseOnlyRevoke = ( + origin: string, + options?: Parameters[1], +) => + revokeWithRecordedServer(origin, options).pipe( Effect.map((stderr) => expect(stderr).toContain( `Could not reach the T3 Code server at ${origin}. Connections already open with this session stay open until they close.`, @@ -715,7 +731,12 @@ describe("auth session revoke", () => { ); yield* persistServerRuntimeState({ path: server.config.serverRuntimeStatePath, - state: { ...baseState, pid: process.pid, origin: "http://127.0.0.1:1" }, + state: { + ...baseState, + pid: process.pid, + origin: "http://127.0.0.1:1", + startedAt: testProcessStartedAt, + }, }); yield* revoke; assert.isFalse(yield* Deferred.isDone(server.removed)); @@ -730,7 +751,9 @@ describe("auth session revoke", () => { it.effect("revokes in this T3 home's database when the running server reports success", () => withDescriptorServer((origin, requests) => Effect.gen(function* () { - const stderr = yield* revokeWithRecordedServer(origin, testDescriptor.environmentId); + const stderr = yield* revokeWithRecordedServer(origin, { + homeEnvironmentId: testDescriptor.environmentId, + }); assert.deepEqual(stderr, []); assert.include( requests.map((request) => request.url), @@ -754,6 +777,19 @@ describe("auth session revoke", () => { }), ), ); + + it.effect("sends nothing when the recorded process start time does not match", () => + withDescriptorServer((origin, requests) => + Effect.gen(function* () { + // The recorded start predates this test process, so the PID belongs to a later process. + yield* expectDatabaseOnlyRevoke(origin, { + homeEnvironmentId: testDescriptor.environmentId, + startedAt: "2000-01-01T00:00:00.000Z", + }); + assert.deepEqual(requests, []); + }), + ), + ); }); describe("auth pairing create --base-url", () => { diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 565f8111849e0..10dceb1af4234 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -33,6 +33,7 @@ import { isProcessAlive, persistServerRuntimeState, readPersistedServerRuntimeState, + recordedProcessStartMatches, type PersistedServerRuntimeState, } from "./serverRuntimeState.ts"; @@ -78,31 +79,8 @@ const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* ( state: PersistedServerRuntimeState, ) { if (!isProcessAlive(state.pid)) return false; - const recordedAt = Date.parse(state.startedAt); - if (!Number.isFinite(recordedAt)) return true; - const windows = (yield* HostProcess.Platform) === "win32"; - const runner = yield* ProcessRunner.ProcessRunner; - const result = yield* runner - .run({ - command: windows ? "powershell.exe" : "ps", - args: windows - ? [ - "-NoProfile", - "-NonInteractive", - "-Command", - `(Get-Process -Id ${state.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`, - ] - : ["-p", String(state.pid), "-o", "lstart="], - env: { LC_ALL: "C", TZ: "UTC" }, - timeout: Duration.seconds(2), - maxOutputBytes: 16_384, - }) - .pipe(Effect.option); - if (Option.isNone(result) || result.value.code !== 0) return true; - const output = result.value.stdout.trim(); - const startedAt = Date.parse(windows ? output : `${output} UTC`); - // ps reports whole seconds. Unknown identity stays conservative. - return !Number.isFinite(startedAt) || startedAt <= recordedAt + 1_000; + // Unknown identity stays conservative. + return Option.getOrElse(yield* recordedProcessStartMatches(state), () => true); }); // Pre-lock services become invisible when another server replaces their runtime diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 49d9a48f991bc..e073dc29cc456 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,4 +1,6 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -7,6 +9,7 @@ import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import packageJson from "../package.json" with { type: "json" }; import type * as ServerConfig from "./config.ts"; +import * as ProcessRunner from "./processRunner.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; export const PersistedServerRuntimeState = Schema.Struct({ @@ -139,6 +142,37 @@ export const isProcessAlive = (pid: number): boolean => { } }; +// Whether the process at the recorded PID started before the record was written; None when unknown. +export const recordedProcessStartMatches = Effect.fn( + "serverRuntimeState.recordedProcessStartMatches", +)(function* (state: PersistedServerRuntimeState) { + const recordedAt = Date.parse(state.startedAt); + if (!Number.isFinite(recordedAt)) return Option.none(); + const windows = (yield* HostProcess.Platform) === "win32"; + const runner = yield* ProcessRunner.ProcessRunner; + const result = yield* runner + .run({ + command: windows ? "powershell.exe" : "ps", + args: windows + ? [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Process -Id ${state.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`, + ] + : ["-p", String(state.pid), "-o", "lstart="], + env: { LC_ALL: "C", TZ: "UTC" }, + timeout: Duration.seconds(2), + maxOutputBytes: 16_384, + }) + .pipe(Effect.option); + if (Option.isNone(result) || result.value.code !== 0) return Option.none(); + const output = result.value.stdout.trim(); + const startedAt = Date.parse(windows ? output : `${output} UTC`); + // ps reports whole seconds. + return Number.isFinite(startedAt) ? Option.some(startedAt <= recordedAt + 1_000) : Option.none(); +}); + export const readPersistedServerRuntimeState = Effect.fn( "serverRuntimeState.readPersistedServerRuntimeState", )(