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..f829dd3600b 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -9,14 +9,9 @@ import { terminalSessionStateAtom, type TerminalSessionTarget, type TerminalSessionState, + type TerminalAttachSessionInput, } from "@t3tools/client-runtime"; -import type { - EnvironmentId, - TerminalAttachInput, - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, -} from "@t3tools/contracts"; +import type { EnvironmentId, TerminalMetadataStreamEvent } from "@t3tools/contracts"; import { useMemo } from "react"; import { appAtomRegistry } from "./atom-registry"; @@ -39,13 +34,11 @@ export function subscribeTerminalMetadata(input: { return terminalSessionManager.subscribeMetadata(input); } -export function attachTerminalSession(input: { - readonly environmentId: EnvironmentId; - readonly client: Parameters[0]["client"]; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: (event: TerminalAttachStreamEvent) => void; -}) { +export function attachTerminalSession( + input: TerminalAttachSessionInput & { + readonly environmentId: EnvironmentId; + }, +) { return terminalSessionManager.attach({ environmentId: input.environmentId, client: input.client, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 19a4b56417c..b9f94ee82d3 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/Layers/LaunchEnvLive.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; @@ -259,12 +260,16 @@ export const makeOrchestrationIntegrationHarness = ( const persistenceLayer = makeSqlitePersistenceLive(dbPath); const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(persistenceLayer), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(persistenceLayer), ); const realCodexRegistry = Layer.effect( ProviderAdapterRegistry, @@ -297,12 +302,15 @@ export const makeOrchestrationIntegrationHarness = ( const providerRegistryLayer = makeProviderRegistryLayer(); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); - const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; + const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(persistenceLayer), + ); const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, - orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)), - ProjectionCheckpointRepositoryLive, - ProjectionPendingApprovalRepositoryLive, + orchestrationLayer, + ProjectionCheckpointRepositoryLive.pipe(Layer.provide(persistenceLayer)), + ProjectionPendingApprovalRepositoryLive.pipe(Layer.provide(persistenceLayer)), checkpointStoreLayer, providerLayer, RuntimeReceiptBusTest, @@ -374,14 +382,20 @@ export const makeOrchestrationIntegrationHarness = ( }), ), ); + const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), Layer.provideMerge(providerRegistryLayer), - Layer.provide(persistenceLayer), - Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), + Layer.provideMerge(serverConfigLayer), + Layer.provide(persistenceLayer), + Layer.provideMerge( + LaunchEnvLive.pipe( + Layer.provide(serverConfigLayer), + Layer.provide(projectionSnapshotQueryLayer), + ), + ), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/launchEnv/Layers/LaunchEnvLive.ts b/apps/server/src/launchEnv/Layers/LaunchEnvLive.ts new file mode 100644 index 00000000000..c90bff0df2a --- /dev/null +++ b/apps/server/src/launchEnv/Layers/LaunchEnvLive.ts @@ -0,0 +1,116 @@ +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ServerConfig } from "../../config.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { LaunchEnv, type LaunchEnvShape } from "../Services/LaunchEnv.ts"; +import { mergeResolvedLaunchEnv } from "../launchEnvUtils.ts"; +import { + LaunchEnvProjectLookupError, + LaunchEnvThreadLookupError, +} from "../Services/LaunchEnvErrors.ts"; + +export const makeLaunchEnv = Effect.fn("makeLaunchEnv")(function* () { + const serverConfig = yield* ServerConfig; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const resolve: LaunchEnvShape["resolve"] = (input) => + Effect.succeed( + mergeResolvedLaunchEnv({ + t3Home: serverConfig.baseDir, + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + context: { + projectRoot: input.projectRoot, + projectId: String(input.projectId), + threadId: String(input.threadId), + worktreePath: input.worktreePath ?? undefined, + }, + }), + ); + + const resolveForThread: LaunchEnvShape["resolveForThread"] = Effect.fn( + "LaunchEnv.resolveForThread", + )(function* (input) { + const threadOption = yield* projectionSnapshotQuery + .getThreadShellById(ThreadId.make(input.threadId)) + .pipe( + Effect.mapError( + (cause) => + new LaunchEnvThreadLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + cause, + }), + ), + ); + + 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( + new LaunchEnvThreadLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ); + } + + return Effect.succeed({ + projectId: input.projectId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); + }, + }); + + const projectOption = yield* projectionSnapshotQuery.getProjectShellById(projectId).pipe( + Effect.mapError( + (cause) => + new LaunchEnvProjectLookupError({ + projectId: String(projectId), + reason: "statFailed", + cause, + }), + ), + ); + + const project = yield* Option.match(projectOption, { + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new LaunchEnvProjectLookupError({ + projectId: String(projectId), + reason: "notFound", + }), + ), + }); + + const env: Record = yield* resolve({ + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + projectRoot: project.workspaceRoot, + projectId: project.id, + threadId: input.threadId, + ...(worktreePath !== undefined ? { worktreePath } : {}), + }); + + const result = { + projectId, + worktreePath, + env, + }; + return result; + }); + + return { + resolve, + resolveForThread, + } satisfies LaunchEnvShape; +}); + +export const LaunchEnvLive = Layer.effect(LaunchEnv, makeLaunchEnv()); diff --git a/apps/server/src/launchEnv/Layers/LaunchEnvTest.ts b/apps/server/src/launchEnv/Layers/LaunchEnvTest.ts new file mode 100644 index 00000000000..9700faa1201 --- /dev/null +++ b/apps/server/src/launchEnv/Layers/LaunchEnvTest.ts @@ -0,0 +1,165 @@ +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, + type LaunchEnvShape, + type ResolvedLaunchEnvForThread, +} from "../Services/LaunchEnv.ts"; +import { + LaunchEnvProjectLookupError, + LaunchEnvThreadLookupError, +} from "../Services/LaunchEnvErrors.ts"; +import { mergeResolvedLaunchEnv } from "../launchEnvUtils.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: LaunchEnvShape["resolve"] = (input) => + Effect.succeed( + mergeResolvedLaunchEnv({ + t3Home: fixtures.t3Home, + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + context: { + projectRoot: input.projectRoot, + projectId: String(input.projectId), + threadId: String(input.threadId), + worktreePath: input.worktreePath ?? undefined, + }, + }), + ); + + const projectsById = toProjectMap(fixtures.projects); + const threadsById = toThreadMap(fixtures.threads); + + const resolveForThread: LaunchEnvShape["resolveForThread"] = Effect.fn( + "LaunchEnv.resolveForThread", + )(function* (input) { + const threadOption = yield* Effect.succeed( + Option.fromNullishOr(threadsById.get(ThreadId.make(input.threadId))), + ); + + 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( + new LaunchEnvThreadLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }), + ); + } + + return Effect.succeed({ + projectId: input.projectId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }); + }, + }); + + const project = yield* Effect.succeed(Option.fromNullishOr(projectsById.get(projectId))).pipe( + Effect.flatMap((projectOption) => + Option.match(projectOption, { + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new LaunchEnvProjectLookupError({ + projectId: String(projectId), + reason: "notFound", + }), + ), + }), + ), + ); + + 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, + env, + } satisfies ResolvedLaunchEnvForThread; + }); + + return { + resolve, + resolveForThread, + }; +}; + +export const launchEnvTestStub = (fixtures: { + readonly t3Home: string; + readonly projectId: ProjectId; +}): LaunchEnvShape => { + const resolve: LaunchEnvShape["resolve"] = (input) => + Effect.succeed( + mergeResolvedLaunchEnv({ + t3Home: fixtures.t3Home, + ...(input.extraEnv !== undefined ? { extraEnv: input.extraEnv } : {}), + context: { + projectRoot: input.projectRoot, + projectId: String(input.projectId), + threadId: input.threadId, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + }, + }), + ); + + return { + resolve, + resolveForThread: (resolveInput) => + Effect.succeed({ + projectId: fixtures.projectId, + ...(resolveInput.worktreePath !== undefined + ? { worktreePath: resolveInput.worktreePath } + : {}), + env: Object.fromEntries( + Object.entries(resolveInput.extraEnv ?? {}).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ), + } 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..4c78256b4dc --- /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 { LaunchEnvTestLayer } from "../Layers/LaunchEnvTest.ts"; +import { LaunchEnvThreadLookupError } from "../Services/LaunchEnvErrors.ts"; +import { LaunchEnv } from "../Services/LaunchEnv.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, LaunchEnvThreadLookupError); + }).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..054f5b17f69 --- /dev/null +++ b/apps/server/src/launchEnv/Services/LaunchEnv.ts @@ -0,0 +1,42 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { EnvRecord } from "../launchEnvUtils.ts"; +import { LaunchEnvProjectLookupError, LaunchEnvThreadLookupError } from "./LaunchEnvErrors.ts"; + +export interface ResolveLaunchEnvInput { + readonly projectRoot: string; + readonly projectId: ProjectId | string; + readonly threadId: ThreadId; + readonly worktreePath?: string | null | undefined; + readonly extraEnv?: EnvRecord; +} + +export interface ResolveLaunchEnvForThreadInput { + readonly threadId: ThreadId; + 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 | undefined; + readonly env: Record; +}; + +export interface LaunchEnvShape { + readonly resolve: (input: ResolveLaunchEnvInput) => Effect.Effect>; + readonly resolveForThread: ( + input: ResolveLaunchEnvForThreadInput, + ) => Effect.Effect< + ResolvedLaunchEnvForThread, + LaunchEnvProjectLookupError | LaunchEnvThreadLookupError + >; +} + +export class LaunchEnv extends Context.Service()( + "t3/launchEnv/Services/LaunchEnv", +) {} diff --git a/apps/server/src/launchEnv/Services/LaunchEnvErrors.ts b/apps/server/src/launchEnv/Services/LaunchEnvErrors.ts new file mode 100644 index 00000000000..47e7feae045 --- /dev/null +++ b/apps/server/src/launchEnv/Services/LaunchEnvErrors.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; + +export class LaunchEnvProjectLookupError extends Schema.TaggedErrorClass()( + "LaunchEnvProjectLookupError", + { + projectId: Schema.String, + reason: Schema.Enum({ notFound: "notFound", statFailed: "statFailed" }), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "notFound" + ? `Project not found: ${this.projectId}` + : `Failed to stat project: ${this.projectId}`; + } +} + +export class LaunchEnvThreadLookupError extends Schema.TaggedErrorClass()( + "LaunchEnvThreadLookupError", + { + threadId: Schema.String, + terminalId: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Thread not found: ${this.threadId}`; + } +} 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..6963e8094a1 --- /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 | undefined; +} + +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 77f9a2ed904..1eff60b603a 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 { LaunchEnvLive } 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,16 @@ 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(projectionSnapshotLayer), + Layer.provide(serverConfigLayer), + ), + ), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), @@ -368,7 +378,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 +462,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 9c7a7c94bb1..8b5811e10ac 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"; @@ -196,6 +197,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)); @@ -470,6 +472,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; @@ -480,6 +491,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 38b77c69262..dd5e05b0dbd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -70,6 +70,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, @@ -1366,9 +1367,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 @@ -3094,6 +3092,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 04ef44d54e8..69cece6aa0a 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: "priority", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8c9969e2bc4..fc134e1daa2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -51,6 +51,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, @@ -1387,7 +1388,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 cdb3c224b97..7d067c6ee17 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, @@ -532,7 +533,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 0f1007f261b..6a948e829ff 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"; @@ -376,7 +377,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 54444ce586d..b0c2fdb678e 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..7362f1f9aba 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,16 +1,37 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { + isManagedRuntimeEnvKey, + stripManagedRuntimeEnvKeys, + type EnvRecord, +} from "../launchEnv/launchEnvUtils.ts"; + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, - baseEnv: NodeJS.ProcessEnv = process.env, -): NodeJS.ProcessEnv { + baseEnv: EnvRecord = process.env, +): Record { + const next = 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: EnvRecord | undefined, + sessionEnv: EnvRecord | undefined, +): Record { + const next = stripManagedRuntimeEnvKeys(baseEnv ?? process.env); + if (!sessionEnv) return next; + for (const [key, value] of Object.entries(sessionEnv)) { + if (value !== undefined) { + 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 b7331f95e6e..ab293eda16a 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/Layers/LaunchEnvLive.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -76,7 +77,10 @@ import * as CloudCliState from "./cloud/CliState.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { + OrchestrationInfrastructureLayerLive, + OrchestrationLayerLive, +} from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -150,14 +154,31 @@ const PlatformServicesLive = Layer.unwrap( }), ); +const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); + +const LaunchEnvLayerLive = LaunchEnvLive.pipe( + Layer.provideMerge(OrchestrationInfrastructureLayerLive), + Layer.provideMerge(PersistenceLayerLive), +); + +const TerminalLayerLive = TerminalManagerLive.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(LaunchEnvLayerLive), +); + 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), + 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.provide(LaunchEnvLayerLive), ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( @@ -175,8 +196,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), ); @@ -231,8 +250,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), @@ -458,8 +475,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..5822267a6ff 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1,15 +1,20 @@ import { DEFAULT_TERMINAL_ID, + ProjectId, + ThreadId, 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 +890,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 +918,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 +943,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 +964,88 @@ 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: ThreadId.make(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.catchTags({ + LaunchEnvProjectLookupError: (error) => + Effect.fail( + new TerminalCwdError({ + cwd: error.projectId, + reason: error.reason === "notFound" ? "notFound" : "statFailed", + cause: error.cause, + }), + ), + LaunchEnvThreadLookupError: (error) => + Effect.fail( + new TerminalSessionLookupError({ + threadId: error.threadId, + terminalId: error.terminalId ?? "", + }), + ), + }), + Effect.map((resolved) => ({ + ...input, + ...(resolved.worktreePath !== undefined ? { worktreePath: resolved.worktreePath } : {}), + env: resolved.env, + })), + ); + + const resolveLaunchInput = (input: T) => + applyLaunchEnv(input).pipe( + Effect.catchTag("TerminalSessionLookupError", () => Effect.succeed(input)), + ); + + const applyLaunchEnvForAttach = (input: TerminalAttachInput) => + launchEnv.resolveForThread(toLaunchEnvInput(input)).pipe( + Effect.catchTags({ + LaunchEnvProjectLookupError: (error) => + Effect.fail( + new TerminalCwdError({ + cwd: error.projectId, + reason: error.reason === "notFound" ? "notFound" : "statFailed", + cause: error.cause, + }), + ), + LaunchEnvThreadLookupError: (error) => + Effect.fail( + new TerminalSessionLookupError({ + threadId: error.threadId, + terminalId: error.terminalId ?? "", + }), + ), + }), + Effect.map( + (resolved) => + ({ + ...input, + projectId: resolved.projectId, + ...(resolved.worktreePath !== undefined + ? { worktreePath: resolved.worktreePath } + : {}), + env: resolved.env, + }) satisfies TerminalAttachRuntimeInput, + ), + ); + + const resolveAttachLaunchInput = (input: TerminalAttachInput) => + applyLaunchEnvForAttach(input).pipe( + Effect.catchTag("TerminalSessionLookupError", () => Effect.succeed(input)), + ); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; @@ -1884,6 +1981,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 +2013,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith unsubscribeExit: null, hasRunningSubprocess: false, childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), + runtimeEnv: nextRuntimeEnv, }; const createdSession = session; @@ -1943,7 +2041,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,7 +2102,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const open: TerminalManagerShape["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock( + input.threadId, + Effect.gen(function* () { + const resolvedInput = yield* resolveLaunchInput(input); + return yield* openLocked(resolvedInput); + }), + ); const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( @@ -2021,9 +2124,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith terminalId, }); } - + const resolvedInput = yield* resolveAttachLaunchInput(input); return yield* openLocked({ - ...input, + ...resolvedInput, terminalId, cwd: input.cwd, }); @@ -2034,8 +2137,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const targetRows = input.rows ?? session.rows; if (!session.process && input.cwd && input.restartIfNotRunning === true) { + const resolvedInput = yield* resolveAttachLaunchInput(input); return yield* openLocked({ - ...input, + ...resolvedInput, terminalId, cwd: input.cwd, }); @@ -2281,21 +2385,23 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith withThreadLock( input.threadId, Effect.gen(function* () { + const resolvedInput = yield* resolveLaunchInput(input); yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); + const terminalId = resolvedInput.terminalId; + yield* assertValidCwd(resolvedInput.cwd); + const nextRuntimeEnv = normalizedRuntimeEnv(resolvedInput.env); - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); + const sessionKey = toSessionKey(resolvedInput.threadId, terminalId); + const existingSession = yield* getSession(resolvedInput.threadId, terminalId); let session: TerminalSessionState; if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; + const cols = resolvedInput.cols ?? DEFAULT_OPEN_COLS; + const rows = resolvedInput.rows ?? DEFAULT_OPEN_ROWS; session = { - threadId: input.threadId, + threadId: resolvedInput.threadId, terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, + cwd: resolvedInput.cwd, + worktreePath: resolvedInput.worktreePath ?? null, status: "starting", pid: null, history: "", @@ -2314,7 +2420,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith unsubscribeExit: null, hasRunningSubprocess: false, childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), + runtimeEnv: nextRuntimeEnv, }; const createdSession = session; yield* modifyManagerState((state) => { @@ -2326,30 +2432,32 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } else { session = existingSession.value; yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); + session.cwd = resolvedInput.cwd; + session.worktreePath = resolvedInput.worktreePath ?? null; + session.runtimeEnv = nextRuntimeEnv; } - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; + const cols = resolvedInput.cols ?? session.cols; + const rows = resolvedInput.rows ?? session.rows; session.history = ""; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); + yield* persistHistory(resolvedInput.threadId, terminalId, session.history); yield* startSession( session, { - threadId: input.threadId, + threadId: resolvedInput.threadId, terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cwd: resolvedInput.cwd, + ...(resolvedInput.worktreePath !== undefined + ? { worktreePath: resolvedInput.worktreePath } + : {}), cols, rows, - ...(input.env ? { env: input.env } : {}), + ...(resolvedInput.env ? { env: resolvedInput.env } : {}), }, "restarted", ); 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/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 5a92a244c52..facd567ff40 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2005,11 +2005,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 }, ); @@ -2358,10 +2355,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 }, ); @@ -2437,11 +2432,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 75fc0ad3235..070490a2185 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"; @@ -571,7 +572,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra { readonly cwd: string; readonly worktreePath: string | null; - readonly runtimeEnv: Record; } >(); if (!project) { @@ -588,10 +588,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, - }), }); } @@ -638,17 +634,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; @@ -665,7 +650,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); @@ -676,9 +661,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. @@ -688,16 +673,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); @@ -708,9 +693,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. @@ -720,11 +705,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, - runtimeEnv, serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, + project, ]); const activateTerminal = useCallback( @@ -783,9 +768,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; @@ -2196,18 +2170,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, @@ -665,11 +667,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; @@ -728,7 +731,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; @@ -777,9 +780,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[]; @@ -835,9 +838,9 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA export default function ThreadTerminalDrawer({ threadRef, threadId, + projectId, cwd, worktreePath, - runtimeEnv, visible = true, height, terminalIds, @@ -993,11 +996,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)` @@ -1226,15 +1228,13 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} @@ -1254,15 +1254,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..e4209ca61fe 100644 --- a/apps/web/src/terminalSessionState.ts +++ b/apps/web/src/terminalSessionState.ts @@ -12,13 +12,10 @@ import { type KnownTerminalSession, type TerminalSessionTarget, type TerminalSessionState, + type TerminalSubscribeMetadataInput, + type TerminalAttachSessionInput, } from "@t3tools/client-runtime"; -import type { - EnvironmentId, - TerminalAttachInput, - TerminalSessionSnapshot, - ThreadId, -} from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { appAtomRegistry } from "./rpc/atomRegistry"; @@ -26,20 +23,19 @@ export const terminalSessionManager = createTerminalSessionManager({ getRegistry: () => appAtomRegistry, }); -export function subscribeTerminalMetadata(input: { - readonly environmentId: EnvironmentId; - readonly client: Parameters[0]["client"]; -}) { +export function subscribeTerminalMetadata( + input: TerminalSubscribeMetadataInput & { + readonly environmentId: EnvironmentId; + }, +) { return terminalSessionManager.subscribeMetadata(input); } -export function attachTerminalSession(input: { - readonly environmentId: EnvironmentId; - readonly client: Parameters[0]["client"]; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: Parameters[0]["onEvent"]; -}) { +export function attachTerminalSession( + input: TerminalAttachSessionInput & { + readonly environmentId: EnvironmentId; + }, +) { return terminalSessionManager.attach({ environmentId: input.environmentId, client: input.client, diff --git a/packages/client-runtime/src/terminalSessionState.ts b/packages/client-runtime/src/terminalSessionState.ts index 668ac343a49..916dc753f41 100644 --- a/packages/client-runtime/src/terminalSessionState.ts +++ b/packages/client-runtime/src/terminalSessionState.ts @@ -88,6 +88,21 @@ export interface TerminalAttachClient { }; } +export type TerminalSubscribeMetadataInput = { + readonly environmentId: EnvironmentId; + readonly client: TerminalMetadataClient; + readonly options?: { readonly onResubscribe?: () => void }; +}; + +export type TerminalAttachSessionInput = { + readonly environmentId: EnvironmentId; + readonly client: TerminalAttachClient; + readonly terminal: TerminalAttachInput; + readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; + readonly onEvent?: (event: TerminalAttachStreamEvent) => void; + readonly options?: { readonly onResubscribe?: () => void }; +}; + export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ buffer: "", status: "closed", 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; }