Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" },
Expand Down
56 changes: 53 additions & 3 deletions apps/server/src/cli/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,17 +32,25 @@ import {
waitForRun,
} from "./thread.ts";

const authMock = vi.hoisted(() => ({
issuedAccess: [] as Array<string>,
issuedTtlMinutes: [] as Array<number | undefined>,
}));

vi.mock("../auth/EnvironmentAuth.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auth/EnvironmentAuth.ts")>();
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),
}),
};
Expand Down Expand Up @@ -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<string, Array<unknown>>) => {
const calls: Array<{ readonly tool: string; readonly args: unknown }> = [];
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/cli/thread.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type AuthMcpClientAccess,
OrchestratorMcpFailure,
OrchestratorMcpThreadListResult,
OrchestratorMcpThreadReadResult,
Expand All @@ -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";
Expand Down Expand Up @@ -52,6 +54,7 @@ const TERMINAL_RUN_STATUSES = new Set<OrchestratorMcpThreadRun["status"]>([
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>()(
"ThreadServerNotRunningError",
Expand Down Expand Up @@ -217,6 +220,11 @@ export type ThreadTools = ReturnType<typeof makeThreadTools>;
*/
const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* <A, E, R>(
flags: { readonly baseDir: Option.Option<string> },
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<A, E, R>,
) {
const logLevel = yield* GlobalFlag.LogLevel;
Expand All @@ -233,9 +241,11 @@ const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* <A, E,
label: "t3 thread cli",
// The CLI runs as the machine's owner. New threads still start at
// the project's default mode, never at this ceiling by default.
access: "full-access",
access: sessionOptions.access,
peerLink: false,
client: { deviceType: "bot" },
// Release does not run when the CLI is killed; expiry bounds what it leaves behind.
...(sessionOptions.waits ? {} : { ttl: CLI_SESSION_TTL }),
}),
(session) =>
environmentAuth.revokeSession(session.sessionId).pipe(Effect.ignore({ log: true })),
Expand Down Expand Up @@ -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<OrchestratorMcpThreadListItem> = [];
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading