# Files changed · PR \#23277 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23277/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(cli\): report live connections in auth session list](https://git.cafe/pingdotgg/t3code/pull/23277.md)

Version: 11

Head commit: f0b9b3b8327a9f2142e39bfe556715fa98211df2

Base commit: defb7e6e6b87d03259745367214db13060fb322b

Comparison base: defb7e6e6b87d03259745367214db13060fb322b

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/cli/auth.ts

Status: modified; +78; −6

```diff
@@ -1,10 +1,20 @@
+import {
+  AuthAccessReadScope,
+  AuthSessionId,
+  AuthStandardClientScopes,
+  EnvironmentHttpApi,
+} from "@t3tools/contracts";
-import { AuthSessionId, AuthStandardClientScopes } 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 * as Schema from "effect/Schema";
 import { Argument, Command, Flag, GlobalFlag } from "effect/cli";
+import { FetchHttpClient } from "effect/http";
+import * as HttpApiClient from "effect/http-api/HttpApiClient";
+import * as Runtime from "effect/Runtime";
 
 import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
 
@@ -15,6 +25,7 @@
   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 +34,58 @@
   resolveCliAuthConfig,
 } from "./config.ts";
 
+export class AuthServerUnreachableError extends Schema.TaggedError<AuthServerUnreachableError>()(
-const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* <A, E>(
+  "AuthServerUnreachableError",
+  { origin: Schema.String, cause: Schema.Defect() },
+) {
+  override readonly [Runtime.errorReported] = false;
+  override get message(): string {
+    return `Could not ask the T3 Code server at ${this.origin} which sessions are connected.`;
+  }
+}
+
+const LIVE_SERVER_TIMEOUT = Duration.seconds(10);
+// Bounds how long the session outlives a CLI killed before it can revoke it.
+const LIVE_SERVER_SESSION_TTL = Duration.minutes(1);
+
+/**
+ * Only the running server knows which sessions hold an open WebSocket, so ask
+ * it with a short-lived session that is revoked before the list is read.
+ * With no running server, including a runtime file left by a crash, nothing
+ * can be connected.
+ */
+const connectedSessionIds = Effect.fn("auth.connectedSessionIds")(function* (
+  environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
+) {
+  const config = yield* ServerConfig.ServerConfig;
+  const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
+  if (Option.isNone(runtimeState) || !isProcessAlive(runtimeState.value.pid)) {
+    return new Set<AuthSessionId>();
+  }
+  const origin = runtimeState.value.origin;
+  const clients = yield* Effect.acquireUseRelease(
+    environmentAuth.issueSession({
+      ttl: LIVE_SERVER_SESSION_TTL,
+      scopes: [AuthAccessReadScope],
+      subject: "auth-cli",
+      label: "t3 auth cli",
+    }),
+    (issued) =>
+      HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe(
+        Effect.flatMap((client) =>
+          client.auth.clients({ headers: { authorization: `Bearer ${issued.token}` } }),
+        ),
+        Effect.timeout(LIVE_SERVER_TIMEOUT),
+        Effect.mapError((cause) => new AuthServerUnreachableError({ origin, cause })),
+      ),
+    (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
+  );
+  return new Set(clients.flatMap((client) => (client.connected ? [client.sessionId] : [])));
+});
+
+const runWithEnvironmentAuth = Effect.fn("auth.runWithEnvironmentAuth")(function* <A, E, R>(
   flags: CliAuthLocationFlags,
+  run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E, R>,
-  run: (environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect<A, E>,
   options?: {
     readonly quietLogs?: boolean;
   },
@@ -38,8 +98,8 @@
     return yield* run(environmentAuth);
   }).pipe(
     Effect.provide(
+      Layer.mergeAll(EnvironmentAuth.layerRuntime, FetchHttpClient.layer).pipe(
+        Layer.provideMerge(ServerConfig.layer(config)),
-      Layer.mergeAll(EnvironmentAuth.layerRuntime).pipe(
-        Layer.provide(ServerConfig.layer(config)),
         Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
       ),
     ),
@@ -201,8 +261,20 @@
       flags,
       (environmentAuth) =>
         Effect.gen(function* () {
+          const connected = yield* connectedSessionIds(environmentAuth).pipe(
+            // Runtime.errorReported is off, so this line is the whole report.
+            Effect.tapError((error) => Console.error(error.message)),
+          );
           const sessions = yield* environmentAuth.listSessions();
+          yield* Console.log(
+            formatSessionList(
+              sessions.map((session) => ({
-          yield* Console.log(formatSessionList(sessions, { json: flags.json }));
+                ...session,
+                connected: connected.has(session.sessionId),
+              })),
+              { json: flags.json },
+            ),
+          );
         }),
       {
         quietLogs: flags.json,
```

## apps/server/src/cli/pair.test.ts

Status: modified; +119; −2

```diff
@@ -1,25 +1,35 @@
 // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries.
+import * as NodeChildProcess from "node:child_process";
 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 { AuthStandardClientScopes } from "@t3tools/contracts";
+import { AuthStandardClientScopes, EnvironmentHttpApi } 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 ConfigProvider from "effect/ConfigProvider";
+import * as Context from "effect/Context";
 import * as Effect from "effect/Effect";
 import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
 import * as 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,
@@ -30,6 +40,7 @@
   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" };
@@ -450,3 +461,109 @@
     }),
   );
 });
+
+class AuthRoutesApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}
+
+describe("auth session list", () => {
+  // Issue #21236: only the running server knows which sessions hold an open
+  // WebSocket, so the CLI must not report them from the database alone.
+  it.effect("reports a session connected to the running server as connected", () =>
+    Effect.gen(function* () {
+      const baseDir = NodeFS.mkdtempSync(
+        NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-connected-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: "issue-21236", deviceType: "bot" },
+      });
+      // What the server does when that session's WebSocket opens.
+      yield* sessions.markConnected(session.sessionId);
+      const serverView = (yield* sessions.listActive()).find(
+        (entry) => entry.sessionId === session.sessionId,
+      );
+
+      const listed = JSON.parse(
+        yield* captureStdout(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"])),
+      ) as ReadonlyArray<{ readonly sessionId: string; readonly connected: boolean }>;
+      const cliView = listed.find((entry) => entry.sessionId === session.sessionId);
+
+      assert.isTrue(serverView?.connected);
+      assert.isDefined(cliView);
+      assert.isTrue(cliView?.connected);
+    }).pipe(Effect.scoped, Effect.provide(layerCliRuntime)),
+  );
+
+  it.effect("lists sessions as disconnected when the recorded server process is gone", () =>
+    Effect.gen(function* () {
+      const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-stale-test-"));
+      const config = yield* resolveCliAuthConfig({ baseDir: Option.some(baseDir) }, Option.none());
+      // A runtime file a crashed server left behind: its process has exited.
+      const exited = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]);
+      assert.strictEqual(exited.status, 0);
+      yield* persistServerRuntimeState({
+        path: config.serverRuntimeStatePath,
+        state: {
+          ...baseState,
+          pid: exited.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 listed = JSON.parse(
+        yield* captureStdout(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"])),
+      ) as ReadonlyArray<{ readonly sessionId: string; readonly connected: boolean }>;
+
+      assert.isFalse(listed.find((entry) => entry.sessionId === issued.sessionId)?.connected);
+    }).pipe(Effect.provide(layerCliRuntime)),
+  );
+
+  it.effect("explains on stderr when the running server cannot be reached", () =>
+    Effect.gen(function* () {
+      const baseDir = NodeFS.mkdtempSync(
+        NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-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 stderr = yield* provideCliTestLayers(
+        Effect.gen(function* () {
+          yield* Effect.flip(runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]));
+          return yield* TestConsole.errorLines;
+        }),
+      );
+
+      expect(stderr).toContain(
+        "Could not ask the T3 Code server at http://127.0.0.1:1 which sessions are connected.",
+      );
+    }).pipe(Effect.provide(layerCliRuntime)),
+  );
+});
```
