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 19a4b56417c..1811be706bb 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -37,6 +37,7 @@ import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapt import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.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"; @@ -374,6 +375,8 @@ 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), @@ -381,7 +384,13 @@ export const makeOrchestrationIntegrationHarness = ( 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.provide(runtimeServicesLayer), + ), + ), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 27a1d55e90d..cc6c333450b 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 { 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"; @@ -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, + defaultLaunchEnvTestLayer, +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); 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 new file mode 100644 index 00000000000..e328314551e --- /dev/null +++ b/apps/server/src/launchEnv/Services/LaunchEnv.ts @@ -0,0 +1,160 @@ +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"; + +export interface ResolveLaunchEnvInput { + readonly projectRoot: string; + readonly projectId: ProjectId | string; + readonly threadId: string; + readonly worktreePath?: string | null; + 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"] => + 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 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", +) {} + +export const makeLaunchEnv = Effect.fn("makeLaunchEnv")(function* () { + const serverConfig = yield* ServerConfig; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const resolve = makeResolveLaunchEnv(serverConfig.baseDir); + + return { + resolve, + resolveForThread: makeResolveForThread(resolve, { + getThreadShellById: (threadId) => projectionSnapshotQuery.getThreadShellById(threadId), + getProjectShellById: (projectId) => projectionSnapshotQuery.getProjectShellById(projectId), + }), + } 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 0d5cfe2feba..c7720c0ad8e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -57,6 +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 { 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"; @@ -342,7 +343,13 @@ 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( + makeLaunchEnvLayerLive(SqlitePersistenceMemory).pipe(Layer.provide(serverConfigLayer)), + ), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), @@ -368,7 +375,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); @@ -452,6 +459,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 e0db0fc320c..9ff08e1dbb9 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"; @@ -186,6 +187,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)); @@ -460,6 +462,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; @@ -470,6 +481,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..747ac98daeb 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 { 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"; @@ -20,25 +21,20 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro deletedAt: null, }); +const TEST_BASE_DIR = "/tmp/t3-setup-script-runner"; +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", () => { @@ -50,6 +46,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 +109,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 +147,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/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/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..7df95be13ce 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,16 +1,32 @@ 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: 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 4ed64890fc3..8b2534c1b81 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 { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { collectSessionConfigOptionValues, extractModelConfigId, @@ -204,7 +205,9 @@ 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 !== 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 0bf2f6589f0..f0398f3f2fb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6450,6 +6450,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 +6476,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, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 94b6cb753a2..36a7726da75 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 { 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"; @@ -142,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( @@ -167,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), ); @@ -223,8 +233,6 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); - const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistryLayerLive), @@ -266,7 +274,6 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), @@ -450,8 +457,9 @@ 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(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..96038c8d175 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 { launchEnvTestStub } from "../../launchEnv/Layers/LaunchEnvTest.ts"; class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ readonly message: string; @@ -238,6 +240,10 @@ const createManager = ( logsDir, historyLineLimit, ptyAdapter, + 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 cd490de1e3f..97afd83e8d9 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1,15 +1,19 @@ import { DEFAULT_TERMINAL_ID, + ProjectId, type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, + type TerminalRestartInput, type TerminalSessionSnapshot, type TerminalSessionStatus, type TerminalSummary, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import { isManagedRuntimeEnvKey } from "../../launchEnv/launchEnvUtils.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"; @@ -885,7 +889,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,14 +917,20 @@ 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))); } +type TerminalAttachRuntimeInput = TerminalAttachInput & { + readonly projectId: ProjectId; +}; + interface TerminalManagerOptions { logsDir: string; historyLineLimit?: number; @@ -932,14 +942,18 @@ interface TerminalManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; + 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, + launchEnv, }); }); @@ -949,6 +963,49 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); + 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; @@ -1884,6 +1941,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 +1973,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith unsubscribeExit: null, hasRunningSubprocess: false, childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), + runtimeEnv: nextRuntimeEnv, }; const createdSession = session; @@ -1943,7 +2001,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; @@ -2005,9 +2062,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const open: TerminalManagerShape["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock(input.threadId, runWithLaunchEnv(input, openLocked)); - const openOrAttachForStream = (input: TerminalAttachInput) => + const openOrAttachForStream = (input: TerminalAttachRuntimeInput) => withThreadLock( input.threadId, Effect.gen(function* () { @@ -2021,7 +2078,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith terminalId, }); } - return yield* openLocked({ ...input, terminalId, @@ -2107,7 +2163,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith return attachEvent ? listener(attachEvent) : Effect.void; }); - const initialSnapshot = yield* openOrAttachForStream(input); + const resolvedInput = yield* applyLaunchEnvForAttach(input); + const initialSnapshot = yield* openOrAttachForStream(resolvedInput); yield* listener({ type: "snapshot", @@ -2277,85 +2334,87 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }), ); - 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 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 cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; + 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; + } - 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); - }), + 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); + }); + + const restart: TerminalManagerShape["restart"] = (input) => + withThreadLock(input.threadId, runWithLaunchEnv(input, restartLocked)); const close: TerminalManagerShape["close"] = (input) => withThreadLock( @@ -2389,7 +2448,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith close, subscribe, subscribeMetadata, - } satisfies TerminalManagerShape; + } as TerminalManagerShape; }, ); 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/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.test.ts b/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts new file mode 100644 index 00000000000..8624e1159ed --- /dev/null +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.test.ts @@ -0,0 +1,144 @@ +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 "./Services/Manager.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 = (): 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 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 resolver = makeResolver([makeThread()]); + const result = yield* resolver.resolveOpenInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/repo/worktrees/a", + }); + + 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"); + }), + ); + + it.effect("ignores client projectId when the thread already exists", () => + Effect.gen(function* () { + const spoofedProjectId = ProjectId.make("project-spoofed"); + const resolver = makeResolver([makeThread()]); + const result = yield* resolver.resolveOpenInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + projectId: spoofedProjectId, + cwd: "/repo/worktrees/a", + }); + + assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1"); + }), + ); + + it.effect("resolves launch env for draft threads using client projectId", () => + Effect.gen(function* () { + const resolver = makeResolver([]); + const result = yield* resolver.resolveOpenInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + projectId: PROJECT_ID, + cwd: "/repo/project", + }); + + 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 resolver = makeResolver([]); + const error = yield* Effect.flip( + resolver.resolveOpenInput({ + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/repo/worktrees/a", + }), + ); + + assert.instanceOf(error, TerminalSessionLookupError); + }), + ); + + it.effect("resolves launch env for restart using the thread project id", () => + Effect.gen(function* () { + 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, + }); + + 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 new file mode 100644 index 00000000000..f887bef7878 --- /dev/null +++ b/apps/server/src/terminal/resolveTerminalLaunchEnv.ts @@ -0,0 +1,235 @@ +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 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, + ) => 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; +} + +type TerminalProjectContextInput = { + readonly threadId: string; + readonly terminalId: string; + readonly projectId?: ProjectId; + readonly worktreePath?: string | null | undefined; +}; + +type TerminalLaunchEnvResolverServices = LaunchEnv | TerminalLaunchEnvProjection; + +const terminalSessionLookupError = (input: { + readonly threadId: string; + readonly terminalId: string; +}) => + new TerminalSessionLookupError({ + threadId: input.threadId, + 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 projection = yield* TerminalLaunchEnvProjection; + + const threadOption = yield* projection + .getThreadShellById(ThreadId.make(input.threadId)) + .pipe(Effect.mapError(() => terminalSessionLookupError(input))); + + return 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(terminalSessionLookupError(input)); + } + + return Effect.succeed({ + projectId: input.projectId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); + }, + }); +}); + +export const resolveTerminalLaunchEnv = Effect.fn("resolveTerminalLaunchEnv")(function* ( + input: ResolveTerminalLaunchEnvInput, +) { + const projection = yield* TerminalLaunchEnvProjection; + const launchEnv = yield* LaunchEnv; + const projectId = input.projectId; + + 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, + }); + + 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 } : {}), + }); +}); + +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, +) => ({ + threadId: input.threadId, + terminalId: input.terminalId, + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), +}); + +export const resolveTerminalOpenInput = Effect.fn("resolveTerminalOpenInput")(function* ( + input: TerminalOpenInput, +) { + const { worktreePath, env } = yield* resolveLaunchEnvForTerminalInput( + terminalLaunchEnvInput(input), + ); + + return { + ...input, + ...(worktreePath !== undefined ? { worktreePath } : {}), + env, + }; +}); + +export const resolveTerminalRestartInput = Effect.fn("resolveTerminalRestartInput")(function* ( + input: TerminalRestartInput, +) { + const { worktreePath, env } = yield* resolveLaunchEnvForTerminalInput( + terminalLaunchEnvInput(input), + ); + + return { + ...input, + ...(worktreePath !== undefined ? { worktreePath } : {}), + env, + }; +}); + +export const resolveTerminalAttachInput = Effect.fn("resolveTerminalAttachInput")(function* ( + input: TerminalAttachInput, +) { + const { projectId, worktreePath } = yield* resolveProjectContextForTerminal({ + threadId: input.threadId, + terminalId: input.terminalId, + ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); + + const env = yield* resolveTerminalLaunchEnv({ + projectId, + threadId: input.threadId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(input.env !== undefined ? { extraEnv: input.env } : {}), + }); + + return { + ...input, + projectId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + env, + } satisfies TerminalAttachRuntimeInput; +}); + +export const bindTerminalLaunchEnvResolver = ( + launchEnv: LaunchEnvShape, + projection: TerminalLaunchEnvProjectionShape, +): TerminalLaunchEnvResolver => { + const services = Context.make(LaunchEnv, launchEnv).pipe( + Context.add(TerminalLaunchEnvProjection, projection), + ); + + return { + resolveOpenInput: (input) => + provideTerminalLaunchEnvResolverServices(services, resolveTerminalOpenInput(input)), + resolveRestartInput: (input) => + provideTerminalLaunchEnvResolverServices(services, resolveTerminalRestartInput(input)), + resolveAttachInput: (input) => + provideTerminalLaunchEnvResolverServices(services, resolveTerminalAttachInput(input)), + }; +}; diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 69e0e514061..30305b98ffe 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 }, ); @@ -2356,10 +2353,8 @@ describe("ChatView timeline estimator parity (full app)", () => { _tag: WS_METHODS.terminalOpen, threadId: THREAD_ID, cwd: "/repo/project", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - }, }); + expect(openRequest?.env).toBeUndefined(); }, { timeout: 8_000, interval: 16 }, ); @@ -2435,11 +2430,9 @@ describe("ChatView timeline estimator parity (full app)", () => { _tag: WS_METHODS.terminalOpen, threadId: THREAD_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 df5d5e4a2ff..ab0599bb006 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"; @@ -526,7 +527,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra { readonly cwd: string; readonly worktreePath: string | null; - readonly runtimeEnv: Record; } >(); if (!project) { @@ -543,10 +543,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, - }), }); } @@ -593,17 +589,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; @@ -620,7 +605,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); @@ -631,9 +616,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. @@ -643,16 +628,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); @@ -663,9 +648,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. @@ -675,11 +660,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, - runtimeEnv, serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, + project, ]); const activateTerminal = useCallback( @@ -738,9 +723,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra 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; @@ -2173,18 +2147,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.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 56482c44e8e..8552c28a75a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -1,7 +1,7 @@ import "../index.css"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; +import { ProjectId, ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; @@ -132,6 +132,7 @@ vi.mock("~/localApi", () => ({ 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: { ; } export function TerminalViewport({ threadRef, threadId, + projectId, terminalId, terminalLabel, cwd, @@ -664,11 +666,12 @@ export function TerminalViewport({ terminal: { threadId, terminalId, + projectId, cwd, ...(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; @@ -727,7 +730,7 @@ export function TerminalViewport({ // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); + }, [cwd, environmentId, projectId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { if (!autoFocus) return; @@ -776,9 +779,9 @@ export function TerminalViewport({ interface ThreadTerminalDrawerProps { threadRef: ScopedThreadRef; threadId: ThreadId; + projectId: ProjectId; cwd: string; worktreePath?: string | null; - runtimeEnv?: Record; visible?: boolean; height: number; terminalIds: string[]; @@ -834,9 +837,9 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA export default function ThreadTerminalDrawer({ threadRef, threadId, + projectId, cwd, worktreePath, - runtimeEnv, visible = true, height, terminalIds, @@ -992,11 +995,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)` @@ -1225,15 +1227,13 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} @@ -1253,15 +1253,13 @@ export default function ThreadTerminalDrawer({ key={resolvedActiveTerminalId} threadRef={threadRef} threadId={threadId} + projectId={projectId} terminalId={resolvedActiveTerminalId} terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} cwd={activeTerminalLaunchLocation.cwd} {...(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..a35f829f750 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, @@ -75,6 +76,17 @@ describe("TerminalOpenInput", () => { ).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 a3c8e37e7f9..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,18 +36,25 @@ 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), 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, + ...TerminalDraftProjectInput.fields, cwd: Schema.optional(TrimmedNonEmptyStringSchema), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: Schema.optional(TerminalColsSchema), @@ -55,7 +62,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 +82,14 @@ 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, 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; }