diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 58c1f1bc0cadf..b733393fd54d5 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -553,6 +553,7 @@ export class EnvironmentAuth extends Context.Service< readonly access: AuthMcpClientAccess; readonly peerLink: boolean; readonly client: AuthClientMetadata; + readonly ttl?: Duration.Duration; }) => Effect.Effect< { readonly sessionId: AuthSessionId; @@ -1344,7 +1345,7 @@ export const make = Effect.gen(function* () { subject: MCP_CLIENT_SUBJECT, method: "bearer-access-token", scopes: mcpClientScopes(input.access), - ttl: MCP_CLIENT_SESSION_TTL, + ttl: input.ttl ?? MCP_CLIENT_SESSION_TTL, ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }), peerLink: input.peerLink, client: { ...input.client, label: input.label, deviceType: "bot" }, diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 5252d81b2a22f..ea240a631596d 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -6,6 +6,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -31,17 +32,25 @@ import { waitForRun, } from "./thread.ts"; +const authMock = vi.hoisted(() => ({ + issuedAccess: [] as Array, + issuedTtlMinutes: [] as Array, +})); + vi.mock("../auth/EnvironmentAuth.ts", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, layerRuntime: Layer.mock(actual.EnvironmentAuth)({ - issueMcpClientSession: () => - Effect.map(DateTime.now, (expiresAt) => ({ + issueMcpClientSession: (input) => { + authMock.issuedAccess.push(input.access); + authMock.issuedTtlMinutes.push(input.ttl && Duration.toMinutes(input.ttl)); + return Effect.map(DateTime.now, (expiresAt) => ({ sessionId: AuthSessionId.make("test-session"), token: "synthetic-token", expiresAt, - })), + })); + }, revokeSession: () => Effect.succeed(true), }), }; @@ -177,6 +186,47 @@ it.effect("does not sign in to whatever took over a dead server's port", () => ), ); +it.effect("list and show sign in read-only, so a killed CLI leaves no write credential", () => + Effect.gen(function* () { + authMock.issuedAccess.length = 0; + authMock.issuedTtlMinutes.length = 0; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-thread-read-access-" }); + yield* persistServerRuntimeState({ + path: path.join(baseDir, "userdata", "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: 3_773, + }), + }); + using _session = vi + .spyOn(McpBridge, "mcpToolSession") + .mockReturnValue(Effect.die("only the issued session matters here")); + + for (const args of [["list"], ["show", threadId]]) { + yield* Command.runWith(threadCommand, { version: "0.0.0" })([ + ...args, + "--base-dir", + baseDir, + ]).pipe(Effect.exit); + } + + assert.deepStrictEqual(authMock.issuedAccess, ["read-only", "read-only"]); + assert.deepStrictEqual(authMock.issuedTtlMinutes, [10, 10]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + NetService.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_PORT: "3773" } })), + ), + ), + ), +); + /** Answers each tool call with the next scripted response for that tool. */ const scriptedTools = (script: Record>) => { const calls: Array<{ readonly tool: string; readonly args: unknown }> = []; diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 5218d0aa2576f..2f6e38158f05b 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -1,4 +1,5 @@ import { + type AuthMcpClientAccess, OrchestratorMcpFailure, OrchestratorMcpThreadListResult, OrchestratorMcpThreadReadResult, @@ -15,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -52,6 +54,7 @@ const TERMINAL_RUN_STATUSES = new Set([ const READ_PAGE_LIMIT = 100; const READ_ITEM_MAX_CHARS = 20_000; const TITLE_MAX_CHARS = 80; +const CLI_SESSION_TTL = Duration.minutes(10); export class ThreadServerNotRunningError extends Schema.TaggedError()( "ThreadServerNotRunningError", @@ -217,6 +220,11 @@ export type ThreadTools = ReturnType; */ const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* ( flags: { readonly baseDir: Option.Option }, + sessionOptions: { + readonly access: AuthMcpClientAccess; + /** A `--wait` has no upper bound, so it keeps the default MCP session lifetime. */ + readonly waits: boolean; + }, run: (tools: ThreadTools) => Effect.Effect, ) { const logLevel = yield* GlobalFlag.LogLevel; @@ -233,9 +241,11 @@ const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* environmentAuth.revokeSession(session.sessionId).pipe(Effect.ignore({ log: true })), @@ -749,6 +759,7 @@ const threadListCommand = Command.make("list", { Command.withHandler((flags) => withThreadTools( flags, + { access: "read-only", waits: false }, Effect.fn("cli.thread.list")(function* (tools) { const project = yield* resolveProject(tools, flags.project); const threads: Array = []; @@ -817,6 +828,7 @@ const threadStartCommand = Command.make("start", { ).pipe(Effect.mapError(() => new ThreadUsageError({ detail: "The title is empty." }))); yield* withThreadTools( flags, + { access: "full-access", waits: flags.wait }, Effect.fn("cli.thread.start")(function* (tools) { const project = yield* resolveProject(tools, flags.project); const launched = yield* tools.call( @@ -869,6 +881,7 @@ const threadSendCommand = Command.make("send", { const message = yield* readMessageArgument(flags.message); yield* withThreadTools( flags, + { access: "full-access", waits: flags.wait }, Effect.fn("cli.thread.send")(function* (tools) { // Work already running when this is sent is not this message's to wait for. const followWakes = flags.wait ? yield* threadIsIdle(tools, threadId) : false; @@ -909,6 +922,7 @@ const threadShowCommand = Command.make("show", { const threadId = yield* decodeThreadId(flags.thread); yield* withThreadTools( flags, + { access: "read-only", waits: false }, Effect.fn("cli.thread.show")(function* (tools) { const { thread, messages: latest } = yield* readLatestMessages( tools,