From 767954b90311faab72521771c4f0117676605b1f Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 8 Jun 2026 23:26:14 +0300 Subject: [PATCH 1/5] Unify launch env resolution on the server. Resolve authoritative T3CODE_* vars server-side for terminals and provider sessions, strip inherited runtime keys from spawns, and simplify clients to pass only cwd/thread context. Consolidate managed env helpers in shared and collapse terminal launch env wiring. Co-authored-by: Cursor --- .../features/threads/ThreadRouteScreen.tsx | 7 +- apps/mobile/src/state/use-terminal-session.ts | 3 +- .../OrchestrationEngineHarness.integration.ts | 5 +- apps/server/src/bin.test.ts | 7 +- .../src/launchEnv/Services/LaunchEnv.ts | 53 +++++ apps/server/src/launchEnv/launchEnv.test.ts | 47 ++++ apps/server/src/launchEnv/launchEnvUtils.ts | 39 ++++ .../Layers/ProviderCommandReactor.test.ts | 13 +- .../Layers/ProviderCommandReactor.ts | 12 + .../Layers/ProjectSetupScriptRunner.test.ts | 11 +- .../Layers/ProjectSetupScriptRunner.ts | 9 +- .../src/provider/Layers/ClaudeAdapter.ts | 12 +- .../src/provider/Layers/CodexAdapter.test.ts | 2 + .../src/provider/Layers/CodexAdapter.ts | 3 +- .../src/provider/Layers/CursorAdapter.ts | 3 +- .../src/provider/Layers/OpenCodeAdapter.ts | 3 +- .../ProviderInstanceEnvironment.test.ts | 12 + .../provider/ProviderInstanceEnvironment.ts | 19 +- apps/server/src/server.test.ts | 20 ++ apps/server/src/server.ts | 11 +- .../src/terminal/Layers/Manager.test.ts | 5 + apps/server/src/terminal/Layers/Manager.ts | 207 ++++++++++-------- apps/server/src/terminal/Services/Manager.ts | 2 +- .../src/terminal/resolveTerminalLaunchEnv.ts | 190 ++++++++++++++++ apps/web/src/components/ChatView.browser.tsx | 17 +- apps/web/src/components/ChatView.tsx | 57 ++--- .../src/components/ThreadTerminalDrawer.tsx | 14 +- apps/web/src/projectScripts.test.ts | 35 +-- apps/web/src/terminalSessionState.ts | 9 +- packages/contracts/src/provider.ts | 9 + packages/contracts/src/terminal.test.ts | 9 + packages/contracts/src/terminal.ts | 14 +- packages/shared/package.json | 4 + packages/shared/src/launchEnv.test.ts | 23 ++ packages/shared/src/launchEnv.ts | 16 ++ packages/shared/src/projectScripts.ts | 23 -- 36 files changed, 682 insertions(+), 243 deletions(-) create mode 100644 apps/server/src/launchEnv/Services/LaunchEnv.ts create mode 100644 apps/server/src/launchEnv/launchEnv.test.ts create mode 100644 apps/server/src/launchEnv/launchEnvUtils.ts create mode 100644 apps/server/src/terminal/resolveTerminalLaunchEnv.ts create mode 100644 packages/shared/src/launchEnv.test.ts create mode 100644 packages/shared/src/launchEnv.ts diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fd73a45b0c8..2a6919ff46b 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -4,7 +4,7 @@ import * as Arr from "effect/Array"; import * as Option from "effect/Option"; import { pipe } from "effect/Function"; import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { projectScriptCwd } from "@t3tools/shared/projectScripts"; import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; import { useVcsStatus } from "../../state/use-vcs-status"; @@ -198,10 +198,6 @@ export function ThreadRouteScreen() { project: { cwd: selectedThreadProject.workspaceRoot }, worktreePath: preferredWorktreePath, }); - const env = projectScriptRuntimeEnv({ - project: { cwd: selectedThreadProject.workspaceRoot }, - worktreePath: preferredWorktreePath, - }); stagePendingTerminalLaunch({ target: { environmentId: selectedThread.environmentId, @@ -211,7 +207,6 @@ export function ThreadRouteScreen() { launch: { cwd, worktreePath: preferredWorktreePath, - env, initialInput: `${script.command}\r`, }, }); diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 9ea13eef3e3..e56e3ab0ae2 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -12,7 +12,6 @@ import { } from "@t3tools/client-runtime"; import type { EnvironmentId, - TerminalAttachInput, TerminalAttachStreamEvent, TerminalMetadataStreamEvent, TerminalSessionSnapshot, @@ -42,7 +41,7 @@ export function subscribeTerminalMetadata(input: { export function attachTerminalSession(input: { readonly environmentId: EnvironmentId; readonly client: Parameters[0]["client"]; - readonly terminal: TerminalAttachInput; + readonly terminal: Parameters[0]["terminal"]; readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; readonly onEvent?: (event: TerminalAttachStreamEvent) => void; }) { diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 563e5b3a8d3..63754d107db 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -36,6 +36,7 @@ import { ProjectionPendingApprovalRepository } from "../src/persistence/Services import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { LaunchEnvLive } from "../src/launchEnv/Services/LaunchEnv.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; @@ -372,13 +373,15 @@ export const makeOrchestrationIntegrationHarness = ( }), ), ); + const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), Layer.provide(persistenceLayer), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(LaunchEnvLive.pipe(Layer.provide(serverConfigLayer))), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5911bfd9874..59c9f77ea91 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -19,6 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; +import { LaunchEnv } from "./launchEnv/Services/LaunchEnv.ts"; import { cli, makeCli } from "./bin.ts"; import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -35,7 +36,11 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const CliRuntimeLayer = Layer.mergeAll( + NodeServices.layer, + NetService.layer, + LaunchEnv.layerTest("/tmp/t3-cli-test"), +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const cloudCli = makeCli({ cloudEnabled: true }); diff --git a/apps/server/src/launchEnv/Services/LaunchEnv.ts b/apps/server/src/launchEnv/Services/LaunchEnv.ts new file mode 100644 index 00000000000..41fe6f43c80 --- /dev/null +++ b/apps/server/src/launchEnv/Services/LaunchEnv.ts @@ -0,0 +1,53 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ServerConfig } from "../../config.ts"; +import type { EnvRecord } from "../launchEnvUtils.ts"; +import { mergeResolvedLaunchEnv } from "../launchEnvUtils.ts"; + +export interface ResolveLaunchEnvInput { + readonly projectRoot: string; + readonly projectId: ProjectId | string; + readonly threadId: string; + readonly worktreePath?: string | null; + readonly extraEnv?: EnvRecord; +} + +export interface LaunchEnvShape { + readonly resolve: (input: ResolveLaunchEnvInput) => Effect.Effect>; +} + +export const makeResolveLaunchEnv = (t3Home: string): LaunchEnvShape["resolve"] => + Effect.fn("LaunchEnv.resolve")(function* (input) { + return mergeResolvedLaunchEnv({ + t3Home, + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + context: { + projectRoot: input.projectRoot, + projectId: String(input.projectId), + threadId: input.threadId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }, + }); + }); + +export class LaunchEnv extends Context.Service()( + "t3/launchEnv/Services/LaunchEnv", +) { + static readonly layerTest = (t3Home: string) => + Layer.succeed(LaunchEnv, { + resolve: makeResolveLaunchEnv(t3Home), + } satisfies LaunchEnvShape); +} + +export const makeLaunchEnv = Effect.fn("makeLaunchEnv")(function* () { + const serverConfig = yield* ServerConfig; + + return { + resolve: makeResolveLaunchEnv(serverConfig.baseDir), + } satisfies LaunchEnvShape; +}); + +export const LaunchEnvLive = Layer.effect(LaunchEnv, makeLaunchEnv()); diff --git a/apps/server/src/launchEnv/launchEnv.test.ts b/apps/server/src/launchEnv/launchEnv.test.ts new file mode 100644 index 00000000000..49d7b583e25 --- /dev/null +++ b/apps/server/src/launchEnv/launchEnv.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildLaunchContextEnv, mergeResolvedLaunchEnv } from "./launchEnvUtils.ts"; + +describe("launchEnvUtils", () => { + it("builds launch context env", () => { + expect( + buildLaunchContextEnv({ + projectRoot: "/repo", + projectId: "project-1", + threadId: "thread-1", + worktreePath: "/repo/worktree-a", + }), + ).toEqual({ + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_PROJECT_ID: "project-1", + T3CODE_THREAD_ID: "thread-1", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + }); + }); + + it("merges custom env with authoritative server and launch values", () => { + expect( + mergeResolvedLaunchEnv({ + extraEnv: { + T3CODE_PROJECT_ROOT: "/custom-root", + T3CODE_PORT: "3773", + CUSTOM_FLAG: "1", + }, + t3Home: "/data/.t3", + context: { + projectRoot: "/repo", + projectId: "project-1", + threadId: "thread-1", + worktreePath: "/repo/worktree-a", + }, + }), + ).toEqual({ + CUSTOM_FLAG: "1", + T3CODE_HOME: "/data/.t3", + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_PROJECT_ID: "project-1", + T3CODE_THREAD_ID: "thread-1", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + }); + }); +}); diff --git a/apps/server/src/launchEnv/launchEnvUtils.ts b/apps/server/src/launchEnv/launchEnvUtils.ts new file mode 100644 index 00000000000..9d3b927a12e --- /dev/null +++ b/apps/server/src/launchEnv/launchEnvUtils.ts @@ -0,0 +1,39 @@ +import { + type EnvRecord, + isManagedRuntimeEnvKey, + stripManagedRuntimeEnvKeys, +} from "@t3tools/shared/launchEnv"; + +export type { EnvRecord }; +export { isManagedRuntimeEnvKey, stripManagedRuntimeEnvKeys }; + +export interface LaunchEnvContextInput { + readonly projectRoot: string; + readonly projectId: string; + readonly threadId: string; + readonly worktreePath?: string | null; +} + +export function buildLaunchContextEnv(input: LaunchEnvContextInput): Record { + const env: Record = { + T3CODE_PROJECT_ROOT: input.projectRoot, + T3CODE_PROJECT_ID: input.projectId, + T3CODE_THREAD_ID: input.threadId, + }; + if (input.worktreePath) { + env.T3CODE_WORKTREE_PATH = input.worktreePath; + } + return env; +} + +export function mergeResolvedLaunchEnv(input: { + readonly t3Home: string; + readonly extraEnv?: EnvRecord; + readonly context: LaunchEnvContextInput; +}): Record { + return { + ...stripManagedRuntimeEnvKeys(input.extraEnv), + T3CODE_HOME: input.t3Home, + ...buildLaunchContextEnv(input.context), + }; +} diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 16e68bf88bb..1a16cdd28a6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -55,6 +55,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; +import { LaunchEnvLive } from "../../launchEnv/Services/LaunchEnv.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; @@ -331,7 +332,11 @@ describe("ProviderCommandReactor", () => { Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), baseDir).pipe( + Layer.provide(NodeServices.layer), + ); const layer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(LaunchEnvLive.pipe(Layer.provide(serverConfigLayer))), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), @@ -356,7 +361,7 @@ describe("ProviderCommandReactor", () => { }), ), Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.provideMerge(serverConfigLayer), Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); @@ -440,6 +445,12 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", + env: { + T3CODE_HOME: expect.any(String), + T3CODE_PROJECT_ROOT: "/tmp/provider-project", + T3CODE_PROJECT_ID: "project-1", + T3CODE_THREAD_ID: "thread-1", + }, modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f63b873bc3d..e6914bb32b6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -13,6 +13,7 @@ import { type TurnId, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { LaunchEnv } from "../../launchEnv/Services/LaunchEnv.ts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -184,6 +185,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const launchEnv = yield* LaunchEnv; const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -412,6 +414,15 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }); + const providerLaunchEnv = + project !== undefined + ? yield* launchEnv.resolve({ + projectRoot: project.workspaceRoot, + projectId: project.id, + threadId, + worktreePath: thread.worktreePath, + }) + : undefined; const startProviderSession = (input?: { readonly resumeCursor?: unknown; @@ -422,6 +433,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(providerLaunchEnv ? { env: providerLaunchEnv } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 051a7d20de0..6b54a7d9961 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { describe, expect, it, vi } from "vite-plus/test"; +import { LaunchEnv } from "../../launchEnv/Services/LaunchEnv.ts"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { TerminalManager } from "../../terminal/Services/Manager.ts"; import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; @@ -20,6 +21,9 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro deletedAt: null, }); +const TEST_BASE_DIR = "/tmp/t3-setup-script-runner"; +const launchEnvLayer = LaunchEnv.layerTest(TEST_BASE_DIR); + const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => Layer.succeed(ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), @@ -50,6 +54,7 @@ describe("ProjectSetupScriptRunner", () => { Effect.service(ProjectSetupScriptRunner).pipe( Effect.provide( ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge(launchEnvLayer), Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge( Layer.succeed(TerminalManager, { @@ -112,6 +117,7 @@ describe("ProjectSetupScriptRunner", () => { Effect.service(ProjectSetupScriptRunner).pipe( Effect.provide( ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge(launchEnvLayer), Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge( Layer.succeed(TerminalManager, { @@ -149,12 +155,9 @@ describe("ProjectSetupScriptRunner", () => { expect(open).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup", + projectId: ProjectId.make("project-1"), cwd: "/repo/worktrees/a", worktreePath: "/repo/worktrees/a", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - T3CODE_WORKTREE_PATH: "/repo/worktrees/a", - }, }); expect(write).toHaveBeenCalledWith({ threadId: "thread-1", diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index 61cd043b43b..0d57a8f261d 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -1,5 +1,5 @@ import { ProjectId } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { setupProjectScript } from "@t3tools/shared/projectScripts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -46,17 +46,12 @@ const makeProjectSetupScriptRunner = Effect.gen(function* () { const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; const cwd = input.worktreePath; - const env = projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: input.worktreePath, - }); - yield* terminalManager.open({ threadId: input.threadId, terminalId, + projectId: project.id, cwd, worktreePath: input.worktreePath, - env, }); yield* terminalManager.write({ threadId: input.threadId, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 78365eb8a16..b512d5b7f00 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -68,6 +68,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { getClaudeModelCapabilities, isClaudeUltracodeEffort, @@ -1053,9 +1054,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( - Effect.provideService(Path.Path, path), - ); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -2646,6 +2644,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const startedAt = yield* nowIso; + const providerSessionEnvironment = mergeProviderSessionEnvironment( + options?.environment, + input.env, + ); + const claudeEnvironment = yield* makeClaudeEnvironment( + claudeSettings, + providerSessionEnvironment, + ).pipe(Effect.provideService(Path.Path, path)); const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = input.threadId; const existingResumeSessionId = resumeState?.resume; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3ae98ec248c..7d70272c050 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -45,6 +45,7 @@ import { type CodexSessionRuntimeShape, type CodexThreadSnapshot, } from "./CodexSessionRuntime.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeCodexAdapter } from "./CodexAdapter.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); @@ -279,6 +280,7 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], { binaryPath: "codex", cwd: process.cwd(), + environment: mergeProviderSessionEnvironment(undefined, undefined), model: "gpt-5.3-codex", providerInstanceId: ProviderInstanceId.make("codex"), serviceTier: "fast", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 9893cf6c149..556565e5fc8 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -53,6 +53,7 @@ import { import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, @@ -1385,7 +1386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, - ...(options?.environment ? { environment: options.environment } : {}), + environment: mergeProviderSessionEnvironment(options?.environment, input.env), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 016feeb79a4..29d29a29068 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -528,7 +529,7 @@ export function makeCursorAdapter( const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), + environment: mergeProviderSessionEnvironment(options?.environment, input.env), childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 34a67199125..1f20f5d0618 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -26,6 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, @@ -1046,7 +1047,7 @@ export function makeOpenCodeAdapter( const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, serverUrl, - ...(options?.environment ? { environment: options.environment } : {}), + environment: mergeProviderSessionEnvironment(options?.environment, input.env), }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f2837..8c096f69079 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -3,6 +3,18 @@ import { describe, expect, it } from "vite-plus/test"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it("strips inherited T3 Code runtime env keys", () => { + expect( + mergeProviderInstanceEnvironment(undefined, { + T3CODE_PORT: "3773", + T3CODE_HOME: "/tmp/.t3", + PATH: "/bin", + }), + ).toEqual({ + PATH: "/bin", + }); + }); + it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e469253604e..424c80390d7 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,16 +1,31 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { isManagedRuntimeEnvKey, stripManagedRuntimeEnvKeys } from "../launchEnv/launchEnvUtils.ts"; + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = stripManagedRuntimeEnvKeys(baseEnv); if (!environment || environment.length === 0) { - return baseEnv; + return next; } - const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { + if (isManagedRuntimeEnvKey(variable.name)) continue; next[variable.name] = variable.value; } return next; } + +export function mergeProviderSessionEnvironment( + baseEnv: NodeJS.ProcessEnv | undefined, + sessionEnv: Readonly> | undefined, +): NodeJS.ProcessEnv { + const next = stripManagedRuntimeEnvKeys(baseEnv ?? process.env); + if (!sessionEnv) return next; + for (const [key, value] of Object.entries(sessionEnv)) { + next[key] = value; + } + return next; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d061578ca68..07cafd869ff 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -68,6 +68,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; +import { LaunchEnvLive } from "./launchEnv/Services/LaunchEnv.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { @@ -793,6 +794,7 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(LaunchEnvLive), Layer.provide(layerConfig), ); @@ -6450,6 +6452,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc terminal methods", () => Effect.gen(function* () { + const terminalProjectId = ProjectId.make("project-1"); + const terminalProject = { + id: terminalProjectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; const snapshot = { threadId: "thread-1", terminalId: "default", @@ -6466,6 +6478,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + projectionSnapshotQuery: { + getProjectShellById: (projectId) => + Effect.succeed( + projectId === terminalProjectId ? Option.some(terminalProject) : Option.none(), + ), + }, terminalManager: { open: () => Effect.succeed(snapshot), write: () => Effect.void, @@ -6484,6 +6502,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { client[WS_METHODS.terminalOpen]({ threadId: "thread-1", terminalId: "default", + projectId: terminalProjectId, cwd: "/tmp/project", }), ), @@ -6525,6 +6544,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { client[WS_METHODS.terminalRestart]({ threadId: "thread-1", terminalId: "default", + projectId: terminalProjectId, cwd: "/tmp/project", cols: 120, rows: 40, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98bef90bb2e..c113390cb0c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -66,6 +66,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; +import { LaunchEnvLive } from "./launchEnv/Services/LaunchEnv.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -260,6 +261,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( ); const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( + Layer.provideMerge(Layer.mergeAll(LaunchEnvLive, ServerEnvironmentLive)), // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), @@ -292,7 +294,6 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), - Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -450,8 +451,14 @@ export const makeServerLayer = Layer.unwrap( cloudDesiredLinkReconcileLayer, ); + const serverConfigLayer = Layer.succeed(ServerConfig, config); return serverApplicationLayer.pipe( - Layer.provideMerge(RuntimeServicesLive), + Layer.provideMerge( + RuntimeServicesLive.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(LaunchEnvLive.pipe(Layer.provide(serverConfigLayer))), + ), + ), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2ebf8481957..5f9c6306801 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, + ProjectId, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, @@ -35,6 +36,7 @@ import { PtySpawnError, } from "../Services/PTY.ts"; import { makeTerminalManagerWithOptions } from "./Manager.ts"; +import { terminalLaunchEnvResolverTest } from "../resolveTerminalLaunchEnv.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ readonly message: string; @@ -157,6 +159,7 @@ function openInput(overrides: Partial = {}): TerminalOpenInpu return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, + projectId: ProjectId.make("project-1"), cwd: process.cwd(), cols: 100, rows: 24, @@ -168,6 +171,7 @@ function restartInput(overrides: Partial = {}): TerminalRe return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, + projectId: ProjectId.make("project-1"), cwd: process.cwd(), cols: 100, rows: 24, @@ -238,6 +242,7 @@ const createManager = ( logsDir, historyLineLimit, ptyAdapter, + launchEnvResolver: terminalLaunchEnvResolverTest(ProjectId.make("project-1")), ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index cd490de1e3f..5ca7c9b6d3b 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1,16 +1,28 @@ import { DEFAULT_TERMINAL_ID, - type TerminalAttachInput, + ProjectId, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, + type TerminalAttachInput, type TerminalOpenInput, + type TerminalRestartInput, type TerminalSessionSnapshot, type TerminalSessionStatus, type TerminalSummary, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import { LaunchEnv } from "../../launchEnv/Services/LaunchEnv.ts"; +import { isManagedRuntimeEnvKey } from "../../launchEnv/launchEnvUtils.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + bindTerminalLaunchEnvResolver, + type TerminalAttachRuntimeInput, + type TerminalLaunchEnvResolver, + type TerminalLaunchEnvResolverServices, +} from "../resolveTerminalLaunchEnv.ts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -98,6 +110,7 @@ interface ShellCandidate { interface TerminalStartInput { threadId: string; terminalId: string; + projectId: ProjectId; cwd: string; worktreePath?: string | null; cols: number; @@ -885,7 +898,7 @@ function toSessionKey(threadId: string, terminalId: string): string { function shouldExcludeTerminalEnvKey(key: string): boolean { const normalizedKey = key.toUpperCase(); - if (normalizedKey.startsWith("T3CODE_")) { + if (isManagedRuntimeEnvKey(normalizedKey)) { return true; } if (normalizedKey.startsWith("VITE_")) { @@ -913,10 +926,12 @@ function createTerminalSpawnEnv( } function normalizedRuntimeEnv( - env: Record | undefined, + env: Readonly> | undefined, ): Record | null { if (!env) return null; - const entries = Object.entries(env); + const entries = Object.entries(env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ); if (entries.length === 0) return null; return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); } @@ -932,11 +947,13 @@ interface TerminalManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; + launchEnvResolver?: TerminalLaunchEnvResolver; } const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; + return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, @@ -949,6 +966,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); + const resolverServices = context as Context.Context; + const launchEnvResolver = + options.launchEnvResolver ?? + bindTerminalLaunchEnvResolver( + Context.get(resolverServices, LaunchEnv), + Context.get(resolverServices, ProjectionSnapshotQuery), + ); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; @@ -967,6 +991,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; + const resolveOpenInput = launchEnvResolver.resolveOpenInput; + const resolveAttachInput = launchEnvResolver.resolveAttachInput; + const resolveRestartInput = launchEnvResolver.resolveRestartInput; yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); @@ -1884,6 +1911,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { const terminalId = input.terminalId; yield* assertValidCwd(input.cwd); + const nextRuntimeEnv = normalizedRuntimeEnv(input.env); const sessionKey = toSessionKey(input.threadId, terminalId); const existing = yield* getSession(input.threadId, terminalId); @@ -1915,7 +1943,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith unsubscribeExit: null, hasRunningSubprocess: false, childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), + runtimeEnv: nextRuntimeEnv, }; const createdSession = session; @@ -1931,6 +1959,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith { threadId: input.threadId, terminalId, + projectId: input.projectId, cwd: input.cwd, ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, @@ -1943,7 +1972,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } const liveSession = existing.value; - const nextRuntimeEnv = normalizedRuntimeEnv(input.env); const currentRuntimeEnv = liveSession.runtimeEnv; const targetCols = input.cols ?? liveSession.cols; const targetRows = input.rows ?? liveSession.rows; @@ -1983,6 +2011,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith { threadId: input.threadId, terminalId, + projectId: input.projectId, cwd: input.cwd, worktreePath: liveSession.worktreePath, cols: targetCols, @@ -2005,9 +2034,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const open: TerminalManagerShape["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock(input.threadId, resolveOpenInput(input).pipe(Effect.flatMap(openLocked))); - const openOrAttachForStream = (input: TerminalAttachInput) => + const openOrAttachForStream = (input: TerminalAttachRuntimeInput) => withThreadLock( input.threadId, Effect.gen(function* () { @@ -2021,7 +2050,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith terminalId, }); } - return yield* openLocked({ ...input, terminalId, @@ -2107,7 +2135,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith return attachEvent ? listener(attachEvent) : Effect.void; }); - const initialSnapshot = yield* openOrAttachForStream(input); + const resolvedInput = yield* resolveAttachInput(input); + const initialSnapshot = yield* openOrAttachForStream(resolvedInput); yield* listener({ type: "snapshot", @@ -2277,84 +2306,90 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }), ); + const restartLocked = Effect.fn("terminal.restartLocked")(function* ( + input: TerminalRestartInput, + ) { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + const nextRuntimeEnv = normalizedRuntimeEnv(input.env); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: "", + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: nextRuntimeEnv, + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = nextRuntimeEnv; + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history = ""; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + projectId: input.projectId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); + const restart: TerminalManagerShape["restart"] = (input) => withThreadLock( input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: "", - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history = ""; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), + resolveRestartInput(input).pipe(Effect.flatMap(restartLocked)), ); const close: TerminalManagerShape["close"] = (input) => @@ -2389,7 +2424,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith close, subscribe, subscribeMetadata, - } satisfies TerminalManagerShape; + } as TerminalManagerShape; }, ); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 51c66f49f7c..c7e7c95f00d 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -7,7 +7,6 @@ * @module TerminalManager */ import { - TerminalAttachInput, TerminalAttachStreamEvent, TerminalClearInput, TerminalCloseInput, @@ -17,6 +16,7 @@ import { TerminalHistoryError, TerminalMetadataStreamEvent, TerminalNotRunningError, + TerminalAttachInput, TerminalOpenInput, TerminalResizeInput, TerminalRestartInput, diff --git a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts new file mode 100644 index 00000000000..c8d1b39436b --- /dev/null +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts @@ -0,0 +1,190 @@ +import { + ProjectId, + ThreadId, + type TerminalAttachInput, + type TerminalOpenInput, + type TerminalRestartInput, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { LaunchEnv, type LaunchEnvShape } from "../launchEnv/Services/LaunchEnv.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { TerminalCwdError, TerminalSessionLookupError } from "./Services/Manager.ts"; + +export type TerminalAttachRuntimeInput = TerminalAttachInput & { + readonly projectId: ProjectId; +}; + +export interface TerminalLaunchEnvResolver { + readonly resolveOpenInput: ( + input: TerminalOpenInput, + ) => Effect.Effect; + readonly resolveRestartInput: ( + input: TerminalRestartInput, + ) => Effect.Effect; + readonly resolveAttachInput: ( + input: TerminalAttachInput, + ) => Effect.Effect; +} + +export interface ResolveTerminalLaunchEnvInput { + readonly projectId: ProjectId; + readonly threadId: string; + readonly worktreePath?: string | null; + readonly extraEnv?: Record; +} + +export const resolveTerminalLaunchEnv = Effect.fn("resolveTerminalLaunchEnv")(function* ( + input: ResolveTerminalLaunchEnvInput, +) { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const launchEnv = yield* LaunchEnv; + const projectId = input.projectId; + + const projectOption = yield* projectionSnapshotQuery.getProjectShellById(projectId).pipe( + Effect.mapError( + (cause) => + new TerminalCwdError({ + cwd: projectId, + reason: "statFailed", + cause, + }), + ), + ); + + const project = yield* Option.match(projectOption, { + onNone: () => + Effect.fail( + new TerminalCwdError({ + cwd: projectId, + reason: "notFound", + }), + ), + onSome: Effect.succeed, + }); + + return yield* launchEnv.resolve({ + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + projectRoot: project.workspaceRoot, + projectId: project.id, + threadId: input.threadId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); +}); + +export const resolveTerminalOpenInput = Effect.fn("resolveTerminalOpenInput")(function* ( + input: TerminalOpenInput, +) { + const env = yield* resolveTerminalLaunchEnv({ + projectId: input.projectId, + threadId: input.threadId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.env !== undefined ? { extraEnv: input.env } : {}), + }); + + return { + ...input, + env, + }; +}); + +export const resolveTerminalRestartInput = Effect.fn("resolveTerminalRestartInput")(function* ( + input: TerminalRestartInput, +) { + const env = yield* resolveTerminalLaunchEnv({ + projectId: input.projectId, + threadId: input.threadId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.env !== undefined ? { extraEnv: input.env } : {}), + }); + + return { + ...input, + env, + }; +}); + +export const resolveTerminalAttachInput = Effect.fn("resolveTerminalAttachInput")(function* ( + input: TerminalAttachInput, +) { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const threadOption = yield* projectionSnapshotQuery + .getThreadShellById(ThreadId.make(input.threadId)) + .pipe( + Effect.mapError( + () => + new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ), + ); + + const thread = yield* Option.match(threadOption, { + onNone: () => + Effect.fail( + new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ), + onSome: Effect.succeed, + }); + + const worktreePath = input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath; + + const env = yield* resolveTerminalLaunchEnv({ + projectId: thread.projectId, + threadId: input.threadId, + worktreePath, + ...(input.env !== undefined ? { extraEnv: input.env } : {}), + }); + + return { + ...input, + projectId: thread.projectId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + env, + } satisfies TerminalAttachRuntimeInput; +}); + +export type TerminalLaunchEnvResolverServices = LaunchEnv | ProjectionSnapshotQuery; + +const provideTerminalLaunchEnvResolverServices = ( + services: Context.Context, + effect: Effect.Effect, +) => effect.pipe(Effect.provide(services)); + +export const bindTerminalLaunchEnvResolver = ( + launchEnv: LaunchEnvShape, + projectionSnapshotQuery: ProjectionSnapshotQueryShape, +): TerminalLaunchEnvResolver => { + const services = Context.make(LaunchEnv, launchEnv).pipe( + Context.add(ProjectionSnapshotQuery, projectionSnapshotQuery), + ); + + return { + resolveOpenInput: (input) => + provideTerminalLaunchEnvResolverServices(services, resolveTerminalOpenInput(input)), + resolveRestartInput: (input) => + provideTerminalLaunchEnvResolverServices(services, resolveTerminalRestartInput(input)), + resolveAttachInput: (input) => + provideTerminalLaunchEnvResolverServices(services, resolveTerminalAttachInput(input)), + }; +}; + +export const terminalLaunchEnvResolverTest = (projectId: ProjectId): TerminalLaunchEnvResolver => ({ + resolveOpenInput: (input) => Effect.succeed(input), + resolveRestartInput: (input) => Effect.succeed(input), + resolveAttachInput: (input) => + Effect.succeed({ + ...input, + projectId, + }), +}); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 69e0e514061..56a3ec02fef 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2003,11 +2003,8 @@ describe("ChatView timeline estimator parity (full app)", () => { _tag: WS_METHODS.terminalAttach, cwd: "/repo/project", worktreePath: null, - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - }, }); - expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); + expect(attachRequest?.env).toBeUndefined(); }, { timeout: 8_000, interval: 16 }, ); @@ -2355,11 +2352,10 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID, + projectId: PROJECT_ID, cwd: "/repo/project", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - }, }); + expect(openRequest?.env).toBeUndefined(); }, { timeout: 8_000, interval: 16 }, ); @@ -2434,12 +2430,11 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID, + projectId: PROJECT_ID, cwd: "/repo/worktrees/feature-draft", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - T3CODE_WORKTREE_PATH: "/repo/worktrees/feature-draft", - }, + worktreePath: "/repo/worktrees/feature-draft", }); + expect(openRequest?.env).toBeUndefined(); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7fc7669fe60..a549c5744f4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -32,7 +32,8 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { stripManagedRuntimeEnvKeys } from "@t3tools/shared/launchEnv"; +import { projectScriptCwd } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; @@ -525,7 +526,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra { readonly cwd: string; readonly worktreePath: string | null; - readonly runtimeEnv: Record; } >(); if (!project) { @@ -542,10 +542,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra next.set(session.target.terminalId, { cwd: launchContext?.cwd ?? summary.cwd, worktreePath: worktreePathForLaunch, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, - worktreePath: worktreePathForLaunch, - }), }); } @@ -592,17 +588,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra : null), [effectiveWorktreePath, launchContext?.cwd, project], ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - const bumpFocusRequestId = useCallback(() => { if (!visible) { return; @@ -619,7 +604,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const splitTerminal = useCallback(() => { const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!api || !cwd || !project) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); @@ -630,9 +615,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra await api.terminal.open({ threadId, terminalId, + projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -642,16 +627,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, - runtimeEnv, serverOrderedTerminalIds, storeSplitTerminal, threadId, threadRef, + project, ]); const createNewTerminal = useCallback(() => { const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!api || !cwd || !project) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); @@ -662,9 +647,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra await api.terminal.open({ threadId, terminalId, + projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -674,11 +659,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, - runtimeEnv, serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, + project, ]); const activateTerminal = useCallback( @@ -739,7 +724,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId={threadId} cwd={cwd} worktreePath={effectiveWorktreePath} - runtimeEnv={runtimeEnv} visible={visible} height={terminalUiState.terminalHeight} // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. @@ -2024,12 +2008,9 @@ export default function ChatView(props: ChatViewProps) { await api.terminal.open({ threadId: activeThreadId, terminalId, + projectId: activeProject.id, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.cwd }, - worktreePath: activeThreadWorktreePath, - }), }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -2066,12 +2047,9 @@ export default function ChatView(props: ChatViewProps) { await api.terminal.open({ threadId: activeThreadId, terminalId, + projectId: activeProject.id, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.cwd }, - worktreePath: activeThreadWorktreePath, - }), }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -2155,13 +2133,8 @@ export default function ChatView(props: ChatViewProps) { } setTerminalFocusRequestId((value) => value + 1); - const runtimeEnv = projectScriptRuntimeEnv({ - project: { - cwd: activeProject.cwd, - }, - worktreePath: targetWorktreePath, - ...(options?.env ? { extraEnv: options.env } : {}), - }); + const customEnv = options?.env ? stripManagedRuntimeEnvKeys(options.env) : {}; + const customRuntimeEnv = Object.keys(customEnv).length > 0 ? { env: customEnv } : {}; const targetTerminalId = shouldCreateNewTerminal ? nextTerminalId(activeKnownTerminalIds) : baseTerminalId; @@ -2169,18 +2142,20 @@ export default function ChatView(props: ChatViewProps) { ? { threadId: activeThreadId, terminalId: targetTerminalId, + projectId: activeProject.id, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, + ...customRuntimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, } : { threadId: activeThreadId, terminalId: targetTerminalId, + projectId: activeProject.id, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, + ...customRuntimeEnv, }; if (shouldCreateNewTerminal) { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9e57cbe60e1..8082eec18de 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -271,7 +271,6 @@ interface TerminalViewportProps { interface TerminalLaunchLocation { readonly cwd: string; readonly worktreePath?: string | null; - readonly runtimeEnv?: Record; } export function TerminalViewport({ @@ -668,7 +667,7 @@ export function TerminalViewport({ ...(worktreePath !== undefined ? { worktreePath } : {}), cols: activeTerminal.cols, rows: activeTerminal.rows, - ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(runtimeEnv && Object.keys(runtimeEnv).length > 0 ? { env: runtimeEnv } : {}), }, onEvent: (event) => { if (disposed) return; @@ -778,7 +777,6 @@ interface ThreadTerminalDrawerProps { threadId: ThreadId; cwd: string; worktreePath?: string | null; - runtimeEnv?: Record; visible?: boolean; height: number; terminalIds: string[]; @@ -836,7 +834,6 @@ export default function ThreadTerminalDrawer({ threadId, cwd, worktreePath, - runtimeEnv, visible = true, height, terminalIds, @@ -992,11 +989,10 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById?.get(terminalId) ?? { cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), - ...(runtimeEnv ? { runtimeEnv } : {}), } ); }, - [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], + [cwd, terminalLaunchLocationsById, worktreePath], ); const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` @@ -1231,9 +1227,6 @@ export default function ThreadTerminalDrawer({ {...(terminalLaunchLocation.worktreePath !== undefined ? { worktreePath: terminalLaunchLocation.worktreePath } : {})} - {...(terminalLaunchLocation.runtimeEnv - ? { runtimeEnv: terminalLaunchLocation.runtimeEnv } - : {})} onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} @@ -1259,9 +1252,6 @@ export default function ThreadTerminalDrawer({ {...(activeTerminalLaunchLocation.worktreePath !== undefined ? { worktreePath: activeTerminalLaunchLocation.worktreePath } : {})} - {...(activeTerminalLaunchLocation.runtimeEnv - ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv } - : {})} onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index 3597b714c77..8109c7be5f7 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - projectScriptCwd, - projectScriptRuntimeEnv, - setupProjectScript, -} from "@t3tools/shared/projectScripts"; +import { stripManagedRuntimeEnvKeys } from "@t3tools/shared/launchEnv"; +import { projectScriptCwd, setupProjectScript } from "@t3tools/shared/projectScripts"; import { commandForProjectScript, @@ -48,30 +45,16 @@ describe("projectScripts helpers", () => { expect(setupProjectScript(scripts)?.id).toBe("setup"); }); - it("builds default runtime env for scripts", () => { - const env = projectScriptRuntimeEnv({ - project: { cwd: "/repo" }, - worktreePath: "/repo/worktree-a", - }); - - expect(env).toMatchObject({ - T3CODE_PROJECT_ROOT: "/repo", - T3CODE_WORKTREE_PATH: "/repo/worktree-a", - }); - }); - - it("allows overriding runtime env values", () => { - const env = projectScriptRuntimeEnv({ - project: { cwd: "/repo" }, - extraEnv: { + it("strips managed T3 Code env keys from custom runtime env", () => { + expect( + stripManagedRuntimeEnvKeys({ T3CODE_PROJECT_ROOT: "/custom-root", + T3CODE_HOME: "/config-home", CUSTOM_FLAG: "1", - }, + }), + ).toEqual({ + CUSTOM_FLAG: "1", }); - - expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); - expect(env.CUSTOM_FLAG).toBe("1"); - expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); }); it("prefers the worktree path for script cwd resolution", () => { diff --git a/apps/web/src/terminalSessionState.ts b/apps/web/src/terminalSessionState.ts index 106a16f8fd7..3c3f529562e 100644 --- a/apps/web/src/terminalSessionState.ts +++ b/apps/web/src/terminalSessionState.ts @@ -13,12 +13,7 @@ import { type TerminalSessionTarget, type TerminalSessionState, } from "@t3tools/client-runtime"; -import type { - EnvironmentId, - TerminalAttachInput, - TerminalSessionSnapshot, - ThreadId, -} from "@t3tools/contracts"; +import type { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; import { appAtomRegistry } from "./rpc/atomRegistry"; @@ -36,7 +31,7 @@ export function subscribeTerminalMetadata(input: { export function attachTerminalSession(input: { readonly environmentId: EnvironmentId; readonly client: Parameters[0]["client"]; - readonly terminal: TerminalAttachInput; + readonly terminal: Parameters[0]["terminal"]; readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; readonly onEvent?: Parameters[0]["onEvent"]; }) { diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc..7614f61f3ce 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -31,6 +31,14 @@ const ProviderSessionStatus = Schema.Literals([ "closed", ]); +const ProviderEnvKey = Schema.String.check(Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/)).check( + Schema.isMaxLength(128), +); +const ProviderEnvValue = Schema.String.check(Schema.isMaxLength(8_192)); +const ProviderEnv = Schema.Record(ProviderEnvKey, ProviderEnvValue).check( + Schema.isMaxProperties(256), +); + export const ProviderSession = Schema.Struct({ provider: ProviderDriverKind, // Optional during the driver/instance migration. Once every producer @@ -58,6 +66,7 @@ export const ProviderSessionStartInput = Schema.Struct({ cwd: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), + env: Schema.optional(ProviderEnv), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), runtimeMode: RuntimeMode, diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index a08ed492388..c2f4951e1a9 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; +import { ProjectId } from "./baseSchemas.ts"; import { DEFAULT_TERMINAL_ID, @@ -14,6 +15,8 @@ import { TerminalWriteInput, } from "./terminal.ts"; +const PROJECT_ID = ProjectId.make("project-1"); + function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; } @@ -33,6 +36,7 @@ describe("TerminalOpenInput", () => { decodes(TerminalOpenInput, { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, + projectId: PROJECT_ID, cwd: "/tmp/project", cols: 120, rows: 40, @@ -45,6 +49,7 @@ describe("TerminalOpenInput", () => { decodes(TerminalOpenInput, { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, + projectId: PROJECT_ID, cwd: "/tmp/project", cols: 423, rows: 40, @@ -57,6 +62,7 @@ describe("TerminalOpenInput", () => { decodes(TerminalOpenInput, { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, + projectId: PROJECT_ID, cwd: "/tmp/project", cols: 10, rows: 0, @@ -68,6 +74,7 @@ describe("TerminalOpenInput", () => { expect( decodes(TerminalOpenInput, { threadId: "thread-1", + projectId: PROJECT_ID, cwd: "/tmp/project", cols: 100, rows: 24, @@ -79,6 +86,7 @@ describe("TerminalOpenInput", () => { const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, + projectId: PROJECT_ID, cwd: "/tmp/project", worktreePath: "/tmp/project/.t3/worktrees/feature-a", cols: 100, @@ -99,6 +107,7 @@ describe("TerminalOpenInput", () => { expect( decodes(TerminalOpenInput, { threadId: "thread-1", + projectId: PROJECT_ID, cwd: "/tmp/project", cols: 100, rows: 24, diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index a3c8e37e7f9..13a9795473e 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Client-side id for the first shell opened on a thread. Ids are uniformly @@ -36,15 +36,20 @@ const TerminalSessionInput = Schema.Struct({ }); export type TerminalSessionInput = Schema.Codec.Encoded; +const TerminalLaunchContextInput = Schema.Struct({ + projectId: ProjectId, +}); + export const TerminalOpenInput = Schema.Struct({ ...TerminalSessionInput.fields, + ...TerminalLaunchContextInput.fields, cwd: TrimmedNonEmptyStringSchema, worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), }); -export type TerminalOpenInput = Schema.Codec.Encoded; +export type TerminalOpenInput = typeof TerminalOpenInput.Type; export const TerminalAttachInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -55,7 +60,7 @@ export const TerminalAttachInput = Schema.Struct({ env: Schema.optional(TerminalEnvSchema), restartIfNotRunning: Schema.optional(Schema.Boolean), }); -export type TerminalAttachInput = Schema.Codec.Encoded; +export type TerminalAttachInput = typeof TerminalAttachInput.Type; export const TerminalWriteInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -75,13 +80,14 @@ export type TerminalClearInput = Schema.Codec.Encoded export const TerminalRestartInput = Schema.Struct({ ...TerminalSessionInput.fields, + ...TerminalLaunchContextInput.fields, cwd: TrimmedNonEmptyStringSchema, worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: TerminalColsSchema, rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), }); -export type TerminalRestartInput = Schema.Codec.Encoded; +export type TerminalRestartInput = typeof TerminalRestartInput.Type; export const TerminalCloseInput = Schema.Struct({ ...TerminalThreadInput.fields, diff --git a/packages/shared/package.json b/packages/shared/package.json index 97af1fa5840..4893e7f3e61 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -83,6 +83,10 @@ "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" }, + "./launchEnv": { + "types": "./src/launchEnv.ts", + "import": "./src/launchEnv.ts" + }, "./orchestrationTiming": { "types": "./src/orchestrationTiming.ts", "import": "./src/orchestrationTiming.ts" diff --git a/packages/shared/src/launchEnv.test.ts b/packages/shared/src/launchEnv.test.ts new file mode 100644 index 00000000000..85ff6d8df93 --- /dev/null +++ b/packages/shared/src/launchEnv.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isManagedRuntimeEnvKey, stripManagedRuntimeEnvKeys } from "./launchEnv.ts"; + +describe("launchEnv", () => { + it("identifies managed runtime env keys", () => { + expect(isManagedRuntimeEnvKey("T3CODE_PORT")).toBe(true); + expect(isManagedRuntimeEnvKey("t3code_home")).toBe(true); + expect(isManagedRuntimeEnvKey("CUSTOM_FLAG")).toBe(false); + }); + + it("strips inherited managed runtime env keys", () => { + expect( + stripManagedRuntimeEnvKeys({ + T3CODE_PORT: "3773", + T3CODE_HOME: "/tmp/.t3", + CUSTOM_FLAG: "1", + }), + ).toEqual({ + CUSTOM_FLAG: "1", + }); + }); +}); diff --git a/packages/shared/src/launchEnv.ts b/packages/shared/src/launchEnv.ts new file mode 100644 index 00000000000..9f68bdb9369 --- /dev/null +++ b/packages/shared/src/launchEnv.ts @@ -0,0 +1,16 @@ +export type EnvRecord = Readonly>; + +export function isManagedRuntimeEnvKey(key: string): boolean { + return key.toUpperCase().startsWith("T3CODE_"); +} + +export function stripManagedRuntimeEnvKeys(env: EnvRecord | undefined): Record { + const next: Record = {}; + if (!env) return next; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) continue; + if (isManagedRuntimeEnvKey(key)) continue; + next[key] = value; + } + return next; +} diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 199a55bf3cb..4dfdec22a3e 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,13 +1,5 @@ import type { ProjectScript } from "@t3tools/contracts"; -interface ProjectScriptRuntimeEnvInput { - project: { - cwd: string; - }; - worktreePath?: string | null; - extraEnv?: Record; -} - export function projectScriptCwd(input: { project: { cwd: string; @@ -17,21 +9,6 @@ export function projectScriptCwd(input: { return input.worktreePath ?? input.project.cwd; } -export function projectScriptRuntimeEnv( - input: ProjectScriptRuntimeEnvInput, -): Record { - const env: Record = { - T3CODE_PROJECT_ROOT: input.project.cwd, - }; - if (input.worktreePath) { - env.T3CODE_WORKTREE_PATH = input.worktreePath; - } - if (input.extraEnv) { - return { ...env, ...input.extraEnv }; - } - return env; -} - export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null { return scripts.find((script) => script.runOnWorktreeCreate) ?? null; } From 42247da9c6fd24a1439192228751b7860b3880e4 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Tue, 9 Jun 2026 20:33:32 +0300 Subject: [PATCH 2/5] Fix terminal launch env resolution to stop server crash-loops. Resolve launch env at RPC time instead of layer construction, and derive project context from threadId server-side so terminal open/restart no longer trust client projectId. Co-authored-by: Cursor --- .../Layers/ProjectSetupScriptRunner.test.ts | 1 - .../Layers/ProjectSetupScriptRunner.ts | 1 - apps/server/src/server.test.ts | 2 - .../src/terminal/Layers/Manager.test.ts | 2 - apps/server/src/terminal/Layers/Manager.ts | 127 ++++++++-------- .../terminal/resolveTerminalLaunchEnv.test.ts | 135 ++++++++++++++++++ .../src/terminal/resolveTerminalLaunchEnv.ts | 125 +++++++++++----- apps/web/src/components/ChatView.browser.tsx | 2 - apps/web/src/components/ChatView.tsx | 6 - packages/contracts/src/terminal.test.ts | 9 -- packages/contracts/src/terminal.ts | 8 +- 11 files changed, 284 insertions(+), 134 deletions(-) create mode 100644 apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 6b54a7d9961..39246c340e9 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -155,7 +155,6 @@ describe("ProjectSetupScriptRunner", () => { expect(open).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup", - projectId: ProjectId.make("project-1"), cwd: "/repo/worktrees/a", worktreePath: "/repo/worktrees/a", }); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index 0d57a8f261d..e24524ecb24 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -49,7 +49,6 @@ const makeProjectSetupScriptRunner = Effect.gen(function* () { yield* terminalManager.open({ threadId: input.threadId, terminalId, - projectId: project.id, cwd, worktreePath: input.worktreePath, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 07cafd869ff..e3b8804c3a2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6502,7 +6502,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { client[WS_METHODS.terminalOpen]({ threadId: "thread-1", terminalId: "default", - projectId: terminalProjectId, cwd: "/tmp/project", }), ), @@ -6544,7 +6543,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { client[WS_METHODS.terminalRestart]({ threadId: "thread-1", terminalId: "default", - projectId: terminalProjectId, cwd: "/tmp/project", cols: 120, rows: 40, diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 5f9c6306801..690a156fe56 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -159,7 +159,6 @@ function openInput(overrides: Partial = {}): TerminalOpenInpu return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, - projectId: ProjectId.make("project-1"), cwd: process.cwd(), cols: 100, rows: 24, @@ -171,7 +170,6 @@ function restartInput(overrides: Partial = {}): TerminalRe return { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, - projectId: ProjectId.make("project-1"), cwd: process.cwd(), cols: 100, rows: 24, diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 5ca7c9b6d3b..0bb581e7392 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1,10 +1,8 @@ import { DEFAULT_TERMINAL_ID, - ProjectId, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, - type TerminalAttachInput, type TerminalOpenInput, type TerminalRestartInput, type TerminalSessionSnapshot, @@ -12,17 +10,16 @@ import { type TerminalSummary, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; -import { LaunchEnv } from "../../launchEnv/Services/LaunchEnv.ts"; import { isManagedRuntimeEnvKey } from "../../launchEnv/launchEnvUtils.ts"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { - bindTerminalLaunchEnvResolver, + inTerminalRuntimeContext, + resolveTerminalAttachInput, + resolveTerminalOpenInput, + resolveTerminalRestartInput, type TerminalAttachRuntimeInput, type TerminalLaunchEnvResolver, - type TerminalLaunchEnvResolverServices, } from "../resolveTerminalLaunchEnv.ts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -110,7 +107,6 @@ interface ShellCandidate { interface TerminalStartInput { threadId: string; terminalId: string; - projectId: ProjectId; cwd: string; worktreePath?: string | null; cols: number; @@ -966,13 +962,12 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); - const resolverServices = context as Context.Context; - const launchEnvResolver = - options.launchEnvResolver ?? - bindTerminalLaunchEnvResolver( - Context.get(resolverServices, LaunchEnv), - Context.get(resolverServices, ProjectionSnapshotQuery), - ); + const resolveOpenInput = + options.launchEnvResolver?.resolveOpenInput ?? resolveTerminalOpenInput; + const resolveRestartInput = + options.launchEnvResolver?.resolveRestartInput ?? resolveTerminalRestartInput; + const resolveAttachInput = + options.launchEnvResolver?.resolveAttachInput ?? resolveTerminalAttachInput; const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; @@ -991,9 +986,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; - const resolveOpenInput = launchEnvResolver.resolveOpenInput; - const resolveAttachInput = launchEnvResolver.resolveAttachInput; - const resolveRestartInput = launchEnvResolver.resolveRestartInput; yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); @@ -1959,7 +1951,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith { threadId: input.threadId, terminalId, - projectId: input.projectId, cwd: input.cwd, ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, @@ -2011,7 +2002,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith { threadId: input.threadId, terminalId, - projectId: input.projectId, cwd: input.cwd, worktreePath: liveSession.worktreePath, cols: targetCols, @@ -2034,7 +2024,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const open: TerminalManagerShape["open"] = (input) => - withThreadLock(input.threadId, resolveOpenInput(input).pipe(Effect.flatMap(openLocked))); + inTerminalRuntimeContext( + withThreadLock(input.threadId, resolveOpenInput(input).pipe(Effect.flatMap(openLocked))), + ); const openOrAttachForStream = (input: TerminalAttachRuntimeInput) => withThreadLock( @@ -2117,56 +2109,58 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => { let unsubscribe: (() => void) | null = null; - return Effect.gen(function* () { - const bufferedEvents: TerminalEvent[] = []; - let deliverLive = false; + return inTerminalRuntimeContext( + Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; - unsubscribe = yield* subscribe((event) => { - if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { - return Effect.void; - } + unsubscribe = yield* subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; - } + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } - const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent) : Effect.void; - }); + const attachEvent = terminalEventToAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }); - const resolvedInput = yield* resolveAttachInput(input); - const initialSnapshot = yield* openOrAttachForStream(resolvedInput); + const resolvedInput = yield* resolveAttachInput(input); + const initialSnapshot = yield* openOrAttachForStream(resolvedInput); - yield* listener({ - type: "snapshot", - snapshot: initialSnapshot, - }); + yield* listener({ + type: "snapshot", + snapshot: initialSnapshot, + }); - for (const event of bufferedEvents) { - if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { - continue; - } + for (const event of bufferedEvents) { + if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { + continue; + } - const attachEvent = terminalEventToAttachEvent(event); - if (attachEvent) { - yield* listener(attachEvent); + const attachEvent = terminalEventToAttachEvent(event); + if (attachEvent) { + yield* listener(attachEvent); + } } - } - deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; - }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), + ), ), ), ); @@ -2374,7 +2368,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith { threadId: input.threadId, terminalId, - projectId: input.projectId, cwd: input.cwd, ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, @@ -2387,9 +2380,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const restart: TerminalManagerShape["restart"] = (input) => - withThreadLock( - input.threadId, - resolveRestartInput(input).pipe(Effect.flatMap(restartLocked)), + inTerminalRuntimeContext( + withThreadLock( + input.threadId, + resolveRestartInput(input).pipe(Effect.flatMap(restartLocked)), + ), ); const close: TerminalManagerShape["close"] = (input) => diff --git a/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts new file mode 100644 index 00000000000..ce5dfdc64f3 --- /dev/null +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts @@ -0,0 +1,135 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_TERMINAL_ID, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationProject, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { LaunchEnv } from "../launchEnv/Services/LaunchEnv.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { TerminalSessionLookupError } from "./Services/Manager.ts"; +import { + resolveTerminalOpenInput, + resolveTerminalRestartInput, +} from "./resolveTerminalLaunchEnv.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const NOW = "2026-01-01T00:00:00.000Z"; +const DEFAULT_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +} as const; + +const makeProject = (): OrchestrationProject => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, +}); + +const makeThread = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: DEFAULT_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/repo/worktrees/a", + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const makeTestLayer = (threadOption: Option.Option) => + LaunchEnv.layerTest("/tmp/t3-resolve-terminal-launch-env").pipe( + Layer.provideMerge( + Layer.succeed(ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: (projectId) => + Effect.succeed(projectId === PROJECT_ID ? Option.some(makeProject()) : Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: (threadId) => + Effect.succeed(threadId === THREAD_ID ? threadOption : Option.none()), + getThreadDetailById: () => Effect.die("unused"), + }), + ), + ); + +describe("resolveTerminalLaunchEnv", () => { + it.effect("resolves launch env for open using the thread project id", () => + Effect.gen(function* () { + const result = yield* resolveTerminalOpenInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/repo/worktrees/a", + }).pipe(Effect.provide(makeTestLayer(Option.some(makeThread())))); + + assert.deepStrictEqual(result.env, { + T3CODE_HOME: "/tmp/t3-resolve-terminal-launch-env", + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_PROJECT_ID: "project-1", + T3CODE_THREAD_ID: "thread-1", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }); + assert.strictEqual(result.worktreePath, "/repo/worktrees/a"); + assert.isFalse("projectId" in result); + }), + ); + + it.effect("fails when the thread is not found", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + resolveTerminalOpenInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/repo/worktrees/a", + }).pipe(Effect.provide(makeTestLayer(Option.none()))), + ); + + assert.instanceOf(error, TerminalSessionLookupError); + }), + ); + + it.effect("resolves launch env for restart using the thread project id", () => + Effect.gen(function* () { + const result = yield* resolveTerminalRestartInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/repo/project", + cols: 120, + rows: 40, + }).pipe(Effect.provide(makeTestLayer(Option.some(makeThread({ worktreePath: null }))))); + + assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); + }), + ); +}); diff --git a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts index c8d1b39436b..31fca96b70f 100644 --- a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts @@ -23,10 +23,10 @@ export type TerminalAttachRuntimeInput = TerminalAttachInput & { export interface TerminalLaunchEnvResolver { readonly resolveOpenInput: ( input: TerminalOpenInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly resolveRestartInput: ( input: TerminalRestartInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly resolveAttachInput: ( input: TerminalAttachInput, ) => Effect.Effect; @@ -39,6 +39,36 @@ export interface ResolveTerminalLaunchEnvInput { readonly extraEnv?: Record; } +const resolveThreadForTerminal = Effect.fn("resolveThreadForTerminal")(function* (input: { + readonly threadId: string; + readonly terminalId: string; +}) { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const threadOption = yield* projectionSnapshotQuery + .getThreadShellById(ThreadId.make(input.threadId)) + .pipe( + Effect.mapError( + () => + new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ), + ); + + return yield* Option.match(threadOption, { + onNone: () => + Effect.fail( + new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ), + onSome: Effect.succeed, + }); +}); + export const resolveTerminalLaunchEnv = Effect.fn("resolveTerminalLaunchEnv")(function* ( input: ResolveTerminalLaunchEnvInput, ) { @@ -77,18 +107,53 @@ export const resolveTerminalLaunchEnv = Effect.fn("resolveTerminalLaunchEnv")(fu }); }); +const resolveLaunchEnvForTerminalInput = Effect.fn("resolveLaunchEnvForTerminalInput")( + function* (input: { + readonly threadId: string; + readonly terminalId: string; + readonly worktreePath?: string | null; + readonly env?: Readonly>; + }) { + const thread = yield* resolveThreadForTerminal({ + threadId: input.threadId, + terminalId: input.terminalId, + }); + const worktreePath = + input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath; + + const env = yield* resolveTerminalLaunchEnv({ + projectId: thread.projectId, + threadId: input.threadId, + worktreePath, + ...(input.env !== undefined ? { extraEnv: input.env } : {}), + }); + + return { + worktreePath, + env, + }; + }, +); + +const terminalLaunchEnvInput = ( + input: Pick, +) => ({ + threadId: input.threadId, + terminalId: input.terminalId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), +}); + export const resolveTerminalOpenInput = Effect.fn("resolveTerminalOpenInput")(function* ( input: TerminalOpenInput, ) { - const env = yield* resolveTerminalLaunchEnv({ - projectId: input.projectId, - threadId: input.threadId, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - ...(input.env !== undefined ? { extraEnv: input.env } : {}), - }); + const { worktreePath, env } = yield* resolveLaunchEnvForTerminalInput( + terminalLaunchEnvInput(input), + ); return { ...input, + ...(worktreePath !== undefined ? { worktreePath } : {}), env, }; }); @@ -96,15 +161,13 @@ export const resolveTerminalOpenInput = Effect.fn("resolveTerminalOpenInput")(fu export const resolveTerminalRestartInput = Effect.fn("resolveTerminalRestartInput")(function* ( input: TerminalRestartInput, ) { - const env = yield* resolveTerminalLaunchEnv({ - projectId: input.projectId, - threadId: input.threadId, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - ...(input.env !== undefined ? { extraEnv: input.env } : {}), - }); + const { worktreePath, env } = yield* resolveLaunchEnvForTerminalInput( + terminalLaunchEnvInput(input), + ); return { ...input, + ...(worktreePath !== undefined ? { worktreePath } : {}), env, }; }); @@ -112,31 +175,10 @@ export const resolveTerminalRestartInput = Effect.fn("resolveTerminalRestartInpu export const resolveTerminalAttachInput = Effect.fn("resolveTerminalAttachInput")(function* ( input: TerminalAttachInput, ) { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - - const threadOption = yield* projectionSnapshotQuery - .getThreadShellById(ThreadId.make(input.threadId)) - .pipe( - Effect.mapError( - () => - new TerminalSessionLookupError({ - threadId: input.threadId, - terminalId: input.terminalId, - }), - ), - ); - - const thread = yield* Option.match(threadOption, { - onNone: () => - Effect.fail( - new TerminalSessionLookupError({ - threadId: input.threadId, - terminalId: input.terminalId, - }), - ), - onSome: Effect.succeed, + const thread = yield* resolveThreadForTerminal({ + threadId: input.threadId, + terminalId: input.terminalId, }); - const worktreePath = input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath; const env = yield* resolveTerminalLaunchEnv({ @@ -156,6 +198,13 @@ export const resolveTerminalAttachInput = Effect.fn("resolveTerminalAttachInput" export type TerminalLaunchEnvResolverServices = LaunchEnv | ProjectionSnapshotQuery; +/** Launch env resolution runs in the server runtime, which always provides these services. */ +export const inTerminalRuntimeContext = ( + effect: Effect.Effect, +): Effect.Effect => + // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off + effect as Effect.Effect; + const provideTerminalLaunchEnvResolverServices = ( services: Context.Context, effect: Effect.Effect, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 56a3ec02fef..30305b98ffe 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2352,7 +2352,6 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID, - projectId: PROJECT_ID, cwd: "/repo/project", }); expect(openRequest?.env).toBeUndefined(); @@ -2430,7 +2429,6 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(openRequest).toMatchObject({ _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID, - projectId: PROJECT_ID, cwd: "/repo/worktrees/feature-draft", worktreePath: "/repo/worktrees/feature-draft", }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a549c5744f4..15a17f4214e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -615,7 +615,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra await api.terminal.open({ threadId, terminalId, - projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), }); @@ -647,7 +646,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra await api.terminal.open({ threadId, terminalId, - projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), }); @@ -2008,7 +2006,6 @@ export default function ChatView(props: ChatViewProps) { await api.terminal.open({ threadId: activeThreadId, terminalId, - projectId: activeProject.id, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), }); @@ -2047,7 +2044,6 @@ export default function ChatView(props: ChatViewProps) { await api.terminal.open({ threadId: activeThreadId, terminalId, - projectId: activeProject.id, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), }); @@ -2142,7 +2138,6 @@ export default function ChatView(props: ChatViewProps) { ? { threadId: activeThreadId, terminalId: targetTerminalId, - projectId: activeProject.id, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), ...customRuntimeEnv, @@ -2152,7 +2147,6 @@ export default function ChatView(props: ChatViewProps) { : { threadId: activeThreadId, terminalId: targetTerminalId, - projectId: activeProject.id, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), ...customRuntimeEnv, diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index c2f4951e1a9..a08ed492388 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -1,6 +1,5 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { ProjectId } from "./baseSchemas.ts"; import { DEFAULT_TERMINAL_ID, @@ -15,8 +14,6 @@ import { TerminalWriteInput, } from "./terminal.ts"; -const PROJECT_ID = ProjectId.make("project-1"); - function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; } @@ -36,7 +33,6 @@ describe("TerminalOpenInput", () => { decodes(TerminalOpenInput, { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, - projectId: PROJECT_ID, cwd: "/tmp/project", cols: 120, rows: 40, @@ -49,7 +45,6 @@ describe("TerminalOpenInput", () => { decodes(TerminalOpenInput, { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, - projectId: PROJECT_ID, cwd: "/tmp/project", cols: 423, rows: 40, @@ -62,7 +57,6 @@ describe("TerminalOpenInput", () => { decodes(TerminalOpenInput, { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, - projectId: PROJECT_ID, cwd: "/tmp/project", cols: 10, rows: 0, @@ -74,7 +68,6 @@ describe("TerminalOpenInput", () => { expect( decodes(TerminalOpenInput, { threadId: "thread-1", - projectId: PROJECT_ID, cwd: "/tmp/project", cols: 100, rows: 24, @@ -86,7 +79,6 @@ describe("TerminalOpenInput", () => { const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, - projectId: PROJECT_ID, cwd: "/tmp/project", worktreePath: "/tmp/project/.t3/worktrees/feature-a", cols: 100, @@ -107,7 +99,6 @@ describe("TerminalOpenInput", () => { expect( decodes(TerminalOpenInput, { threadId: "thread-1", - projectId: PROJECT_ID, cwd: "/tmp/project", cols: 100, rows: 24, diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 13a9795473e..bb20e2e9be9 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Client-side id for the first shell opened on a thread. Ids are uniformly @@ -36,13 +36,8 @@ const TerminalSessionInput = Schema.Struct({ }); export type TerminalSessionInput = Schema.Codec.Encoded; -const TerminalLaunchContextInput = Schema.Struct({ - projectId: ProjectId, -}); - export const TerminalOpenInput = Schema.Struct({ ...TerminalSessionInput.fields, - ...TerminalLaunchContextInput.fields, cwd: TrimmedNonEmptyStringSchema, worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: Schema.optional(TerminalColsSchema), @@ -80,7 +75,6 @@ export type TerminalClearInput = Schema.Codec.Encoded export const TerminalRestartInput = Schema.Struct({ ...TerminalSessionInput.fields, - ...TerminalLaunchContextInput.fields, cwd: TrimmedNonEmptyStringSchema, worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: TerminalColsSchema, From 192e37e29e0fe249af0c17b9b0a83832855b0091 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Tue, 9 Jun 2026 20:40:09 +0300 Subject: [PATCH 3/5] Fix terminal open for draft threads missing from server projection. Allow optional client projectId when the thread is not yet persisted, while still resolving project context from the server thread when it exists. Co-authored-by: Cursor --- .../terminal/resolveTerminalLaunchEnv.test.ts | 31 ++++- .../src/terminal/resolveTerminalLaunchEnv.ts | 110 +++++++++--------- apps/web/src/components/ChatView.tsx | 7 ++ .../ThreadTerminalDrawer.browser.tsx | 5 +- .../src/components/ThreadTerminalDrawer.tsx | 10 +- packages/contracts/src/terminal.test.ts | 12 ++ packages/contracts/src/terminal.ts | 10 +- 7 files changed, 128 insertions(+), 57 deletions(-) diff --git a/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts index ce5dfdc64f3..4b9f5c4d0b2 100644 --- a/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts @@ -101,11 +101,38 @@ describe("resolveTerminalLaunchEnv", () => { T3CODE_WORKTREE_PATH: "/repo/worktrees/a", }); assert.strictEqual(result.worktreePath, "/repo/worktrees/a"); - assert.isFalse("projectId" in result); }), ); - it.effect("fails when the thread is not found", () => + it.effect("ignores client projectId when the thread already exists", () => + Effect.gen(function* () { + const spoofedProjectId = ProjectId.make("project-spoofed"); + const result = yield* resolveTerminalOpenInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + projectId: spoofedProjectId, + cwd: "/repo/worktrees/a", + }).pipe(Effect.provide(makeTestLayer(Option.some(makeThread())))); + + assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); + }), + ); + + it.effect("resolves launch env for draft threads using client projectId", () => + Effect.gen(function* () { + const result = yield* resolveTerminalOpenInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + projectId: PROJECT_ID, + cwd: "/repo/project", + }).pipe(Effect.provide(makeTestLayer(Option.none()))); + + assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); + assert.strictEqual(result.env.T3CODE_THREAD_ID, "thread-1"); + }), + ); + + it.effect("fails when the thread is not found and projectId is omitted", () => Effect.gen(function* () { const error = yield* Effect.flip( resolveTerminalOpenInput({ diff --git a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts index 31fca96b70f..79ed1906fdf 100644 --- a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts @@ -39,33 +39,47 @@ export interface ResolveTerminalLaunchEnvInput { readonly extraEnv?: Record; } -const resolveThreadForTerminal = Effect.fn("resolveThreadForTerminal")(function* (input: { +type TerminalProjectContextInput = { readonly threadId: string; readonly terminalId: string; -}) { + readonly projectId?: ProjectId; + readonly worktreePath?: string | null | undefined; +}; + +const terminalSessionLookupError = (input: { + readonly threadId: string; + readonly terminalId: string; +}) => + new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }); + +const resolveProjectContextForTerminal = Effect.fn("resolveProjectContextForTerminal")(function* ( + input: TerminalProjectContextInput, +) { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const threadOption = yield* projectionSnapshotQuery .getThreadShellById(ThreadId.make(input.threadId)) - .pipe( - Effect.mapError( - () => - new TerminalSessionLookupError({ - threadId: input.threadId, - terminalId: input.terminalId, - }), - ), - ); + .pipe(Effect.mapError(() => terminalSessionLookupError(input))); return yield* Option.match(threadOption, { - onNone: () => - Effect.fail( - new TerminalSessionLookupError({ - threadId: input.threadId, - terminalId: input.terminalId, - }), - ), - onSome: Effect.succeed, + onSome: (thread) => + Effect.succeed({ + projectId: thread.projectId, + worktreePath: input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath, + }), + onNone: () => { + if (input.projectId === undefined) { + return Effect.fail(terminalSessionLookupError(input)); + } + + return Effect.succeed({ + projectId: input.projectId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); + }, }); }); @@ -107,39 +121,30 @@ export const resolveTerminalLaunchEnv = Effect.fn("resolveTerminalLaunchEnv")(fu }); }); -const resolveLaunchEnvForTerminalInput = Effect.fn("resolveLaunchEnvForTerminalInput")( - function* (input: { - readonly threadId: string; - readonly terminalId: string; - readonly worktreePath?: string | null; - readonly env?: Readonly>; - }) { - const thread = yield* resolveThreadForTerminal({ - threadId: input.threadId, - terminalId: input.terminalId, - }); - const worktreePath = - input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath; - - const env = yield* resolveTerminalLaunchEnv({ - projectId: thread.projectId, - threadId: input.threadId, - worktreePath, - ...(input.env !== undefined ? { extraEnv: input.env } : {}), - }); - - return { - worktreePath, - env, - }; - }, -); +const resolveLaunchEnvForTerminalInput = Effect.fn("resolveLaunchEnvForTerminalInput")(function* ( + input: TerminalProjectContextInput & { readonly env?: Readonly> }, +) { + const { projectId, worktreePath } = yield* resolveProjectContextForTerminal(input); + + const env = yield* resolveTerminalLaunchEnv({ + projectId, + threadId: input.threadId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(input.env !== undefined ? { extraEnv: input.env } : {}), + }); + + return { + worktreePath, + env, + }; +}); const terminalLaunchEnvInput = ( - input: Pick, + input: Pick, ) => ({ threadId: input.threadId, terminalId: input.terminalId, + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), ...(input.env !== undefined ? { env: input.env } : {}), }); @@ -175,22 +180,23 @@ export const resolveTerminalRestartInput = Effect.fn("resolveTerminalRestartInpu export const resolveTerminalAttachInput = Effect.fn("resolveTerminalAttachInput")(function* ( input: TerminalAttachInput, ) { - const thread = yield* resolveThreadForTerminal({ + const { projectId, worktreePath } = yield* resolveProjectContextForTerminal({ threadId: input.threadId, terminalId: input.terminalId, + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), }); - const worktreePath = input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath; const env = yield* resolveTerminalLaunchEnv({ - projectId: thread.projectId, + projectId, threadId: input.threadId, - worktreePath, + ...(worktreePath !== undefined ? { worktreePath } : {}), ...(input.env !== undefined ? { extraEnv: input.env } : {}), }); return { ...input, - projectId: thread.projectId, + projectId, ...(worktreePath !== undefined ? { worktreePath } : {}), env, } satisfies TerminalAttachRuntimeInput; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 15a17f4214e..8080c94a9f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -615,6 +615,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra await api.terminal.open({ threadId, terminalId, + projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), }); @@ -646,6 +647,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra await api.terminal.open({ threadId, terminalId, + projectId: project.id, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), }); @@ -720,6 +722,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ({ import { TerminalViewport } from "./ThreadTerminalDrawer"; const THREAD_ID = ThreadId.make("thread-terminal-browser"); +const PROJECT_ID = ProjectId.make("project-terminal-browser"); function createEnvironmentApi() { const snapshot = { @@ -192,6 +193,7 @@ async function mountTerminalViewport(props: { { if (!autoFocus) return; @@ -775,6 +779,7 @@ export function TerminalViewport({ interface ThreadTerminalDrawerProps { threadRef: ScopedThreadRef; threadId: ThreadId; + projectId: ProjectId; cwd: string; worktreePath?: string | null; visible?: boolean; @@ -832,6 +837,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA export default function ThreadTerminalDrawer({ threadRef, threadId, + projectId, cwd, worktreePath, visible = true, @@ -1221,6 +1227,7 @@ export default function ThreadTerminalDrawer({ { ).toBe(false); }); + it("accepts optional projectId for draft threads", () => { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + projectId: ProjectId.make("project-1"), + cwd: "/tmp/project", + }), + ).toBe(true); + }); + it("accepts optional env overrides", () => { const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index bb20e2e9be9..1a73d4cc525 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Client-side id for the first shell opened on a thread. Ids are uniformly @@ -36,8 +36,14 @@ const TerminalSessionInput = Schema.Struct({ }); export type TerminalSessionInput = Schema.Codec.Encoded; +/** Required when the thread is not yet in the server projection (e.g. draft threads). */ +const TerminalDraftProjectInput = Schema.Struct({ + projectId: Schema.optional(ProjectId), +}); + export const TerminalOpenInput = Schema.Struct({ ...TerminalSessionInput.fields, + ...TerminalDraftProjectInput.fields, cwd: TrimmedNonEmptyStringSchema, worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: Schema.optional(TerminalColsSchema), @@ -48,6 +54,7 @@ export type TerminalOpenInput = typeof TerminalOpenInput.Type; export const TerminalAttachInput = Schema.Struct({ ...TerminalSessionInput.fields, + ...TerminalDraftProjectInput.fields, cwd: Schema.optional(TrimmedNonEmptyStringSchema), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: Schema.optional(TerminalColsSchema), @@ -75,6 +82,7 @@ export type TerminalClearInput = Schema.Codec.Encoded export const TerminalRestartInput = Schema.Struct({ ...TerminalSessionInput.fields, + ...TerminalDraftProjectInput.fields, cwd: TrimmedNonEmptyStringSchema, worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: TerminalColsSchema, From 44f48a3c36cce7dd8b55337fa540ede32b4959cf Mon Sep 17 00:00:00 2001 From: tarik02 Date: Wed, 10 Jun 2026 00:22:08 +0300 Subject: [PATCH 4/5] Fix provider launch env leakage and tighten terminal env binding. Bind the terminal launch-env resolver at manager construction without unsafe casts, strip managed T3CODE_* keys in ACP spawns, merge per-session env for Grok, and pass projectId from setup scripts. Co-authored-by: Cursor --- .../Layers/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProjectSetupScriptRunner.ts | 1 + .../server/src/provider/Layers/GrokAdapter.ts | 3 +- .../src/provider/acp/AcpSessionRuntime.ts | 10 +- apps/server/src/server.ts | 12 +- apps/server/src/terminal/Layers/Manager.ts | 118 +++++++++--------- .../src/terminal/resolveTerminalLaunchEnv.ts | 10 +- 7 files changed, 82 insertions(+), 73 deletions(-) diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 39246c340e9..6b54a7d9961 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -155,6 +155,7 @@ describe("ProjectSetupScriptRunner", () => { expect(open).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup", + projectId: ProjectId.make("project-1"), cwd: "/repo/worktrees/a", worktreePath: "/repo/worktrees/a", }); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index e24524ecb24..0d57a8f261d 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -49,6 +49,7 @@ const makeProjectSetupScriptRunner = Effect.gen(function* () { yield* terminalManager.open({ threadId: input.threadId, terminalId, + projectId: project.id, cwd, worktreePath: input.worktreePath, }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index ed4097ecda3..86a9308e7cd 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -63,6 +63,7 @@ import { makeXAiAskUserQuestionResponse, XAiAskUserQuestionRequest, } from "../acp/XAiAcpExtension.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -372,7 +373,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const acp = yield* makeGrokAcpRuntime({ grokSettings, - ...(options?.environment ? { environment: options.environment } : {}), + environment: mergeProviderSessionEnvironment(options?.environment, input.env), childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 4ed64890fc3..0cf5d3429d0 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -14,6 +14,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; +import { stripManagedRuntimeEnvKeys } from "../../launchEnv/launchEnvUtils.ts"; import { collectSessionConfigOptionValues, extractModelConfigId, @@ -204,7 +205,14 @@ const makeAcpSessionRuntime = ( .spawn( ChildProcess.make(options.spawn.command, [...options.spawn.args], { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}), + ...(options.spawn.env + ? { + env: { + ...stripManagedRuntimeEnvKeys(process.env), + ...options.spawn.env, + }, + } + : {}), shell: process.platform === "win32", }), ) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d65f5539e02..b61f5687239 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -34,6 +34,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; @@ -224,7 +225,16 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const TerminalLayerLive = TerminalManagerLive.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(LaunchEnvLive), + Layer.provide( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(SqlitePersistenceLayerLive), + Layer.provide(RepositoryIdentityResolverLive), + ), + ), +); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 0bb581e7392..31ad59fb22b 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -12,13 +12,12 @@ import { import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { isManagedRuntimeEnvKey } from "../../launchEnv/launchEnvUtils.ts"; import { - inTerminalRuntimeContext, - resolveTerminalAttachInput, - resolveTerminalOpenInput, - resolveTerminalRestartInput, + bindTerminalLaunchEnvResolver, type TerminalAttachRuntimeInput, type TerminalLaunchEnvResolver, } from "../resolveTerminalLaunchEnv.ts"; +import { LaunchEnv } from "../../launchEnv/Services/LaunchEnv.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -943,7 +942,7 @@ interface TerminalManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; - launchEnvResolver?: TerminalLaunchEnvResolver; + launchEnvResolver: TerminalLaunchEnvResolver; } const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { @@ -953,6 +952,10 @@ const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, + launchEnvResolver: bindTerminalLaunchEnvResolver( + yield* LaunchEnv, + yield* ProjectionSnapshotQuery, + ), }); }); @@ -962,12 +965,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); - const resolveOpenInput = - options.launchEnvResolver?.resolveOpenInput ?? resolveTerminalOpenInput; - const resolveRestartInput = - options.launchEnvResolver?.resolveRestartInput ?? resolveTerminalRestartInput; - const resolveAttachInput = - options.launchEnvResolver?.resolveAttachInput ?? resolveTerminalAttachInput; + const resolveOpenInput = options.launchEnvResolver.resolveOpenInput; + const resolveRestartInput = options.launchEnvResolver.resolveRestartInput; + const resolveAttachInput = options.launchEnvResolver.resolveAttachInput; const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; @@ -2024,9 +2024,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const open: TerminalManagerShape["open"] = (input) => - inTerminalRuntimeContext( - withThreadLock(input.threadId, resolveOpenInput(input).pipe(Effect.flatMap(openLocked))), - ); + withThreadLock(input.threadId, resolveOpenInput(input).pipe(Effect.flatMap(openLocked))); const openOrAttachForStream = (input: TerminalAttachRuntimeInput) => withThreadLock( @@ -2109,58 +2107,56 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => { let unsubscribe: (() => void) | null = null; - return inTerminalRuntimeContext( - Effect.gen(function* () { - const bufferedEvents: TerminalEvent[] = []; - let deliverLive = false; + return Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; - unsubscribe = yield* subscribe((event) => { - if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { - return Effect.void; - } + unsubscribe = yield* subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; - } + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } - const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent) : Effect.void; - }); + const attachEvent = terminalEventToAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }); - const resolvedInput = yield* resolveAttachInput(input); - const initialSnapshot = yield* openOrAttachForStream(resolvedInput); + const resolvedInput = yield* resolveAttachInput(input); + const initialSnapshot = yield* openOrAttachForStream(resolvedInput); - yield* listener({ - type: "snapshot", - snapshot: initialSnapshot, - }); + yield* listener({ + type: "snapshot", + snapshot: initialSnapshot, + }); - for (const event of bufferedEvents) { - if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { - continue; - } + for (const event of bufferedEvents) { + if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { + continue; + } - const attachEvent = terminalEventToAttachEvent(event); - if (attachEvent) { - yield* listener(attachEvent); - } + const attachEvent = terminalEventToAttachEvent(event); + if (attachEvent) { + yield* listener(attachEvent); } + } - deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; - }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), - ), + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), ), ), ); @@ -2380,11 +2376,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const restart: TerminalManagerShape["restart"] = (input) => - inTerminalRuntimeContext( - withThreadLock( - input.threadId, - resolveRestartInput(input).pipe(Effect.flatMap(restartLocked)), - ), + withThreadLock( + input.threadId, + resolveRestartInput(input).pipe(Effect.flatMap(restartLocked)), ); const close: TerminalManagerShape["close"] = (input) => diff --git a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts index 79ed1906fdf..a9866e95aa9 100644 --- a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts @@ -202,14 +202,7 @@ export const resolveTerminalAttachInput = Effect.fn("resolveTerminalAttachInput" } satisfies TerminalAttachRuntimeInput; }); -export type TerminalLaunchEnvResolverServices = LaunchEnv | ProjectionSnapshotQuery; - -/** Launch env resolution runs in the server runtime, which always provides these services. */ -export const inTerminalRuntimeContext = ( - effect: Effect.Effect, -): Effect.Effect => - // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off - effect as Effect.Effect; +type TerminalLaunchEnvResolverServices = LaunchEnv | ProjectionSnapshotQuery; const provideTerminalLaunchEnvResolverServices = ( services: Context.Context, @@ -243,3 +236,4 @@ export const terminalLaunchEnvResolverTest = (projectId: ProjectId): TerminalLau projectId, }), }); + From a089db872e7d8e61d16e5a424c9cd99bbb87588b Mon Sep 17 00:00:00 2001 From: tarik02 Date: Wed, 10 Jun 2026 01:19:08 +0300 Subject: [PATCH 5/5] next part of wip, more coming... --- .../OrchestrationEngineHarness.integration.ts | 8 +- apps/server/src/bin.test.ts | 4 +- .../src/launchEnv/Layers/LaunchEnvLive.ts | 15 ++ .../src/launchEnv/Layers/LaunchEnvTest.ts | 74 +++++++++ .../src/launchEnv/Services/LaunchEnv.test.ts | 141 ++++++++++++++++++ .../src/launchEnv/Services/LaunchEnv.ts | 123 ++++++++++++++- .../Layers/ProviderCommandReactor.test.ts | 6 +- .../Layers/ProjectSetupScriptRunner.test.ts | 20 +-- .../provider/ProviderInstanceEnvironment.ts | 3 +- .../src/provider/acp/AcpSessionRuntime.ts | 11 +- apps/server/src/server.test.ts | 2 - apps/server/src/server.ts | 53 +++---- .../src/terminal/Layers/Manager.test.ts | 7 +- apps/server/src/terminal/Layers/Manager.ts | 77 +++++++--- .../Layers/TerminalLaunchEnvResolverTest.ts | 59 ++++++++ .../terminal/resolveTerminalLaunchEnv.test.ts | 70 ++++----- .../src/terminal/resolveTerminalLaunchEnv.ts | 52 +++---- 17 files changed, 561 insertions(+), 164 deletions(-) create mode 100644 apps/server/src/launchEnv/Layers/LaunchEnvLive.ts create mode 100644 apps/server/src/launchEnv/Layers/LaunchEnvTest.ts create mode 100644 apps/server/src/launchEnv/Services/LaunchEnv.test.ts create mode 100644 apps/server/src/terminal/Layers/TerminalLaunchEnvResolverTest.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d126323f9d2..1811be706bb 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -376,6 +376,7 @@ export const makeOrchestrationIntegrationHarness = ( ), ); const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir); + const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), @@ -384,7 +385,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(serverConfigLayer), - Layer.provideMerge(LaunchEnvLive.pipe(Layer.provide(serverConfigLayer))), + Layer.provideMerge( + LaunchEnvLive.pipe( + Layer.provide(serverConfigLayer), + Layer.provide(runtimeServicesLayer), + ), + ), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index aed20782328..cc6c333450b 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -19,7 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; -import { LaunchEnv } from "./launchEnv/Services/LaunchEnv.ts"; +import { defaultLaunchEnvTestLayer } from "./launchEnv/Layers/LaunchEnvTest.ts"; import { cli, makeCli } from "./bin.ts"; import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -39,7 +39,7 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; const CliRuntimeLayer = Layer.mergeAll( NodeServices.layer, NetService.layer, - LaunchEnv.layerTest("/tmp/t3-cli-test"), + defaultLaunchEnvTestLayer, ); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} diff --git a/apps/server/src/launchEnv/Layers/LaunchEnvLive.ts b/apps/server/src/launchEnv/Layers/LaunchEnvLive.ts new file mode 100644 index 00000000000..ac06ae77065 --- /dev/null +++ b/apps/server/src/launchEnv/Layers/LaunchEnvLive.ts @@ -0,0 +1,15 @@ +import * as Layer from "effect/Layer"; + +import { OrchestrationProjectionSnapshotQueryLive } from "../../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; +import { LaunchEnvLive } from "../Services/LaunchEnv.ts"; + +export const makeLaunchEnvLayerLive = (persistenceLayer: Layer.Layer) => + LaunchEnvLive.pipe( + Layer.provide( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(persistenceLayer), + Layer.provide(RepositoryIdentityResolverLive), + ), + ), + ); diff --git a/apps/server/src/launchEnv/Layers/LaunchEnvTest.ts b/apps/server/src/launchEnv/Layers/LaunchEnvTest.ts new file mode 100644 index 00000000000..6846963b941 --- /dev/null +++ b/apps/server/src/launchEnv/Layers/LaunchEnvTest.ts @@ -0,0 +1,74 @@ +import { + ProjectId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { + LaunchEnv, + makeResolveForThread, + makeResolveLaunchEnv, + type LaunchEnvShape, + type ResolvedLaunchEnvForThread, +} from "../Services/LaunchEnv.ts"; + +export type LaunchEnvTestFixtures = { + readonly t3Home: string; + readonly projects?: ReadonlyArray; + readonly threads?: ReadonlyArray; +}; + +const toProjectMap = (projects: ReadonlyArray | undefined) => + new Map((projects ?? []).map((project) => [project.id, project] as const)); + +const toThreadMap = (threads: ReadonlyArray | undefined) => + new Map((threads ?? []).map((thread) => [thread.id, thread] as const)); + +export const makeLaunchEnvTestShape = (fixtures: LaunchEnvTestFixtures): LaunchEnvShape => { + const resolve = makeResolveLaunchEnv(fixtures.t3Home); + const projectsById = toProjectMap(fixtures.projects); + const threadsById = toThreadMap(fixtures.threads); + + return { + resolve, + resolveForThread: makeResolveForThread(resolve, { + getThreadShellById: (threadId) => + Effect.succeed(Option.fromNullishOr(threadsById.get(threadId))), + getProjectShellById: (projectId) => + Effect.succeed(Option.fromNullishOr(projectsById.get(projectId))), + }), + }; +}; + +export const launchEnvTestStub = (input: { + readonly t3Home: string; + readonly projectId: ProjectId; +}): LaunchEnvShape => ({ + resolve: makeResolveLaunchEnv(input.t3Home), + resolveForThread: (resolveInput) => + Effect.succeed({ + projectId: input.projectId, + ...(resolveInput.worktreePath !== undefined + ? { worktreePath: resolveInput.worktreePath } + : {}), + env: (resolveInput.extraEnv ?? {}) as Record, + } satisfies ResolvedLaunchEnvForThread), +}); + +export const LaunchEnvTestLayer = { + stub: (input: { readonly t3Home: string; readonly projectId: ProjectId }) => + Layer.succeed(LaunchEnv, launchEnvTestStub(input)), + + withFixtures: (fixtures: LaunchEnvTestFixtures) => + Layer.succeed(LaunchEnv, makeLaunchEnvTestShape(fixtures)), +}; + +/** Default CLI/unit-test layer: resolve-only stub with a fixed project id. */ +export const defaultLaunchEnvTestLayer = LaunchEnvTestLayer.stub({ + t3Home: "/tmp/t3-launch-env-test", + projectId: ProjectId.make("project-1"), +}); diff --git a/apps/server/src/launchEnv/Services/LaunchEnv.test.ts b/apps/server/src/launchEnv/Services/LaunchEnv.test.ts new file mode 100644 index 00000000000..f74154a1336 --- /dev/null +++ b/apps/server/src/launchEnv/Services/LaunchEnv.test.ts @@ -0,0 +1,141 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + DEFAULT_TERMINAL_ID, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { TerminalSessionLookupError } from "../../terminal/Services/Manager.ts"; +import { LaunchEnv } from "../Services/LaunchEnv.ts"; +import { LaunchEnvTestLayer } from "../Layers/LaunchEnvTest.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const T3_HOME = "/tmp/t3-launch-env"; +const NOW = "2026-01-01T00:00:00.000Z"; +const DEFAULT_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +} as const; + +const makeProject = (): OrchestrationProjectShell => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, +}); + +const makeThread = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: DEFAULT_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/repo/worktrees/a", + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const makeTestLayer = (threads: ReadonlyArray) => + LaunchEnvTestLayer.withFixtures({ + t3Home: T3_HOME, + projects: [makeProject()], + threads, + }); + +describe("LaunchEnv.resolveForThread", () => { + it.effect("resolves launch env using the thread project id", () => + Effect.gen(function* () { + const launchEnv = yield* LaunchEnv; + const result = yield* launchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + }); + + assert.deepStrictEqual(result.env, { + T3CODE_HOME: T3_HOME, + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_PROJECT_ID: "project-1", + T3CODE_THREAD_ID: "thread-1", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }); + assert.strictEqual(result.worktreePath, "/repo/worktrees/a"); + }).pipe(Effect.provide(makeTestLayer([makeThread()]))), + ); + + it.effect("ignores client projectId when the thread already exists", () => + Effect.gen(function* () { + const launchEnv = yield* LaunchEnv; + const spoofedProjectId = ProjectId.make("project-spoofed"); + const result = yield* launchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + projectId: spoofedProjectId, + }); + + assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); + assert.strictEqual(result.projectId, PROJECT_ID); + }).pipe(Effect.provide(makeTestLayer([makeThread()]))), + ); + + it.effect("resolves launch env for draft threads using client projectId", () => + Effect.gen(function* () { + const launchEnv = yield* LaunchEnv; + const result = yield* launchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + projectId: PROJECT_ID, + }); + + assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); + assert.strictEqual(result.env.T3CODE_THREAD_ID, "thread-1"); + }).pipe(Effect.provide(makeTestLayer([]))), + ); + + it.effect("fails when the thread is not found and projectId is omitted", () => + Effect.gen(function* () { + const launchEnv = yield* LaunchEnv; + const error = yield* Effect.flip( + launchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + }), + ); + + assert.instanceOf(error, TerminalSessionLookupError); + }).pipe(Effect.provide(makeTestLayer([]))), + ); + + it.effect("prefers explicit worktreePath over the thread default", () => + Effect.gen(function* () { + const launchEnv = yield* LaunchEnv; + const result = yield* launchEnv.resolveForThread({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + worktreePath: "/repo/worktrees/b", + }); + + assert.strictEqual(result.worktreePath, "/repo/worktrees/b"); + assert.strictEqual(result.env.T3CODE_WORKTREE_PATH, "/repo/worktrees/b"); + }).pipe(Effect.provide(makeTestLayer([makeThread()]))), + ); +}); diff --git a/apps/server/src/launchEnv/Services/LaunchEnv.ts b/apps/server/src/launchEnv/Services/LaunchEnv.ts index 41fe6f43c80..e328314551e 100644 --- a/apps/server/src/launchEnv/Services/LaunchEnv.ts +++ b/apps/server/src/launchEnv/Services/LaunchEnv.ts @@ -1,9 +1,19 @@ -import type { ProjectId } from "@t3tools/contracts"; +import { + ProjectId, + TerminalCwdError, + TerminalSessionLookupError, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; 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 { ServerConfig } from "../../config.ts"; +import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import type { EnvRecord } from "../launchEnvUtils.ts"; import { mergeResolvedLaunchEnv } from "../launchEnvUtils.ts"; @@ -15,8 +25,34 @@ export interface ResolveLaunchEnvInput { readonly extraEnv?: EnvRecord; } +export interface ResolveLaunchEnvForThreadInput { + readonly threadId: string; + readonly terminalId?: string | undefined; + readonly projectId?: ProjectId | undefined; + readonly worktreePath?: string | null | undefined; + readonly extraEnv?: EnvRecord; +} + +export type ResolvedLaunchEnvForThread = { + readonly projectId: ProjectId; + readonly worktreePath?: string | null; + readonly env: Record; +}; + export interface LaunchEnvShape { readonly resolve: (input: ResolveLaunchEnvInput) => Effect.Effect>; + readonly resolveForThread: ( + input: ResolveLaunchEnvForThreadInput, + ) => Effect.Effect; +} + +export interface LaunchEnvProjectionShape { + readonly getThreadShellById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly getProjectShellById: ( + projectId: ProjectId, + ) => Effect.Effect, ProjectionRepositoryError>; } export const makeResolveLaunchEnv = (t3Home: string): LaunchEnvShape["resolve"] => @@ -33,20 +69,91 @@ export const makeResolveLaunchEnv = (t3Home: string): LaunchEnvShape["resolve"] }); }); +export const makeResolveForThread = ( + resolve: LaunchEnvShape["resolve"], + projection: LaunchEnvProjectionShape, +): LaunchEnvShape["resolveForThread"] => + Effect.fn("LaunchEnv.resolveForThread")(function* (input) { + const sessionLookupError = () => + new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId: input.terminalId ?? "", + }); + + const threadOption = yield* projection + .getThreadShellById(ThreadId.make(input.threadId)) + .pipe(Effect.mapError(() => sessionLookupError())); + + const { projectId, worktreePath } = yield* Option.match(threadOption, { + onSome: (thread) => + Effect.succeed({ + projectId: thread.projectId, + worktreePath: input.worktreePath !== undefined ? input.worktreePath : thread.worktreePath, + }), + onNone: () => { + if (input.projectId === undefined) { + return Effect.fail(sessionLookupError()); + } + + return Effect.succeed({ + projectId: input.projectId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); + }, + }); + + const projectOption = yield* projection.getProjectShellById(projectId).pipe( + Effect.mapError( + (cause) => + new TerminalCwdError({ + cwd: projectId, + reason: "statFailed", + cause, + }), + ), + ); + + const project = yield* Option.match(projectOption, { + onNone: () => + Effect.fail( + new TerminalCwdError({ + cwd: projectId, + reason: "notFound", + }), + ), + onSome: Effect.succeed, + }); + + const env: Record = yield* resolve({ + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + projectRoot: project.workspaceRoot, + projectId: project.id, + threadId: input.threadId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + }); + + return { + projectId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + env, + } satisfies ResolvedLaunchEnvForThread; + }); + export class LaunchEnv extends Context.Service()( "t3/launchEnv/Services/LaunchEnv", -) { - static readonly layerTest = (t3Home: string) => - Layer.succeed(LaunchEnv, { - resolve: makeResolveLaunchEnv(t3Home), - } satisfies LaunchEnvShape); -} +) {} export const makeLaunchEnv = Effect.fn("makeLaunchEnv")(function* () { const serverConfig = yield* ServerConfig; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const resolve = makeResolveLaunchEnv(serverConfig.baseDir); return { - resolve: makeResolveLaunchEnv(serverConfig.baseDir), + resolve, + resolveForThread: makeResolveForThread(resolve, { + getThreadShellById: (threadId) => projectionSnapshotQuery.getThreadShellById(threadId), + getProjectShellById: (projectId) => projectionSnapshotQuery.getProjectShellById(projectId), + }), } satisfies LaunchEnvShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b9103964bc8..c7720c0ad8e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -57,7 +57,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; -import { LaunchEnvLive } from "../../launchEnv/Services/LaunchEnv.ts"; +import { makeLaunchEnvLayerLive } from "../../launchEnv/Layers/LaunchEnvLive.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; @@ -347,7 +347,9 @@ describe("ProviderCommandReactor", () => { Layer.provide(NodeServices.layer), ); const layer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(LaunchEnvLive.pipe(Layer.provide(serverConfigLayer))), + Layer.provideMerge( + makeLaunchEnvLayerLive(SqlitePersistenceMemory).pipe(Layer.provide(serverConfigLayer)), + ), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 6b54a7d9961..747ac98daeb 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { describe, expect, it, vi } from "vite-plus/test"; -import { LaunchEnv } from "../../launchEnv/Services/LaunchEnv.ts"; +import { LaunchEnvTestLayer } from "../../launchEnv/Layers/LaunchEnvTest.ts"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { TerminalManager } from "../../terminal/Services/Manager.ts"; import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; @@ -22,27 +22,19 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro }); const TEST_BASE_DIR = "/tmp/t3-setup-script-runner"; -const launchEnvLayer = LaunchEnv.layerTest(TEST_BASE_DIR); +const launchEnvLayer = LaunchEnvTestLayer.stub({ + t3Home: TEST_BASE_DIR, + projectId: ProjectId.make("project-1"), +}); const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), - getCounts: () => Effect.die("unused"), + Layer.mock(ProjectionSnapshotQuery)({ getActiveProjectByWorkspaceRoot: (workspaceRoot) => Effect.succeed( workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), ), getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.die("unused"), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.die("unused"), - getThreadDetailById: () => Effect.die("unused"), }); describe("ProjectSetupScriptRunner", () => { diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index 424c80390d7..7df95be13ce 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -20,11 +20,12 @@ export function mergeProviderInstanceEnvironment( export function mergeProviderSessionEnvironment( baseEnv: NodeJS.ProcessEnv | undefined, - sessionEnv: Readonly> | undefined, + sessionEnv: NodeJS.ProcessEnv | Readonly> | undefined, ): NodeJS.ProcessEnv { const next = stripManagedRuntimeEnvKeys(baseEnv ?? process.env); if (!sessionEnv) return next; for (const [key, value] of Object.entries(sessionEnv)) { + if (value === undefined) continue; next[key] = value; } return next; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 0cf5d3429d0..8b2534c1b81 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -14,7 +14,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import { stripManagedRuntimeEnvKeys } from "../../launchEnv/launchEnvUtils.ts"; +import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { collectSessionConfigOptionValues, extractModelConfigId, @@ -205,13 +205,8 @@ const makeAcpSessionRuntime = ( .spawn( ChildProcess.make(options.spawn.command, [...options.spawn.args], { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env - ? { - env: { - ...stripManagedRuntimeEnvKeys(process.env), - ...options.spawn.env, - }, - } + ...(options.spawn.env !== undefined + ? { env: mergeProviderSessionEnvironment(process.env, options.spawn.env) } : {}), shell: process.platform === "win32", }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 339af640a0a..f0398f3f2fb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -68,7 +68,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; -import { LaunchEnvLive } from "./launchEnv/Services/LaunchEnv.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { @@ -794,7 +793,6 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(LaunchEnvLive), Layer.provide(layerConfig), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b61f5687239..36a7726da75 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -34,7 +34,6 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; @@ -67,7 +66,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; -import { LaunchEnvLive } from "./launchEnv/Services/LaunchEnv.ts"; +import { makeLaunchEnvLayerLive } from "./launchEnv/Layers/LaunchEnvLive.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -144,14 +143,25 @@ const PlatformServicesLive = Layer.unwrap( }), ); -const ReactorLayerLive = Layer.empty.pipe( - Layer.provideMerge(OrchestrationReactorLive), - Layer.provideMerge(ProviderRuntimeIngestionLive), - Layer.provideMerge(ProviderCommandReactorLive), - Layer.provideMerge(CheckpointReactorLive), - Layer.provideMerge(ThreadDeletionReactorLive), - Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), - Layer.provideMerge(RuntimeReceiptBusLive), +const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); + +const LaunchEnvLayerLive = makeLaunchEnvLayerLive(SqlitePersistenceLayerLive); + +const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); + +const ReactorLayerLive = LaunchEnvLayerLive.pipe( + Layer.provideMerge( + Layer.empty.pipe( + Layer.provideMerge(OrchestrationReactorLive), + Layer.provideMerge(ProviderRuntimeIngestionLive), + Layer.provideMerge(ProviderCommandReactorLive), + Layer.provideMerge(CheckpointReactorLive), + Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provideMerge(RuntimeReceiptBusLive), + ), + ), + Layer.provideMerge(TerminalLayerLive), ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( @@ -169,8 +179,6 @@ const ProviderLayerLive = ProviderServiceLive.pipe( Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); -const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); - const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -225,17 +233,6 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe( - Layer.provide(PtyAdapterLive), - Layer.provide(LaunchEnvLive), - Layer.provide( - OrchestrationProjectionSnapshotQueryLive.pipe( - Layer.provide(SqlitePersistenceLayerLive), - Layer.provide(RepositoryIdentityResolverLive), - ), - ), -); - const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistryLayerLive), @@ -271,14 +268,12 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( ); const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( - Layer.provideMerge(Layer.mergeAll(LaunchEnvLive, ServerEnvironmentLive)), // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), @@ -304,6 +299,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -463,12 +459,7 @@ export const makeServerLayer = Layer.unwrap( const serverConfigLayer = Layer.succeed(ServerConfig, config); return serverApplicationLayer.pipe( - Layer.provideMerge( - RuntimeServicesLive.pipe( - Layer.provideMerge(serverConfigLayer), - Layer.provideMerge(LaunchEnvLive.pipe(Layer.provide(serverConfigLayer))), - ), - ), + Layer.provideMerge(RuntimeServicesLive.pipe(Layer.provideMerge(serverConfigLayer))), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 690a156fe56..96038c8d175 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -36,7 +36,7 @@ import { PtySpawnError, } from "../Services/PTY.ts"; import { makeTerminalManagerWithOptions } from "./Manager.ts"; -import { terminalLaunchEnvResolverTest } from "../resolveTerminalLaunchEnv.ts"; +import { launchEnvTestStub } from "../../launchEnv/Layers/LaunchEnvTest.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ readonly message: string; @@ -240,7 +240,10 @@ const createManager = ( logsDir, historyLineLimit, ptyAdapter, - launchEnvResolver: terminalLaunchEnvResolverTest(ProjectId.make("project-1")), + launchEnv: launchEnvTestStub({ + t3Home: baseDir, + projectId: ProjectId.make("project-1"), + }), ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 31ad59fb22b..97afd83e8d9 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1,5 +1,7 @@ import { DEFAULT_TERMINAL_ID, + ProjectId, + type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, @@ -11,13 +13,7 @@ import { } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { isManagedRuntimeEnvKey } from "../../launchEnv/launchEnvUtils.ts"; -import { - bindTerminalLaunchEnvResolver, - type TerminalAttachRuntimeInput, - type TerminalLaunchEnvResolver, -} from "../resolveTerminalLaunchEnv.ts"; -import { LaunchEnv } from "../../launchEnv/Services/LaunchEnv.ts"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { LaunchEnv, type LaunchEnvShape } from "../../launchEnv/Services/LaunchEnv.ts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -931,6 +927,10 @@ function normalizedRuntimeEnv( return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); } +type TerminalAttachRuntimeInput = TerminalAttachInput & { + readonly projectId: ProjectId; +}; + interface TerminalManagerOptions { logsDir: string; historyLineLimit?: number; @@ -942,20 +942,18 @@ interface TerminalManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; - launchEnvResolver: TerminalLaunchEnvResolver; + launchEnv: LaunchEnvShape; } const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; + const launchEnv = yield* LaunchEnv; return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, - launchEnvResolver: bindTerminalLaunchEnvResolver( - yield* LaunchEnv, - yield* ProjectionSnapshotQuery, - ), + launchEnv, }); }); @@ -965,9 +963,49 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); - const resolveOpenInput = options.launchEnvResolver.resolveOpenInput; - const resolveRestartInput = options.launchEnvResolver.resolveRestartInput; - const resolveAttachInput = options.launchEnvResolver.resolveAttachInput; + const launchEnv = options.launchEnv; + + const toLaunchEnvInput = ( + input: Pick< + TerminalOpenInput | TerminalRestartInput | TerminalAttachInput, + "threadId" | "terminalId" | "projectId" | "worktreePath" | "env" + >, + ) => ({ + threadId: input.threadId, + terminalId: input.terminalId, + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.env !== undefined ? { extraEnv: input.env } : {}), + }); + + const applyLaunchEnv = (input: T) => + launchEnv.resolveForThread(toLaunchEnvInput(input)).pipe( + Effect.map((resolved) => ({ + ...input, + ...(resolved.worktreePath !== undefined ? { worktreePath: resolved.worktreePath } : {}), + env: resolved.env, + })), + ); + + const applyLaunchEnvForAttach = (input: TerminalAttachInput) => + launchEnv.resolveForThread(toLaunchEnvInput(input)).pipe( + Effect.map( + (resolved) => + ({ + ...input, + projectId: resolved.projectId, + ...(resolved.worktreePath !== undefined + ? { worktreePath: resolved.worktreePath } + : {}), + env: resolved.env, + }) satisfies TerminalAttachRuntimeInput, + ), + ); + + const runWithLaunchEnv = ( + input: T, + run: (resolved: T & { env: Record }) => Effect.Effect, + ) => Effect.flatMap(applyLaunchEnv(input), run); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; @@ -2024,7 +2062,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const open: TerminalManagerShape["open"] = (input) => - withThreadLock(input.threadId, resolveOpenInput(input).pipe(Effect.flatMap(openLocked))); + withThreadLock(input.threadId, runWithLaunchEnv(input, openLocked)); const openOrAttachForStream = (input: TerminalAttachRuntimeInput) => withThreadLock( @@ -2125,7 +2163,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith return attachEvent ? listener(attachEvent) : Effect.void; }); - const resolvedInput = yield* resolveAttachInput(input); + const resolvedInput = yield* applyLaunchEnvForAttach(input); const initialSnapshot = yield* openOrAttachForStream(resolvedInput); yield* listener({ @@ -2376,10 +2414,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const restart: TerminalManagerShape["restart"] = (input) => - withThreadLock( - input.threadId, - resolveRestartInput(input).pipe(Effect.flatMap(restartLocked)), - ); + withThreadLock(input.threadId, runWithLaunchEnv(input, restartLocked)); const close: TerminalManagerShape["close"] = (input) => withThreadLock( diff --git a/apps/server/src/terminal/Layers/TerminalLaunchEnvResolverTest.ts b/apps/server/src/terminal/Layers/TerminalLaunchEnvResolverTest.ts new file mode 100644 index 00000000000..70c3b7381cf --- /dev/null +++ b/apps/server/src/terminal/Layers/TerminalLaunchEnvResolverTest.ts @@ -0,0 +1,59 @@ +import { + ProjectId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { makeResolveLaunchEnv } from "../../launchEnv/Services/LaunchEnv.ts"; +import { + bindTerminalLaunchEnvResolver, + type TerminalLaunchEnvProjectionShape, + type TerminalLaunchEnvResolver, +} from "../resolveTerminalLaunchEnv.ts"; + +export type TerminalLaunchEnvResolverTestFixtures = { + readonly t3Home: string; + readonly projects?: ReadonlyArray; + readonly threads?: ReadonlyArray; +}; + +export const makeTerminalLaunchEnvProjection = ( + fixtures: Pick, +): TerminalLaunchEnvProjectionShape => { + const projectsById = new Map( + (fixtures.projects ?? []).map((project) => [project.id, project] as const), + ); + const threadsById = new Map( + (fixtures.threads ?? []).map((thread) => [thread.id, thread] as const), + ); + + return { + getProjectShellById: (projectId) => + Effect.succeed(Option.fromNullishOr(projectsById.get(projectId))), + getThreadShellById: (threadId) => + Effect.succeed(Option.fromNullishOr(threadsById.get(threadId))), + }; +}; + +export const bindTerminalLaunchEnvResolverForTest = ( + fixtures: TerminalLaunchEnvResolverTestFixtures, +): TerminalLaunchEnvResolver => + bindTerminalLaunchEnvResolver( + { resolve: makeResolveLaunchEnv(fixtures.t3Home) }, + makeTerminalLaunchEnvProjection(fixtures), + ); + +/** Passthrough resolver for Manager tests that supply env on the input directly. */ +export const terminalLaunchEnvResolverTestStub = ( + projectId: ProjectId, +): TerminalLaunchEnvResolver => ({ + resolveOpenInput: (input) => Effect.succeed(input), + resolveRestartInput: (input) => Effect.succeed(input), + resolveAttachInput: (input) => + Effect.succeed({ + ...input, + projectId, + }), +}); diff --git a/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts index 4b9f5c4d0b2..8624e1159ed 100644 --- a/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts @@ -4,30 +4,24 @@ import { ProjectId, ProviderInstanceId, ThreadId, - type OrchestrationProject, + type OrchestrationProjectShell, type OrchestrationThreadShell, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { LaunchEnv } from "../launchEnv/Services/LaunchEnv.ts"; -import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { TerminalSessionLookupError } from "./Services/Manager.ts"; -import { - resolveTerminalOpenInput, - resolveTerminalRestartInput, -} from "./resolveTerminalLaunchEnv.ts"; +import { bindTerminalLaunchEnvResolverForTest } from "./Layers/TerminalLaunchEnvResolverTest.ts"; const PROJECT_ID = ProjectId.make("project-1"); const THREAD_ID = ThreadId.make("thread-1"); +const T3_HOME = "/tmp/t3-resolve-terminal-launch-env"; const NOW = "2026-01-01T00:00:00.000Z"; const DEFAULT_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; -const makeProject = (): OrchestrationProject => ({ +const makeProject = (): OrchestrationProjectShell => ({ id: PROJECT_ID, title: "Project", workspaceRoot: "/repo/project", @@ -35,7 +29,6 @@ const makeProject = (): OrchestrationProject => ({ scripts: [], createdAt: NOW, updatedAt: NOW, - deletedAt: null, }); const makeThread = ( @@ -61,40 +54,25 @@ const makeThread = ( ...overrides, }); -const makeTestLayer = (threadOption: Option.Option) => - LaunchEnv.layerTest("/tmp/t3-resolve-terminal-launch-env").pipe( - Layer.provideMerge( - Layer.succeed(ProjectionSnapshotQuery, { - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), - getCounts: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: (projectId) => - Effect.succeed(projectId === PROJECT_ID ? Option.some(makeProject()) : Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.die("unused"), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadShellById: (threadId) => - Effect.succeed(threadId === THREAD_ID ? threadOption : Option.none()), - getThreadDetailById: () => Effect.die("unused"), - }), - ), - ); +const makeResolver = (threads: ReadonlyArray) => + bindTerminalLaunchEnvResolverForTest({ + t3Home: T3_HOME, + projects: [makeProject()], + threads, + }); describe("resolveTerminalLaunchEnv", () => { it.effect("resolves launch env for open using the thread project id", () => Effect.gen(function* () { - const result = yield* resolveTerminalOpenInput({ + const resolver = makeResolver([makeThread()]); + const result = yield* resolver.resolveOpenInput({ threadId: THREAD_ID, terminalId: DEFAULT_TERMINAL_ID, cwd: "/repo/worktrees/a", - }).pipe(Effect.provide(makeTestLayer(Option.some(makeThread())))); + }); assert.deepStrictEqual(result.env, { - T3CODE_HOME: "/tmp/t3-resolve-terminal-launch-env", + T3CODE_HOME: T3_HOME, T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_PROJECT_ID: "project-1", T3CODE_THREAD_ID: "thread-1", @@ -107,12 +85,13 @@ describe("resolveTerminalLaunchEnv", () => { it.effect("ignores client projectId when the thread already exists", () => Effect.gen(function* () { const spoofedProjectId = ProjectId.make("project-spoofed"); - const result = yield* resolveTerminalOpenInput({ + const resolver = makeResolver([makeThread()]); + const result = yield* resolver.resolveOpenInput({ threadId: THREAD_ID, terminalId: DEFAULT_TERMINAL_ID, projectId: spoofedProjectId, cwd: "/repo/worktrees/a", - }).pipe(Effect.provide(makeTestLayer(Option.some(makeThread())))); + }); assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); }), @@ -120,12 +99,13 @@ describe("resolveTerminalLaunchEnv", () => { it.effect("resolves launch env for draft threads using client projectId", () => Effect.gen(function* () { - const result = yield* resolveTerminalOpenInput({ + const resolver = makeResolver([]); + const result = yield* resolver.resolveOpenInput({ threadId: THREAD_ID, terminalId: DEFAULT_TERMINAL_ID, projectId: PROJECT_ID, cwd: "/repo/project", - }).pipe(Effect.provide(makeTestLayer(Option.none()))); + }); assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); assert.strictEqual(result.env.T3CODE_THREAD_ID, "thread-1"); @@ -134,12 +114,13 @@ describe("resolveTerminalLaunchEnv", () => { it.effect("fails when the thread is not found and projectId is omitted", () => Effect.gen(function* () { + const resolver = makeResolver([]); const error = yield* Effect.flip( - resolveTerminalOpenInput({ + resolver.resolveOpenInput({ threadId: THREAD_ID, terminalId: DEFAULT_TERMINAL_ID, cwd: "/repo/worktrees/a", - }).pipe(Effect.provide(makeTestLayer(Option.none()))), + }), ); assert.instanceOf(error, TerminalSessionLookupError); @@ -148,13 +129,14 @@ describe("resolveTerminalLaunchEnv", () => { it.effect("resolves launch env for restart using the thread project id", () => Effect.gen(function* () { - const result = yield* resolveTerminalRestartInput({ + const resolver = makeResolver([makeThread({ worktreePath: null })]); + const result = yield* resolver.resolveRestartInput({ threadId: THREAD_ID, terminalId: DEFAULT_TERMINAL_ID, cwd: "/repo/project", cols: 120, rows: 40, - }).pipe(Effect.provide(makeTestLayer(Option.some(makeThread({ worktreePath: null }))))); + }); assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); }), diff --git a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts index a9866e95aa9..f887bef7878 100644 --- a/apps/server/src/terminal/resolveTerminalLaunchEnv.ts +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts @@ -10,16 +10,23 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { LaunchEnv, type LaunchEnvShape } from "../launchEnv/Services/LaunchEnv.ts"; -import { - ProjectionSnapshotQuery, - type ProjectionSnapshotQueryShape, -} from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { ProjectionSnapshotQueryShape } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { TerminalCwdError, TerminalSessionLookupError } from "./Services/Manager.ts"; export type TerminalAttachRuntimeInput = TerminalAttachInput & { readonly projectId: ProjectId; }; +export type TerminalLaunchEnvProjectionShape = Pick< + ProjectionSnapshotQueryShape, + "getProjectShellById" | "getThreadShellById" +>; + +export class TerminalLaunchEnvProjection extends Context.Service< + TerminalLaunchEnvProjection, + TerminalLaunchEnvProjectionShape +>()("t3/terminal/TerminalLaunchEnvProjection") {} + export interface TerminalLaunchEnvResolver { readonly resolveOpenInput: ( input: TerminalOpenInput, @@ -46,6 +53,8 @@ type TerminalProjectContextInput = { readonly worktreePath?: string | null | undefined; }; +type TerminalLaunchEnvResolverServices = LaunchEnv | TerminalLaunchEnvProjection; + const terminalSessionLookupError = (input: { readonly threadId: string; readonly terminalId: string; @@ -55,12 +64,17 @@ const terminalSessionLookupError = (input: { terminalId: input.terminalId, }); +const provideTerminalLaunchEnvResolverServices = ( + services: Context.Context, + effect: Effect.Effect, +) => effect.pipe(Effect.provide(services)); + const resolveProjectContextForTerminal = Effect.fn("resolveProjectContextForTerminal")(function* ( input: TerminalProjectContextInput, ) { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projection = yield* TerminalLaunchEnvProjection; - const threadOption = yield* projectionSnapshotQuery + const threadOption = yield* projection .getThreadShellById(ThreadId.make(input.threadId)) .pipe(Effect.mapError(() => terminalSessionLookupError(input))); @@ -86,11 +100,11 @@ const resolveProjectContextForTerminal = Effect.fn("resolveProjectContextForTerm export const resolveTerminalLaunchEnv = Effect.fn("resolveTerminalLaunchEnv")(function* ( input: ResolveTerminalLaunchEnvInput, ) { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const projection = yield* TerminalLaunchEnvProjection; const launchEnv = yield* LaunchEnv; const projectId = input.projectId; - const projectOption = yield* projectionSnapshotQuery.getProjectShellById(projectId).pipe( + const projectOption = yield* projection.getProjectShellById(projectId).pipe( Effect.mapError( (cause) => new TerminalCwdError({ @@ -202,19 +216,12 @@ export const resolveTerminalAttachInput = Effect.fn("resolveTerminalAttachInput" } satisfies TerminalAttachRuntimeInput; }); -type TerminalLaunchEnvResolverServices = LaunchEnv | ProjectionSnapshotQuery; - -const provideTerminalLaunchEnvResolverServices = ( - services: Context.Context, - effect: Effect.Effect, -) => effect.pipe(Effect.provide(services)); - export const bindTerminalLaunchEnvResolver = ( launchEnv: LaunchEnvShape, - projectionSnapshotQuery: ProjectionSnapshotQueryShape, + projection: TerminalLaunchEnvProjectionShape, ): TerminalLaunchEnvResolver => { const services = Context.make(LaunchEnv, launchEnv).pipe( - Context.add(ProjectionSnapshotQuery, projectionSnapshotQuery), + Context.add(TerminalLaunchEnvProjection, projection), ); return { @@ -226,14 +233,3 @@ export const bindTerminalLaunchEnvResolver = ( provideTerminalLaunchEnvResolverServices(services, resolveTerminalAttachInput(input)), }; }; - -export const terminalLaunchEnvResolverTest = (projectId: ProjectId): TerminalLaunchEnvResolver => ({ - resolveOpenInput: (input) => Effect.succeed(input), - resolveRestartInput: (input) => Effect.succeed(input), - resolveAttachInput: (input) => - Effect.succeed({ - ...input, - projectId, - }), -}); -