From a7173178213c167bb29c900e607b876c39ea146e Mon Sep 17 00:00:00 2001 From: Kameron Smith <112618179+nullStack65@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:46:22 -0400 Subject: [PATCH 1/2] fix(desktop): isolate packaged macOS maintenance profiles --- apps/desktop/src/app/DesktopApp.ts | 4 +- .../src/app/DesktopAppIdentity.test.ts | 1 + apps/desktop/src/app/DesktopAppIdentity.ts | 3 + apps/desktop/src/app/DesktopClerk.test.ts | 36 ++- apps/desktop/src/app/DesktopClerk.ts | 45 +-- .../src/app/DesktopEnvironment.test.ts | 28 ++ apps/desktop/src/app/DesktopEnvironment.ts | 27 +- .../src/app/DesktopIsolationProfile.ts | 26 ++ apps/desktop/src/app/DesktopLifecycle.test.ts | 1 + .../src/app/DesktopPreReadyPlatform.ts | 6 +- .../DesktopBackendConfiguration.test.ts | 77 ++++- .../backend/DesktopBackendConfiguration.ts | 60 +++- .../src/backend/DesktopServerExposure.ts | 15 +- apps/desktop/src/electron/ElectronApp.ts | 2 + .../src/electron/ElectronSafeStorage.ts | 92 +----- .../ElectronSafeStorageService.test.ts | 27 ++ .../electron/ElectronSafeStorageService.ts | 103 +++++++ apps/desktop/src/ipc/methods/window.ts | 4 +- apps/desktop/src/main.ts | 8 +- .../preview/BrowserImport/BrowserImport.ts | 39 ++- .../src/shell/DesktopShellEnvironment.ts | 20 +- apps/desktop/src/ssh/DesktopSshEnvironment.ts | 75 +++-- .../DesktopTelemetryPublisher.test.ts | 1 + apps/desktop/src/updates/DesktopUpdates.ts | 3 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/server/src/cloud/CliTokenManager.ts | 24 ++ .../src/cloud/ManagedEndpointRuntime.ts | 7 + apps/server/src/device/DeviceService.ts | 2 + .../src/isolationProfile.integration.test.ts | 88 ++++++ .../src/orchestration/workflowScriptQuery.ts | 4 +- apps/server/src/os-jank.ts | 25 +- apps/server/src/pathExpansion.ts | 25 +- .../server/src/project/AgentSessionScanner.ts | 15 +- .../ProviderInstanceRegistryLive.test.ts | 137 +++++---- .../Layers/ProviderInstanceRegistryLive.ts | 13 + .../PullRequestProviderRegistry.ts | 3 + apps/server/src/relay/AgentAwarenessRelay.ts | 8 + apps/server/src/server.ts | 217 ++++++++------ apps/server/src/sourceControl/ForgejoCli.ts | 80 ++++-- .../src/sourceControl/GitHubCli.test.ts | 35 +++ apps/server/src/sourceControl/GitHubCli.ts | 8 + .../SourceControlDiscovery.test.ts | 22 ++ .../sourceControl/SourceControlDiscovery.ts | 25 ++ .../SourceControlProviderRegistry.ts | 13 + apps/server/src/telemetry/AnalyticsService.ts | 7 + apps/server/src/telemetry/Identify.ts | 4 +- apps/server/src/usage/UsageService.ts | 5 +- apps/server/src/ws.ts | 31 +- packages/shared/package.json | 4 + packages/shared/src/isolationRoot.test.ts | 159 +++++++++++ packages/shared/src/isolationRoot.ts | 269 ++++++++++++++++++ 51 files changed, 1535 insertions(+), 399 deletions(-) create mode 100644 apps/desktop/src/app/DesktopIsolationProfile.ts create mode 100644 apps/desktop/src/electron/ElectronSafeStorageService.test.ts create mode 100644 apps/desktop/src/electron/ElectronSafeStorageService.ts create mode 100644 apps/server/src/isolationProfile.integration.test.ts create mode 100644 packages/shared/src/isolationRoot.test.ts create mode 100644 packages/shared/src/isolationRoot.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 365363881f5b..71feb4584191 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -325,7 +325,9 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; - yield* DesktopRemoteUpdates.listen; + if (environment.isolationProfile === undefined) { + yield* DesktopRemoteUpdates.listen; + } yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index ce0c7d013a99..030900ce1a6a 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -43,6 +43,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, exit: () => Effect.void, relaunch: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 36b3079ad2c4..b2381539c7f6 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -47,6 +47,9 @@ const normalizeCommitHash = (value: string): Option.Option => { export const resolveUserDataPath = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.isolationProfile !== undefined) { + return environment.userDataDirectory; + } const fileSystem = yield* FileSystem.FileSystem; const legacyPath = environment.path.join( environment.appDataDirectory, diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 1641149e9e35..f5bca857082b 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -29,17 +29,29 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { +const makeDesktopClerkLayer = ( + isDevelopment = true, + events: string[] = [], + isolationProfile?: NonNullable< + DesktopEnvironment.DesktopEnvironment["Service"]["isolationProfile"] + >, +) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", + userDataDirectory: isolationProfile?.userDataDirectory ?? "/tmp/app-data/t3code-dev", + isolationProfile, userDataDirName: isDevelopment ? "t3code-dev" : "t3code", legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); const electronApp = { + requestSingleInstanceLock: Effect.sync(() => { + events.push("requestSingleInstanceLock"); + return true; + }), setPath: (name: string, value: string) => Effect.sync(() => { events.push(`setPath:${name}:${value}`); @@ -63,6 +75,28 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); + it.effect("uses the isolated Electron lock without creating a Clerk bridge", () => { + const profile = { + root: "/tmp/t3-profile", + homeDirectory: "/tmp/t3-profile", + appDataDirectory: "/tmp/t3-profile/Library/Application Support", + userDataDirectory: "/tmp/t3-profile/userData", + sessionDataDirectory: "/tmp/t3-profile/sessionData", + t3Home: "/tmp/t3-profile/.t3", + }; + const events: string[] = []; + + return Effect.gen(function* () { + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(true, events, profile))); + assert.deepEqual(events, [ + `setPath:userData:${profile.userDataDirectory}`, + "requestSingleInstanceLock", + ]); + assert.equal(createClerkBridgeMock.mock.calls.length, 0); + assert.equal(storageMock.mock.calls.length, 0); + }); + }); + it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); const events: string[] = []; diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 072a1871d986..773e110ab279 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -97,27 +97,30 @@ export const make = Effect.gen(function* () { const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); - const bridge = yield* Effect.acquireRelease( - Effect.try({ - try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), - catch: (cause) => - new DesktopClerkBridgeInitializationError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, - }), - }), - (bridge) => - Effect.try({ - try: () => bridge.cleanup(), - catch: (cause) => - new DesktopClerkBridgeCleanupError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, + const isPrimaryInstance = + environment.isolationProfile !== undefined + ? yield* electronApp.requestSingleInstanceLock + : yield* Effect.acquireRelease( + Effect.try({ + try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + catch: (cause) => + new DesktopClerkBridgeInitializationError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), }), - }).pipe(Effect.orDie), - ); + (bridge) => + Effect.try({ + try: () => bridge.cleanup(), + catch: (cause) => + new DesktopClerkBridgeCleanupError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }).pipe(Effect.orDie), + ).pipe(Effect.map((bridge) => bridge.isPrimaryInstance)); return DesktopClerk.of({ configure: Effect.gen(function* () { @@ -131,7 +134,7 @@ export const make = Effect.gen(function* () { // forwarded to the running app. In a secondary instance the bridge has // already begun quitting the app; app.quit() is asynchronous, so stop // bootstrap here before whenReady can fire. - if (!bridge.isPrimaryInstance) { + if (!isPrimaryInstance) { yield* electronApp.quit; return yield* Effect.interrupt; } diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index b5aabf253d0f..4f0be13334f6 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -120,6 +120,34 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("drops inherited OTLP destinations and headers in an isolation profile", () => + Effect.gen(function* () { + const root = "/tmp/t3-profile"; + const environment = yield* makeEnvironment( + { + isolationProfile: { + root, + homeDirectory: root, + appDataDirectory: `${root}/Library/Application Support`, + userDataDirectory: `${root}/userData`, + sessionDataDirectory: `${root}/sessionData`, + t3Home: `${root}/.t3`, + }, + }, + { + T3CODE_OTLP_TRACES_URL: "https://outside.invalid/traces", + T3CODE_OTLP_HEADERS: "authorization=Bearer%20account-token", + }, + ); + + assert.deepEqual(environment.otlpTracesUrl, Option.none()); + assert.deepEqual(environment.otlpMetricsUrl, Option.none()); + assert.deepEqual(environment.otlpLogsUrl, Option.none()); + assert.deepEqual(environment.otlpHeaders, Option.none()); + assert.equal(environment.baseDir, `${root}/.t3`); + }), + ); + it.effect("uses the packaged Windows server sidecar as the backend root", () => Effect.gen(function* () { const environment = yield* makeEnvironment({ diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index d6924b08acec..889e074d7c41 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import type { IsolationProfilePaths } from "@t3tools/shared/isolationRoot"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; @@ -28,6 +29,7 @@ export interface MakeDesktopEnvironmentInput { readonly isPackaged: boolean; readonly resourcesPath: string; readonly runningUnderArm64Translation: boolean; + readonly isolationProfile?: IsolationProfilePaths; } export class DesktopEnvironment extends Context.Service< @@ -43,6 +45,8 @@ export class DesktopEnvironment extends Context.Service< readonly appPath: string; readonly resourcesPath: string; readonly homeDirectory: string; + readonly isolationProfile: IsolationProfilePaths | undefined; + readonly userDataDirectory: string; readonly appDataDirectory: string; readonly baseDir: string; readonly stateDir: string; @@ -154,20 +158,24 @@ const make = Effect.fn("desktop.environment.make")(function* ( const path = yield* Path.Path; const config = yield* DesktopConfig.DesktopConfig; const homeDirectory = input.homeDirectory; + const isolationProfile = input.isolationProfile; const devServerUrl = config.devServerUrl; const isDevelopment = Option.isSome(devServerUrl); const appDataDirectory = - input.platform === "win32" + isolationProfile?.appDataDirectory ?? + (input.platform === "win32" ? Option.getOrElse(config.appDataDirectory, () => path.join(homeDirectory, "AppData", "Roaming"), ) : input.platform === "darwin" ? path.join(homeDirectory, "Library", "Application Support") - : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); + : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"))); + const effectiveT3Home = + isolationProfile === undefined ? config.t3Home : Option.some(isolationProfile.t3Home); const baseDir = resolveDesktopBaseDir({ homeDirectory, joinPath: path.join, - t3Home: config.t3Home, + t3Home: effectiveT3Home, }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; @@ -184,7 +192,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( baseDir, isDevelopment, joinPath: path.join, - t3Home: config.t3Home, + t3Home: effectiveT3Home, }); const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; @@ -205,6 +213,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( appPath: input.appPath, resourcesPath, homeDirectory, + isolationProfile, + userDataDirectory: + isolationProfile?.userDataDirectory ?? path.join(appDataDirectory, userDataDirName), appDataDirectory, baseDir, stateDir, @@ -228,11 +239,11 @@ const make = Effect.fn("desktop.environment.make")(function* ( devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, configuredBackendPort: config.configuredBackendPort, commitHashOverride: config.commitHashOverride, - otlpTracesUrl: config.otlpTracesUrl, - otlpMetricsUrl: config.otlpMetricsUrl, - otlpLogsUrl: config.otlpLogsUrl, + otlpTracesUrl: isolationProfile === undefined ? config.otlpTracesUrl : Option.none(), + otlpMetricsUrl: isolationProfile === undefined ? config.otlpMetricsUrl : Option.none(), + otlpLogsUrl: isolationProfile === undefined ? config.otlpLogsUrl : Option.none(), otlpExportIntervalMs: config.otlpExportIntervalMs, - otlpHeaders: config.otlpHeaders, + otlpHeaders: isolationProfile === undefined ? config.otlpHeaders : Option.none(), otlpProtocol: config.otlpProtocol, branding, displayName, diff --git a/apps/desktop/src/app/DesktopIsolationProfile.ts b/apps/desktop/src/app/DesktopIsolationProfile.ts new file mode 100644 index 000000000000..4855f1150c27 --- /dev/null +++ b/apps/desktop/src/app/DesktopIsolationProfile.ts @@ -0,0 +1,26 @@ +import * as Electron from "electron"; +import * as NodeOS from "node:os"; + +import { + ISOLATION_ROOT_ENV, + resolveDesktopIsolationProfile, + setElectronIsolationPaths, +} from "@t3tools/shared/isolationRoot"; + +export const isolationProfile = resolveDesktopIsolationProfile({ + argv: process.argv, + isPackaged: Electron.app.isPackaged, + // oxlint-disable-next-line t3code/no-global-process-runtime -- The synchronous pre-ready bootstrap must reject unsupported OS targets before Effect services can start. + platform: NodeOS.platform(), +}); + +if (isolationProfile !== undefined) { + // This app-owned marker is propagated only to owned child processes. It does + // not rewrite HOME or CODEX_HOME and is always resolved from our CLI option. + process.env[ISOLATION_ROOT_ENV] = isolationProfile.root; + // Electron's session and single-instance storage paths are selected during + // startup, before the Clerk bridge and BrowserWindow can initialize them. + setElectronIsolationPaths((name, value) => Electron.app.setPath(name, value), isolationProfile); +} + +export const homeDirectory = (): string => isolationProfile?.homeDirectory ?? NodeOS.homedir(); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 33c74f5a8b9b..4c64565d0b70 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -35,6 +35,7 @@ function makeElectronAppLayer( name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), quit, exit: () => Effect.void, relaunch: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index c07334f33bbb..168a84c6a3d6 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -1,6 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available. import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -10,6 +9,7 @@ import * as Electron from "electron"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; +import * as DesktopIsolationProfile from "./DesktopIsolationProfile.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -35,7 +35,7 @@ export const resolveEarlyLinuxElectronOptionsFromProcess = (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ env: process.env, - homeDirectory: NodeOS.homedir(), + homeDirectory: DesktopIsolationProfile.homeDirectory(), joinPath: NodePath.posix.join, readFileString: (path) => NodeFS.readFileSync(path, "utf8"), }); @@ -64,7 +64,7 @@ export const make = Effect.gen(function* () { try { const applicationsDir = NodePath.posix.join( process.env.XDG_DATA_HOME?.trim() || - NodePath.posix.join(NodeOS.homedir(), ".local", "share"), + NodePath.posix.join(DesktopIsolationProfile.homeDirectory(), ".local", "share"), "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 41eeedc668b6..bb9d9fa03da9 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -61,6 +61,9 @@ function makeEnvironmentLayer( readonly resourcesPath?: string; readonly appVersion?: string; readonly processArch?: NodeJS.Architecture; + readonly isolationProfile?: NonNullable< + DesktopEnvironment.MakeDesktopEnvironmentInput["isolationProfile"] + >; readonly otlpTracesUrl?: string; readonly otlpMetricsUrl?: string; readonly otlpLogsUrl?: string; @@ -76,6 +79,9 @@ function makeEnvironmentLayer( isPackaged: options?.isPackaged ?? true, resourcesPath: options?.resourcesPath ?? "/missing/resources", runningUnderArm64Translation: false, + ...(options?.isolationProfile === undefined + ? {} + : { isolationProfile: options.isolationProfile }), }).pipe( Layer.provide( Layer.mergeAll( @@ -112,6 +118,7 @@ const withHarness = ( | FileSystem.FileSystem | DesktopBackendConfiguration.DesktopBackendConfiguration >, + environmentOptions?: Parameters[1], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -126,7 +133,7 @@ const withHarness = ( Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(makeEnvironmentLayer(baseDir)), + Layer.provideMerge(makeEnvironmentLayer(baseDir, environmentOptions)), ), ), ); @@ -256,6 +263,74 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect( + "pins a profile backend home and strips network telemetry despite hostile inherited settings", + () => + withHarness( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const inheritedNames = [ + "HOME", + "CODEX_HOME", + "T3CODE_HOME", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "T3CODE_OTLP_HEADERS", + ] as const; + const previous = Object.fromEntries( + inheritedNames.map((name) => [name, process.env[name]]), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const name of inheritedNames) restoreEnv(name, previous[name]); + }), + ); + process.env.HOME = "/Users/account"; + process.env.CODEX_HOME = "/Users/account/.codex"; + process.env.T3CODE_HOME = "/Users/account/.t3"; + process.env.OPENAI_API_KEY = "account-openai-token"; + process.env.ANTHROPIC_API_KEY = "account-claude-token"; + process.env.T3CODE_OTLP_HEADERS = "authorization=account-token"; + const config = yield* configuration.resolvePrimary; + const restartedConfig = yield* configuration.resolvePrimary; + + assert.equal(config.extendEnv, false); + assert.equal(config.env.T3CODE_ISOLATION_ROOT, environment.isolationProfile?.root); + assert.equal(config.env.T3CODE_HOME, environment.baseDir); + assert.isUndefined(config.env.HOME); + assert.isUndefined(config.env.CODEX_HOME); + assert.isUndefined(config.env.OPENAI_API_KEY); + assert.isUndefined(config.env.ANTHROPIC_API_KEY); + assert.isUndefined(config.env.T3CODE_OTLP_HEADERS); + assert.equal(config.bootstrap.t3Home, environment.baseDir); + assert.equal(config.bootstrap.host, "127.0.0.1"); + assert.equal(config.bootstrap.tailscaleServeEnabled, false); + assert.isUndefined(config.bootstrap.otlpTracesUrl); + assert.isUndefined(config.bootstrap.otlpMetricsUrl); + assert.isUndefined(config.bootstrap.otlpLogsUrl); + assert.equal(restartedConfig.env.T3CODE_ISOLATION_ROOT, config.env.T3CODE_ISOLATION_ROOT); + assert.equal(restartedConfig.env.T3CODE_HOME, config.env.T3CODE_HOME); + assert.equal(restartedConfig.bootstrap.t3Home, config.bootstrap.t3Home); + assert.equal(restartedConfig.bootstrap.host, "127.0.0.1"); + assert.equal(restartedConfig.bootstrap.tailscaleServeEnabled, false); + assert.isUndefined(restartedConfig.bootstrap.otlpTracesUrl); + assert.isUndefined(restartedConfig.bootstrap.otlpMetricsUrl); + assert.isUndefined(restartedConfig.bootstrap.otlpLogsUrl); + }), + { + isolationProfile: { + root: "/tmp/t3-profile", + homeDirectory: "/tmp/t3-profile", + appDataDirectory: "/tmp/t3-profile/Library/Application Support", + userDataDirectory: "/tmp/t3-profile/userData", + sessionDataDirectory: "/tmp/t3-profile/sessionData", + t3Home: "/tmp/t3-profile/.t3", + }, + }, + ), + ); + it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 9e809667ac42..48d0b954a83d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { ISOLATION_ROOT_ENV } from "@t3tools/shared/isolationRoot"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -87,6 +88,12 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_DESKTOP_HTTPS_ENDPOINTS", "T3CODE_TAILSCALE_SERVE", "T3CODE_TAILSCALE_SERVE_PORT", + "T3CODE_OTLP_TRACES_URL", + "T3CODE_OTLP_METRICS_URL", + "T3CODE_OTLP_LOGS_URL", + "T3CODE_OTLP_HEADERS", + "T3CODE_OTLP_PROTOCOL", + ISOLATION_ROOT_ENV, ] as const; // Env vars that the WSL backend needs but Windows process.env won't forward @@ -107,8 +114,27 @@ const nodeBinDirOf = (nodePath: string): string => { return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin"; }; -const backendChildEnvPatch = (): Record => - Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); +const backendChildEnvPatch = ( + isolationRoot?: string, + isolationT3Home?: string, +): Record => ({ + ...Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])), + [ISOLATION_ROOT_ENV]: isolationRoot, + ...(isolationRoot === undefined ? {} : { T3CODE_HOME: isolationT3Home }), +}); + +const isolatedChildEnvironment = (isolationRoot: string, isolationT3Home: string) => { + const inherited: Record = {}; + for (const name of ["PATH", "TMPDIR", "TMP", "TEMP", "LANG", "TERM", "NO_COLOR"] as const) { + const value = process.env[name]; + if (value !== undefined) inherited[name] = value; + } + return { + ...inherited, + ...backendChildEnvPatch(isolationRoot, isolationT3Home), + ELECTRON_RUN_AS_NODE: "1", + }; +}; const getWslEnvEntryName = (entry: string): string => { const slashIndex = entry.indexOf("/"); @@ -526,23 +552,36 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv const environment = yield* DesktopEnvironment.DesktopEnvironment; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const backendExposure = yield* serverExposure.backendConfig; + const isolatedExposure = + environment.isolationProfile === undefined + ? backendExposure + : { + ...backendExposure, + bindHost: "127.0.0.1", + httpBaseUrl: new URL(`http://127.0.0.1:${String(backendExposure.port)}`), + tailscaleServeEnabled: false, + }; const bootstrap = { mode: "desktop" as const, noBrowser: true, port: backendExposure.port, t3Home: environment.baseDir, - host: backendExposure.bindHost, + host: isolatedExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, - tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, - tailscaleServePort: backendExposure.tailscaleServePort, + tailscaleServeEnabled: isolatedExposure.tailscaleServeEnabled, + tailscaleServePort: isolatedExposure.tailscaleServePort, desktopTelemetryFd: 4, desktopTelemetryControlFd: 5, ...Option.match(input.resourceMonitorPath, { onNone: () => ({}), onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), }), - ...buildObservabilityFragment(input.observabilitySettings), + ...buildObservabilityFragment( + environment.isolationProfile === undefined + ? input.observabilitySettings + : emptyBackendObservabilitySettings, + ), }; return { @@ -551,14 +590,15 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv entryPath: environment.backendEntryPath, cwd: environment.backendCwd, env: { - ...backendChildEnvPatch(), - ELECTRON_RUN_AS_NODE: "1", + ...(environment.isolationProfile === undefined + ? { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1" } + : isolatedChildEnvironment(environment.isolationProfile.root, environment.baseDir)), }, // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). - extendEnv: true, + extendEnv: environment.isolationProfile === undefined, bootstrap, bootstrapDelivery: "fd3", - httpBaseUrl: backendExposure.httpBaseUrl, + httpBaseUrl: isolatedExposure.httpBaseUrl, captureOutput: true, preflightFailure: Option.none(), } satisfies DesktopBackendManager.DesktopBackendStartConfig; diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index a04f4ecbc100..0bc7ac2369da 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -19,6 +19,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -443,8 +444,10 @@ export const make = Effect.gen(function* () { const settings = yield* desktopSettings.get; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ - requestedMode: settings.serverExposureMode, - settings, + requestedMode: isIsolationProfileActive() ? "local-only" : settings.serverExposureMode, + settings: isIsolationProfileActive() + ? { ...settings, serverExposureMode: "local-only", tailscaleServeEnabled: false } + : settings, port, networkInterfaces: currentNetworkInterfaces, advertisedHostOverride: config.desktopLanHostOverride, @@ -459,6 +462,9 @@ export const make = Effect.gen(function* () { ) { yield* Effect.annotateCurrentSpan({ mode }); const previous = yield* Ref.get(stateRef); + if (isIsolationProfileActive() && mode !== "local-only") { + return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port }); + } const currentSettings = yield* desktopSettings.get; const nextSettings = { ...currentSettings, @@ -541,7 +547,10 @@ export const make = Effect.gen(function* () { // Don't spawn the Tailscale CLI when the user hasn't opted into any // network exposure. The spawn itself triggers a macOS "Other apps" // TCC prompt on Mac App Store Tailscale builds. - if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { + if ( + isIsolationProfileActive() || + (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) + ) { return coreEndpoints; } diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index f75ea51552e7..b4a2c22987af 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -58,6 +58,7 @@ export class ElectronApp extends Context.Service< name: Parameters[0], path: string, ) => Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; readonly setName: (name: string) => Effect.Effect; readonly setAboutPanelOptions: ( options: Electron.AboutPanelOptionsOptions, @@ -152,6 +153,7 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.app.setPath(name, path); }), + requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), setName: (name) => Effect.sync(() => { Electron.app.setName(name); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 57ae5d6dcb69..69204e630cdf 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,100 +1,18 @@ -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 * as Schema from "effect/Schema"; import * as Electron from "electron"; +import * as DesktopIsolationProfile from "../app/DesktopIsolationProfile.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { ElectronSafeStorage, makeService } from "./ElectronSafeStorageService.ts"; -const electronSafeStorageErrorFields = { - cause: Schema.Defect(), -}; - -export class ElectronSafeStorageAvailabilityError extends Schema.TaggedError()( - "ElectronSafeStorageAvailabilityError", - { - ...electronSafeStorageErrorFields, - }, -) { - override get message(): string { - return "Electron safe storage failed to check encryption availability."; - } -} - -export class ElectronSafeStorageEncryptError extends Schema.TaggedError()( - "ElectronSafeStorageEncryptError", - { - ...electronSafeStorageErrorFields, - }, -) { - override get message(): string { - return "Electron safe storage failed to encrypt a string."; - } -} - -export class ElectronSafeStorageDecryptError extends Schema.TaggedError()( - "ElectronSafeStorageDecryptError", - { - ...electronSafeStorageErrorFields, - }, -) { - override get message(): string { - return "Electron safe storage failed to decrypt a string."; - } -} - -export const ElectronSafeStorageError = Schema.Union([ - ElectronSafeStorageAvailabilityError, - ElectronSafeStorageEncryptError, - ElectronSafeStorageDecryptError, -]); -export type ElectronSafeStorageError = typeof ElectronSafeStorageError.Type; - -export class ElectronSafeStorage extends Context.Service< - ElectronSafeStorage, - { - readonly isEncryptionAvailable: Effect.Effect; - readonly encryptString: ( - value: string, - ) => Effect.Effect; - readonly decryptString: ( - value: Uint8Array, - ) => Effect.Effect; - readonly selectedStorageBackend: Effect.Effect>; - } ->()("@t3tools/desktop/electron/ElectronSafeStorage") {} +export * from "./ElectronSafeStorageService.ts"; /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; - - return ElectronSafeStorage.of({ - isEncryptionAvailable: Effect.try({ - try: () => Electron.safeStorage.isEncryptionAvailable(), - catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), - }), - encryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.encryptString(value), - catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), - }), - decryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.decryptString(Buffer.from(value)), - catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), - }), - selectedStorageBackend: Effect.sync(() => { - if (platform !== "linux") { - return Option.none(); - } - try { - return Option.fromNullishOr(Electron.safeStorage.getSelectedStorageBackend()); - } catch { - return Option.none(); - } - }), - }); + const isIsolationProfile = DesktopIsolationProfile.isolationProfile !== undefined; + return makeService(platform, isIsolationProfile, Electron.safeStorage); }); export const layer = Layer.effect(ElectronSafeStorage, make); diff --git a/apps/desktop/src/electron/ElectronSafeStorageService.test.ts b/apps/desktop/src/electron/ElectronSafeStorageService.test.ts new file mode 100644 index 000000000000..4fe29ff63239 --- /dev/null +++ b/apps/desktop/src/electron/ElectronSafeStorageService.test.ts @@ -0,0 +1,27 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as Option from "effect/Option"; +import * as ElectronSafeStorageService from "./ElectronSafeStorageService.ts"; + +it.effect("does not call native safe storage in an isolation profile", () => { + let nativeCalls = 0; + const native = () => { + nativeCalls += 1; + throw new Error("native safe storage must not be called"); + }; + const service = ElectronSafeStorageService.makeService("darwin", true, { + isEncryptionAvailable: native, + encryptString: native, + decryptString: native, + getSelectedStorageBackend: native, + }); + + return Effect.gen(function* () { + assert.isFalse(yield* service.isEncryptionAvailable); + assert.isTrue(Option.isNone(yield* service.selectedStorageBackend)); + assert.isTrue(Result.isFailure(yield* Effect.result(service.encryptString("synthetic")))); + assert.isTrue(Result.isFailure(yield* Effect.result(service.decryptString(new Uint8Array())))); + assert.equal(nativeCalls, 0); + }); +}); diff --git a/apps/desktop/src/electron/ElectronSafeStorageService.ts b/apps/desktop/src/electron/ElectronSafeStorageService.ts new file mode 100644 index 000000000000..de3e084b69c9 --- /dev/null +++ b/apps/desktop/src/electron/ElectronSafeStorageService.ts @@ -0,0 +1,103 @@ +// @effect-diagnostics deterministicKeys:off -- Preserve the existing public service identifier while isolating pure logic from Electron imports. +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const errorFields = { cause: Schema.Defect() }; + +export class ElectronSafeStorageAvailabilityError extends Schema.TaggedError()( + "ElectronSafeStorageAvailabilityError", + errorFields, +) { + override get message(): string { + return "Electron safe storage failed to check encryption availability."; + } +} + +export class ElectronSafeStorageEncryptError extends Schema.TaggedError()( + "ElectronSafeStorageEncryptError", + errorFields, +) { + override get message(): string { + return "Electron safe storage failed to encrypt a string."; + } +} + +export class ElectronSafeStorageDecryptError extends Schema.TaggedError()( + "ElectronSafeStorageDecryptError", + errorFields, +) { + override get message(): string { + return "Electron safe storage failed to decrypt a string."; + } +} + +export const ElectronSafeStorageError = Schema.Union([ + ElectronSafeStorageAvailabilityError, + ElectronSafeStorageEncryptError, + ElectronSafeStorageDecryptError, +]); +export type ElectronSafeStorageError = typeof ElectronSafeStorageError.Type; + +export interface ElectronSafeStorageAdapter { + isEncryptionAvailable(): boolean; + encryptString(value: string): Uint8Array; + decryptString(value: Uint8Array): string; + getSelectedStorageBackend(): string | undefined; +} + +export class ElectronSafeStorage extends Context.Service< + ElectronSafeStorage, + { + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; + readonly selectedStorageBackend: Effect.Effect>; + } +>()("@t3tools/desktop/electron/ElectronSafeStorage") {} + +export function makeService( + platform: string, + isIsolationProfile: boolean, + safeStorage: ElectronSafeStorageAdapter, +): ElectronSafeStorage["Service"] { + return ElectronSafeStorage.of({ + isEncryptionAvailable: isIsolationProfile + ? Effect.succeed(false) + : Effect.try({ + try: () => safeStorage.isEncryptionAvailable(), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), + }), + encryptString: (value) => + isIsolationProfile + ? Effect.fail( + new ElectronSafeStorageEncryptError({ cause: new Error("isolation profile") }), + ) + : Effect.try({ + try: () => safeStorage.encryptString(value), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + }), + decryptString: (value) => + isIsolationProfile + ? Effect.fail( + new ElectronSafeStorageDecryptError({ cause: new Error("isolation profile") }), + ) + : Effect.try({ + try: () => safeStorage.decryptString(value), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + }), + selectedStorageBackend: Effect.sync(() => { + if (isIsolationProfile || platform !== "linux") return Option.none(); + try { + return Option.fromNullishOr(safeStorage.getSelectedStorageBackend()); + } catch { + return Option.none(); + } + }), + }); +} diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 75f34ab07ab1..9ccc0d801941 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -14,9 +14,9 @@ import { type PickedThemeFile, } from "@t3tools/contracts"; import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; +import { effectiveHomeDirectory } from "@t3tools/shared/isolationRoot"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/hostProcess"; -import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; @@ -389,7 +389,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ // The VS Code extensions directory is the same dotfolder on Windows, // macOS, and Linux; when it is missing the picker opens wherever the // platform would by default. - const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions"); + const extensionsDir = path.join(effectiveHomeDirectory(), ".vscode", "extensions"); const defaultPath = yield* fileSystem .exists(extensionsDir) .pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0d626a51955d..4c888d34609d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,4 @@ +import * as DesktopIsolationProfile from "./app/DesktopIsolationProfile.ts"; import * as MacPermissions from "./permissions/MacPermissions.ts"; for (const stream of [process.stdout, process.stderr]) { stream.on("error", (err: NodeJS.ErrnoException) => { @@ -8,7 +9,6 @@ for (const stream of [process.stdout, process.stderr]) { import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -77,7 +77,10 @@ const desktopEnvironmentLayer = Layer.unwrap( const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: NodeOS.homedir(), + homeDirectory: DesktopIsolationProfile.homeDirectory(), + ...(DesktopIsolationProfile.isolationProfile === undefined + ? {} + : { isolationProfile: DesktopIsolationProfile.isolationProfile }), platform, processArch, ...metadata, @@ -106,6 +109,7 @@ const desktopSshEnvironmentLayer = Layer.unwrap( const environment = yield* DesktopEnvironment.DesktopEnvironment; return DesktopSshEnvironment.layer({ resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), + isolationProfile: environment.isolationProfile !== undefined, }); }), ); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 9b867adb48df..a72459e40fc8 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -11,6 +11,7 @@ import type { BrowserImportUnavailableReason, } from "@t3tools/contracts"; import { BrowserImportFailureReason } from "@t3tools/contracts"; +import { isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import * as Context from "effect/Context"; import type { Session } from "electron"; import * as Effect from "effect/Effect"; @@ -178,21 +179,23 @@ export const make = Effect.gen(function* BrowserImportMake() { >(); const pathContext = yield* sourcePathContext; - const listSources: Effect.Effect> = Effect.forEach( - BROWSER_IMPORT_SOURCES, - Effect.fnUntraced(function* (definition) { - const unavailable = yield* unavailableReason(definition, pathContext); - return { - id: definition.id, - name: definition.name, - // Listing profiles touches the source's own files, so skip it when the - // source is unusable anyway. - profiles: - unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], - ...(unavailable === undefined ? {} : { unavailable }), - } satisfies BrowserImportSource; - }), - ).pipe(Effect.provide(platformServices)); + const listSources: Effect.Effect> = isIsolationProfileActive() + ? Effect.succeed([]) + : Effect.forEach( + BROWSER_IMPORT_SOURCES, + Effect.fnUntraced(function* (definition) { + const unavailable = yield* unavailableReason(definition, pathContext); + return { + id: definition.id, + name: definition.name, + // Listing profiles touches the source's own files, so skip it when the + // source is unusable anyway. + profiles: + unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], + ...(unavailable === undefined ? {} : { unavailable }), + } satisfies BrowserImportSource; + }), + ).pipe(Effect.provide(platformServices)); const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { readonly input: BrowserImportInput; @@ -200,6 +203,12 @@ export const make = Effect.gen(function* BrowserImportMake() { readonly persistent: boolean; readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; }) { + if (isIsolationProfileActive()) { + return yield* new BrowserImportFailedError({ + sourceId: input.input.sourceId, + reason: "readFailed", + }); + } const definition = BROWSER_IMPORT_SOURCES.find( (candidate) => candidate.id === input.input.sourceId, ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 704b061db363..37620f8a8086 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -515,15 +515,17 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = - installShellEnvironment({ - env: process.env, - platform: environment.platform, - userShell: Option.none(), - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), - ); + environment.isolationProfile !== undefined + ? Effect.void + : installShellEnvironment({ + env: process.env, + platform: environment.platform, + userShell: Option.none(), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + ); return DesktopShellEnvironment.of({ installIntoProcess }); }); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index b5e724084a21..1401ffc88309 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -66,6 +66,7 @@ export class DesktopSshEnvironment extends Context.Service< export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliRunner?: Effect.Effect; + readonly isolationProfile?: boolean; } function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { @@ -124,44 +125,66 @@ const makePasswordPrompt = ( }); /** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const manager = yield* SshTunnel.SshEnvironmentManager; - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const runtimeContext = yield* Effect.context(); - const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); +const make = (isolationProfile: boolean) => + Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const runtimeContext = yield* Effect.context(); + const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); - return DesktopSshEnvironment.of({ - discoverHosts: (input) => - discoverDesktopSshHostsEffect(input).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.discoverHosts"), - ), - resolveHost: (alias) => - resolveSshTarget(alias.trim()).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.resolveHost"), - ), - ensureEnvironment: (target, ensureOptions) => - manager - .ensureEnvironment(target, ensureOptions) - .pipe( + return DesktopSshEnvironment.of({ + discoverHosts: (input) => + (isolationProfile + ? Effect.fail( + new SshHostDiscoveryError({ + message: "SSH discovery is disabled in the isolation profile.", + cause: new Error("isolation profile"), + }), + ) + : discoverDesktopSshHostsEffect(input) + ).pipe(Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.discoverHosts")), + resolveHost: (alias) => + (isolationProfile + ? Effect.fail( + new SshInvalidTargetError({ + message: "SSH is disabled in the isolation profile.", + }), + ) + : resolveSshTarget(alias.trim()) + ).pipe(Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.resolveHost")), + ensureEnvironment: (target, ensureOptions) => + (isolationProfile + ? Effect.fail( + new SshLaunchError({ + message: "SSH launches are disabled in the isolation profile.", + stdout: "", + }), + ) + : manager.ensureEnvironment(target, ensureOptions) + ).pipe( Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.ensureEnvironment"), ), - disconnectEnvironment: (target) => - manager - .disconnectEnvironment(target) - .pipe( + disconnectEnvironment: (target) => + (isolationProfile + ? Effect.fail( + new SshLaunchError({ + message: "SSH disconnects are disabled in the isolation profile.", + stdout: "", + }), + ) + : manager.disconnectEnvironment(target) + ).pipe( Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), Effect.withSpan("desktop.ssh.disconnectEnvironment"), ), + }); }); -}); export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => - Layer.effect(DesktopSshEnvironment, make).pipe( + Layer.effect(DesktopSshEnvironment, make(options.isolationProfile === true)).pipe( Layer.provide( SshTunnel.SshEnvironmentManager.layer( options.resolveCliRunner === undefined diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index cef90b29874b..88bf9115bce2 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -28,6 +28,7 @@ function makeElectronAppLayer( name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, exit: () => Effect.void, relaunch: () => Effect.void, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index c35b52e8343d..8d878ac6e7d4 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -336,6 +336,9 @@ export const make = Effect.gen(function* () { ); const resolveDisabledReason = Effect.gen(function* () { + if (environment.isolationProfile !== undefined) { + return Option.some("Updates are disabled in the isolation profile."); + } const hasFeedConfig = yield* hasUpdateFeedConfig; return Option.fromNullishOr( getAutoUpdateDisabledReason({ diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 5f5cbaa1d7fc..2afa89291413 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -33,6 +33,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { name: Effect.succeed("T3 Code"), systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, + requestSingleInstanceLock: Effect.succeed(true), quit: Effect.void, exit: () => Effect.void, relaunch: () => Effect.void, diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 4172578d45f3..f11dd5cbb79d 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -582,4 +582,28 @@ export const make = Effect.gen(function* () { return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear }); }); +export const layerDisabled = Layer.succeed( + CloudCliTokenManager, + CloudCliTokenManager.of({ + get: Effect.fail( + new CloudCliAuthorizationError({ + cause: new Error("Cloud authentication is disabled in the isolation profile."), + }), + ), + getExisting: Effect.succeed(Option.none()), + hasCredential: Effect.succeed(false), + store: () => + Effect.fail( + new CloudCliAuthorizationError({ + cause: new Error("Cloud authentication is disabled in the isolation profile."), + }), + ), + clear: Effect.fail( + new CloudCliAuthorizationError({ + cause: new Error("Cloud authentication is disabled in the isolation profile."), + }), + ), + }), +); + export const layer = Layer.effect(CloudCliTokenManager, make); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index cc657bdebf1b..38afe3c85072 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -371,4 +371,11 @@ export const make = Effect.gen(function* () { return runtime; }); +export const layerDisabled = Layer.succeed( + CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + }), +); + export const layer = Layer.effect(CloudManagedEndpointRuntime, make); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 2435fbca34ab..c611136d8f72 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -1165,6 +1165,8 @@ export const make = Effect.gen(function* () { }; }); +export const layerDisabled = Layer.effect(DeviceService, makeWithHosts(new Map())); + export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer)); /** State stream for WS subscribers: current snapshot first, then every change. */ diff --git a/apps/server/src/isolationProfile.integration.test.ts b/apps/server/src/isolationProfile.integration.test.ts new file mode 100644 index 000000000000..7090e87d5ae3 --- /dev/null +++ b/apps/server/src/isolationProfile.integration.test.ts @@ -0,0 +1,88 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The isolation boundary test owns a private temporary profile marker. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ServerSettings from "./serverSettings.ts"; +import { ISOLATION_ROOT_ENV, prepareIsolationProfile } from "@t3tools/shared/isolationRoot"; + +it.live( + "blocks cloud credentials, relay startup, and awareness publishing in a claimed profile", + () => { + const parent = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-server-isolation-test-")); + const profileRoot = NodePath.join(parent, "profile"); + prepareIsolationProfile(profileRoot, [NodePath.join(parent, "account-home")]); + const previousRoot = process.env[ISOLATION_ROOT_ENV]; + process.env[ISOLATION_ROOT_ENV] = profileRoot; + + return Effect.gen(function* () { + const [tokenModule, runtimeModule, awarenessModule, deviceModule] = yield* Effect.promise( + () => + Promise.all([ + import("./cloud/CliTokenManager.ts"), + import("./cloud/ManagedEndpointRuntime.ts"), + import("./relay/AgentAwarenessRelay.ts"), + import("./device/DeviceService.ts"), + ]), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const tokens = yield* tokenModule.CloudCliTokenManager; + assert.isTrue(Option.isNone(yield* tokens.getExisting)); + assert.isFalse(yield* tokens.hasCredential); + const authError = yield* tokens.get.pipe(Effect.flip); + assert.equal(authError._tag, "CloudCliAuthorizationError"); + assert.equal( + (yield* Effect.result( + tokens.store({ + accessToken: "must-not-store", + refreshToken: "must-not-store", + expiresAtEpochMs: 1, + }), + ))._tag, + "Failure", + ); + + const runtime = yield* runtimeModule.CloudManagedEndpointRuntime; + assert.deepEqual(yield* runtime.applyConfig(null), { status: "disabled" }); + + const relay = yield* awarenessModule.AgentAwarenessRelay; + yield* relay.publishThread(ThreadId.make("isolated-thread")); + yield* relay.start(); + + const deviceLayer = deviceModule.layerDisabled.pipe( + Layer.provide(ServerSettings.ServerSettingsService.layerTest()), + Layer.provide(FetchHttpClient.layer), + ); + const devices = yield* deviceModule.DeviceService.pipe( + Effect.provide(Layer.mergeAll(deviceLayer, NodeServices.layer)), + ); + assert.deepEqual((yield* devices.state).hosts, []); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + tokenModule.layerDisabled, + runtimeModule.layerDisabled, + awarenessModule.layerDisabled, + ), + ), + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousRoot === undefined) delete process.env[ISOLATION_ROOT_ENV]; + else process.env[ISOLATION_ROOT_ENV] = previousRoot; + NodeFS.rmSync(parent, { recursive: true, force: true }); + }), + ), + ); + }, +); diff --git a/apps/server/src/orchestration/workflowScriptQuery.ts b/apps/server/src/orchestration/workflowScriptQuery.ts index 06bbd35ccf62..f0daf43cd831 100644 --- a/apps/server/src/orchestration/workflowScriptQuery.ts +++ b/apps/server/src/orchestration/workflowScriptQuery.ts @@ -14,8 +14,8 @@ * never trusted beyond these checks. */ import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import { effectiveHomeDirectory } from "@t3tools/shared/isolationRoot"; import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -23,7 +23,7 @@ import * as Effect from "effect/Effect"; const SCRIPT_BYTE_CAP = 256 * 1024; function scriptsRoot(): string { - return NodePath.join(NodeOS.homedir(), ".claude", "projects"); + return NodePath.join(effectiveHomeDirectory(process.env, []), ".claude", "projects"); } export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c8..d88062cb920d 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,4 +1,5 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { effectiveHomeDirectory, isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import { listLoginShellCandidates, mergePathEntries, @@ -75,13 +76,15 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< if (platform !== "darwin" && platform !== "linux") return; - yield* Effect.sync(() => hydratePosixHome(env)).pipe( - Effect.catchDefect((defect) => - Effect.sync(() => { - logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect); - }), - ), - ); + if (!isIsolationProfileActive(env)) { + yield* Effect.sync(() => hydratePosixHome(env)).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect); + }), + ), + ); + } yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( Effect.catchDefect((defect) => Effect.sync(() => { @@ -93,19 +96,21 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; + const homeDirectory = effectiveHomeDirectory(process.env, []); if (input === "~") { - return NodeOS.homedir(); + return homeDirectory; } if (input.startsWith("~/") || input.startsWith("~\\")) { - return join(NodeOS.homedir(), input.slice(2)); + return join(homeDirectory, input.slice(2)); } return input; }); export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { const { join, resolve } = yield* Path.Path; + const homeDirectory = effectiveHomeDirectory(process.env, []); if (!raw || raw.trim().length === 0) { - return join(NodeOS.homedir(), ".t3"); + return join(homeDirectory, ".t3"); } return resolve(yield* expandHomePath(raw.trim())); }); diff --git a/apps/server/src/pathExpansion.ts b/apps/server/src/pathExpansion.ts index ec3f03faa5b8..97beed9c0a84 100644 --- a/apps/server/src/pathExpansion.ts +++ b/apps/server/src/pathExpansion.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import { effectiveHomeDirectory } from "@t3tools/shared/isolationRoot"; import type * as Path from "effect/Path"; @@ -17,10 +17,15 @@ import type * as Path from "effect/Path"; * expansion. */ export function expandHomePath(value: string): string { + return expandHomePathFrom(value, effectiveHomeDirectory(process.env, [])); +} + +/** Pure expansion form for callers that already hold an explicitly selected home directory. */ +export function expandHomePathFrom(value: string, homeDirectory: string): string { if (!value) return value; - if (value === "~") return NodeOS.homedir(); + if (value === "~") return homeDirectory; if (value.startsWith("~/") || value.startsWith("~\\")) { - return NodePath.join(NodeOS.homedir(), value.slice(2)); + return NodePath.join(homeDirectory, value.slice(2)); } return value; } @@ -32,11 +37,17 @@ export function expandHomePath(value: string): string { * separator handling. */ export function expandHomePathWith(value: string, path: Path.Path): string { - if (value === "~") { - return NodeOS.homedir(); - } + return expandHomePathFromWith(value, effectiveHomeDirectory(process.env, []), path); +} + +export function expandHomePathFromWith( + value: string, + homeDirectory: string, + path: Path.Path, +): string { + if (value === "~") return homeDirectory; if (value.startsWith("~/") || value.startsWith("~\\")) { - return path.join(NodeOS.homedir(), value.slice(2)); + return path.join(homeDirectory, value.slice(2)); } return value; } diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 975192e70033..29a7d72cb7bc 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -14,6 +14,7 @@ * @module project/AgentSessionScanner */ import * as NodeOS from "node:os"; +import { effectiveHomeDirectory, isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import { AgentSessionScanError, @@ -631,7 +632,7 @@ export const make = Effect.gen(function* () { // must case fold. const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; const hostEnvironment = yield* HostProcessEnvironment; - const homeDir = NodeOS.homedir(); + const homeDir = effectiveHomeDirectory(process.env, []); // `/private/tmp` is what macOS reports for sessions started in `/tmp`. const excludedProjectRoots = new Set( [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => @@ -910,6 +911,7 @@ export const make = Effect.gen(function* () { * environment, then `~/.claude`. */ const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { + if (isIsolationProfileActive()) return path.join(homeDir, ".claude"); const configured = homePath.trim(); if (configured.length > 0) { return path.resolve(expandHomePath(configured)); @@ -918,7 +920,7 @@ export const make = Effect.gen(function* () { if (fromEnvironment.length > 0) { return path.resolve(expandHomePath(fromEnvironment)); } - return path.join(NodeOS.homedir(), ".claude"); + return path.join(homeDir, ".claude"); }; const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( @@ -1199,6 +1201,10 @@ export const make = Effect.gen(function* () { let cachedCandidates: ReadonlyArray | null = null; const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { + if (isIsolationProfileActive()) { + cachedCandidates = []; + return { candidates: [], scannedAt: DateTime.formatIso(yield* DateTime.now) }; + } const { candidates: raw, truncated } = yield* collectCandidates(); cachedCandidates = raw; @@ -1487,7 +1493,10 @@ export const make = Effect.gen(function* () { const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( workspaceRoot, completedSources = [], - ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); + ) => + isIsolationProfileActive() + ? Stream.empty + : Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); return AgentSessionScanner.of({ scan, recentThreads }); }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 942fbb0bc40a..3e025a0f50a8 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -22,6 +22,10 @@ * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ +// @effect-diagnostics nodeBuiltinImport:off -- The isolated-profile marker fixture uses a private native temp directory. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -41,13 +45,14 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; +import { ISOLATION_ROOT_ENV, prepareIsolationProfile } from "@t3tools/shared/isolationRoot"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import { ServerConfig } from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { expandHomePathFrom } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; @@ -143,37 +148,17 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); -const makeTildeProviderFixtures = Effect.fn( - "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", +const makePortableProviderFixtures = Effect.fn( + "ProviderInstanceRegistryLive.test.makePortableProviderFixtures", )(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const homePath = expandHomePath("~"); const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ - directory: homePath, + directory: NodeOS.tmpdir(), prefix: ".t3-provider-path-test-", }); - const codexPath = path.join(fixtureDir, "codex"); const claudePath = path.join(fixtureDir, "claude"); const claudeHomePath = path.join(fixtureDir, "claude-home"); - const codexScriptPath = path.join(fixtureDir, "codex-script.json"); - const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); - - yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); - yield* fileSystem.copyFile( - path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), - path.join(fixtureDir, "codexCollabMockPeer.mjs"), - ); - yield* fileSystem.copyFile( - path.join(codexFixtureDir, "codexMultiAgentWire.json"), - path.join(fixtureDir, "codexMultiAgentWire.json"), - ); - yield* fileSystem.writeFileString( - codexScriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. - JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), - ); - yield* fileSystem.chmod(codexPath, 0o755); yield* fileSystem.writeFileString( claudePath, @@ -187,7 +172,8 @@ const makeTildeProviderFixtures = Effect.fn( "const lines = NodeReadline.createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype === "initialize") {', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -200,6 +186,17 @@ const makeTildeProviderFixtures = Effect.fn( " },", " },", ' }) + "\\n");', + " }", + ' if (message.type === "control_request" && message.request?.subtype === "get_usage") {', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + ' response: { subtype: "success", request_id: message.request_id, response: {', + ' session: {}, subscription_type: "pro", rate_limits_available: true,', + ' rate_limits: { five_hour: { utilization: 12, resets_at: "2099-01-01T00:00:00Z" } },', + " behaviors: null,", + " } },", + ' }) + "\\n");', + " }", "});", "setInterval(() => {}, 1_000);", "", @@ -208,12 +205,14 @@ const makeTildeProviderFixtures = Effect.fn( yield* fileSystem.chmod(claudePath, 0o755); yield* fileSystem.makeDirectory(claudeHomePath); - const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; + const asTildePath = (filePath: string) => filePath; + expect(expandHomePathFrom("~/synthetic-provider", fixtureDir)).toBe( + path.join(fixtureDir, "synthetic-provider"), + ); return { - codexBinaryPath: asTildePath(codexPath), + fixtureDir, claudeBinaryPath: asTildePath(claudePath), claudeHomePath, - codexScriptPath, }; }); @@ -235,6 +234,57 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(CodexResetCredit.layerTest), ); + it.live("does not create provider drivers from a claimed isolation profile", () => { + const parent = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-provider-isolation-test-"), + ); + const profileRoot = NodePath.join(parent, "profile"); + prepareIsolationProfile(profileRoot, [NodePath.join(parent, "account-home")]); + const previousRoot = process.env[ISOLATION_ROOT_ENV]; + process.env[ISOLATION_ROOT_ENV] = profileRoot; + let createCalls = 0; + const driver = { + ...CodexDriver, + create: () => + Effect.sync(() => { + createCalls += 1; + throw new Error("provider creation must be denied in an isolation profile"); + }), + } as typeof CodexDriver; + const instanceId = ProviderInstanceId.make("isolated_codex"); + const configMap: ProviderInstanceConfigMap = { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Isolated Codex", + enabled: true, + config: makeCodexConfig({ + enabled: true, + homePath: "/outside/account/.codex", + binaryPath: "/outside/account/codex", + }), + }, + }; + + return Effect.scoped( + Effect.gen(function* () { + const { registry } = yield* makeProviderInstanceRegistry({ drivers: [driver], configMap }); + expect(createCalls).toBe(0); + expect(yield* registry.listInstances).toEqual([]); + expect(yield* registry.listUnavailable).toHaveLength(1); + }), + ) + .pipe(Effect.provide(testLayer)) + .pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousRoot === undefined) delete process.env[ISOLATION_ROOT_ENV]; + else process.env[ISOLATION_ROOT_ENV] = previousRoot; + NodeFS.rmSync(parent, { recursive: true, force: true }); + }), + ), + ); + }); + it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => Effect.gen(function* () { const personalId = ProviderInstanceId.make("codex_personal"); @@ -339,27 +389,14 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); - it.live("runs Codex and Claude readiness probes from configured tilde paths", () => + it.live("runs Claude readiness probes from a portable configured path", () => Effect.gen(function* () { if (yield* isHostWindows) return; - const fixtures = yield* makeTildeProviderFixtures(); + const fixtures = yield* makePortableProviderFixtures(); - const codexId = ProviderInstanceId.make("codex_tilde"); const claudeId = ProviderInstanceId.make("claude_tilde"); const configMap: ProviderInstanceConfigMap = { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - environment: [ - { - name: "T3_CODEX_COLLAB_SCRIPT", - value: fixtures.codexScriptPath, - sensitive: false, - }, - ], - config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), - }, [claudeId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: true, @@ -372,23 +409,17 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver], + drivers: [ClaudeDriver], configMap, }); - const codex = yield* registry.getInstance(codexId); const claude = yield* registry.getInstance(claudeId); - expect(codex).toBeDefined(); expect(claude).toBeDefined(); - const [codexSnapshot, claudeSnapshot] = yield* Effect.all( - [codex!.snapshot.refresh, claude!.snapshot.refresh], - { concurrency: "unbounded" }, - ); - expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); + const claudeSnapshot = yield* claude!.snapshot.refresh; expect(claudeSnapshot).toMatchObject({ - status: "ready", + status: "warning", installed: true, - version: "2.1.219", + message: "Could not verify Claude authentication status from initialization result.", }); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index 67ccbd167c30..66ca9b953a56 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -52,6 +52,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts"; +import { isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import { ProviderInstanceRegistry, type ProviderInstanceRegistryShape, @@ -128,6 +129,18 @@ const buildEntry = (input: { > => Effect.gen(function* () { const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; + if (isIsolationProfileActive()) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: "Provider execution is disabled in the desktop isolation profile.", + }), + }; + } const driver = driversById.get(entry.driver); if (!driver) { return { diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 9e8727ed7a77..34a678750bab 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -69,3 +69,6 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), ); + +/** Isolation profiles do not construct providers backed by external accounts. */ +export const layerDisabled = Layer.succeed(PullRequestProviderRegistry, fromProviders([])); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 052a67959ad1..ff2610595e02 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -638,4 +638,12 @@ export const make = Effect.gen(function* () { }); }); +export const layerDisabled = Layer.succeed( + AgentAwarenessRelay, + AgentAwarenessRelay.of({ + publishThread: () => Effect.void, + start: () => Effect.void, + }), +); + export const layer = Layer.effect(AgentAwarenessRelay, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 72ea763917db..7f3165d7c96c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -22,6 +22,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; +import { isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import { otlpTracesProxyRouteLayer, assetRouteLayer, @@ -222,12 +223,32 @@ const ResourceDiagnosticsLayerLive = Layer.mergeAll( ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), ); -const RelayClientLive = Layer.unwrap( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - return RelayClient.layerCloudflared({ baseDir: config.baseDir }); - }), -); +const RelayClientLive = isIsolationProfileActive() + ? Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ status: "missing", version: RelayClient.CLOUDFLARED_VERSION }), + install: Effect.fail( + new RelayClient.RelayClientInstallError({ + reason: "unsupported_platform", + message: "Relay client installation is disabled in the isolation profile.", + }), + ), + installWithProgress: () => + Effect.fail( + new RelayClient.RelayClientInstallError({ + reason: "unsupported_platform", + message: "Relay client installation is disabled in the isolation profile.", + }), + ), + }), + ) + : Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return RelayClient.layerCloudflared({ baseDir: config.baseDir }); + }), + ); const HttpServerLive = Layer.unwrap( Effect.gen(function* () { @@ -259,7 +280,11 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(PullRequestSyncReactor.layer), Layer.provideMerge(ThreadPullRequestReactor.layer), - Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provideMerge( + isIsolationProfileActive() + ? AgentAwarenessRelay.layerDisabled + : AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer)), + ), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -292,19 +317,21 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); -const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubCli.layer, - GitLabCli.layer, - ForgejoCli.layer, - ), - ), - Layer.provideMerge(GitVcsDriver.layer), - Layer.provideMerge(VcsDriverRegistryLayerLive), -); +const SourceControlProviderRegistryLayerLive = isIsolationProfileActive() + ? SourceControlProviderRegistry.layerDisabled + : SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, + ForgejoCli.layer, + ), + ), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), + ); const RepositoryIdentityResolverLayerLive = Layer.effect( RepositoryIdentityResolver.RepositoryIdentityResolver, @@ -343,7 +370,11 @@ const RepositoryIdentityResolverLayerLive = Layer.effect( ).pipe(Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(ProcessRunner.layer)); const PullRequestServiceLive = PullRequestService.layer.pipe( - Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide( + isIsolationProfileActive() + ? PullRequestProviderRegistry.layerDisabled + : PullRequestProviderRegistry.layer, + ), // Where the viewed-file marks live for a host that keeps none of its own. Layer.provide(PullRequestFilesViewed.layer), Layer.provide(PullRequestReadCache.layer), @@ -421,7 +452,9 @@ const PreviewLayerLive = Layer.empty.pipe( Layer.provideMerge(PortScannerLayerLive), ); -const DeviceLayerLive = DeviceService.layer.pipe( +const DeviceLayerLive = ( + isIsolationProfileActive() ? DeviceService.layerDisabled : DeviceService.layer +).pipe( Layer.provide(ServerSettingsLayerLive), Layer.provide(ProcessRunner.layer), Layer.provide(NetService.layer), @@ -455,13 +488,15 @@ const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provide(ServerSecretStore.layer), ); -const CloudManagedEndpointRuntimeLive = Layer.mergeAll( - RelayClientLive, - CloudManagedEndpointRuntime.layer.pipe( - Layer.provide(ServerSecretStore.layer), - Layer.provide(RelayClientLive), - ), -); +const CloudManagedEndpointRuntimeLive = isIsolationProfileActive() + ? CloudManagedEndpointRuntime.layerDisabled + : Layer.mergeAll( + RelayClientLive, + CloudManagedEndpointRuntime.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(RelayClientLive), + ), + ); // Build the orchestration engine with this same provider service instance so // serialized turn admission can resolve live route compatibility. @@ -519,6 +554,7 @@ const AntigravityInstallationRefreshLive = Layer.effectDiscard( ); const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( + Layer.provideMerge(RelayClientLive), Layer.provideMerge(AntigravityInstallationRefreshLive), Layer.provideMerge(ProviderAuthServiceLive), // Core Services @@ -573,10 +609,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( Layer.mergeAll( - CloudCliTokenManager.layer.pipe( - Layer.provide(ServerSecretStore.layer), - Layer.provide(ExternalLauncher.layer), - ), + isIsolationProfileActive() + ? CloudCliTokenManager.layerDisabled + : CloudCliTokenManager.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ExternalLauncher.layer), + ), CloudManagedEndpointRuntimeLive, ), ), @@ -687,62 +725,63 @@ const makeServerLayer = Layer.unwrap( ), ), ); - const tailscaleServeLayer = config.tailscaleServeEnabled - ? Layer.effectDiscard( - Effect.acquireRelease( - Effect.gen(function* () { - yield* Deferred.succeed(tailscaleParked, undefined).pipe(Effect.orDie); - yield* awaitActivation; - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (typeof address === "string" || !("port" in address)) { - return null; - } - - const localPort = address.port; - return yield* ensureTailscaleServe({ - localPort, - servePort: config.tailscaleServePort, - localHost: "127.0.0.1", - }).pipe( - Effect.as({ localPort, servePort: config.tailscaleServePort }), - Effect.tap(() => - Effect.logInfo("Tailscale Serve configured", { - localPort, - servePort: config.tailscaleServePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to configure Tailscale Serve", { - cause, - localPort, - servePort: config.tailscaleServePort, - }).pipe(Effect.as(null)), - ), - ); - }), - (configured) => - configured - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( - Effect.tap(() => - Effect.logInfo("Tailscale Serve disabled", { - servePort: configured.servePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { - cause, - servePort: configured.servePort, - }), - ), - ) - : Effect.void, - ), - ) - : Layer.empty; + const tailscaleServeLayer = + config.tailscaleServeEnabled && !isIsolationProfileActive() + ? Layer.effectDiscard( + Effect.acquireRelease( + Effect.gen(function* () { + yield* Deferred.succeed(tailscaleParked, undefined).pipe(Effect.orDie); + yield* awaitActivation; + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return null; + } + + const localPort = address.port; + return yield* ensureTailscaleServe({ + localPort, + servePort: config.tailscaleServePort, + localHost: "127.0.0.1", + }).pipe( + Effect.as({ localPort, servePort: config.tailscaleServePort }), + Effect.tap(() => + Effect.logInfo("Tailscale Serve configured", { + localPort, + servePort: config.tailscaleServePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to configure Tailscale Serve", { + cause, + localPort, + servePort: config.tailscaleServePort, + }).pipe(Effect.as(null)), + ), + ); + }), + (configured) => + configured + ? disableTailscaleServe({ servePort: configured.servePort }).pipe( + Effect.tap(() => + Effect.logInfo("Tailscale Serve disabled", { + servePort: configured.servePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { + cause, + servePort: configured.servePort, + }), + ), + ) + : Effect.void, + ), + ) + : Layer.empty; const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { - if (!hasCloudPublicConfig) { + if (!hasCloudPublicConfig || isIsolationProfileActive()) { yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); return; } @@ -815,7 +854,9 @@ const makeServerLayer = Layer.unwrap( Deferred.await(runtimeStateParked), Deferred.await(cloudLinkParked), Deferred.await(routesReady), - ...(config.tailscaleServeEnabled ? [Deferred.await(tailscaleParked)] : []), + ...(config.tailscaleServeEnabled && !isIsolationProfileActive() + ? [Deferred.await(tailscaleParked)] + : []), ], { concurrency: "unbounded" }, ).pipe(Effect.asVoid), diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index 0eacba0759cf..9703f21fc6e4 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -6,13 +6,13 @@ import * as Result from "effect/Result"; import * as Clock from "effect/Clock"; import * as FileSystem from "effect/FileSystem"; import * as Semaphore from "effect/Semaphore"; -import * as NodeOS from "node:os"; // @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform. import * as NodePath from "node:path"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { effectiveHomeDirectory, isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import type { SourceControlProviderContext } from "./SourceControlProvider.ts"; @@ -218,39 +218,50 @@ export const make = Effect.gen(function* () { const authLock = yield* Semaphore.make(1); const authenticated = new Map(); const execute: ForgejoCli["Service"]["execute"] = (input) => - process - .run({ - ...input, - operation: "ForgejoCli.execute", - command: input.command ?? "tea", - timeoutMs: input.timeoutMs ?? 30_000, - }) - .pipe( - Effect.mapError( - (cause) => - new ForgejoCliError({ - command: input.command ?? "tea", - cwd: input.cwd, - ...(input.command === "fj" ? {} : { cause }), - ...(cause._tag === "VcsProcessSpawnError" - ? { reason: "missing-cli" as const } - : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" - ? { reason: "authentication" as const } - : {}), - detail: - cause._tag === "VcsProcessSpawnError" - ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry." - : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" - ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`." - : "Forgejo CLI command failed.", - }), - ), - ); + isIsolationProfileActive() + ? Effect.fail( + new ForgejoCliError({ + command: input.command ?? "tea", + cwd: input.cwd, + reason: "authentication", + detail: "Forgejo CLI access is disabled in the isolation profile.", + }), + ) + : process + .run({ + ...input, + operation: "ForgejoCli.execute", + command: input.command ?? "tea", + timeoutMs: input.timeoutMs ?? 30_000, + }) + .pipe( + Effect.mapError( + (cause) => + new ForgejoCliError({ + command: input.command ?? "tea", + cwd: input.cwd, + ...(input.command === "fj" ? {} : { cause }), + ...(cause._tag === "VcsProcessSpawnError" + ? { reason: "missing-cli" as const } + : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" + ? { reason: "authentication" as const } + : {}), + detail: + cause._tag === "VcsProcessSpawnError" + ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry." + : cause._tag === "VcsProcessExitError" && + cause.failureKind === "authentication" + ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`." + : "Forgejo CLI command failed.", + }), + ), + ); const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) { + if (isIsolationProfileActive()) return { hosts: {} }; for (const path of forgejoKeysPaths({ platform: yield* HostProcessPlatform, - home: NodeOS.homedir(), + home: effectiveHomeDirectory(globalThis.process.env, []), ...(globalThis.process.env.XDG_DATA_HOME ? { dataHome: globalThis.process.env.XDG_DATA_HOME } : {}), @@ -318,6 +329,7 @@ export const make = Effect.gen(function* () { const listLogins: NonNullable = Effect.fn( "ForgejoCli.listLogins", )(function* (input) { + if (isIsolationProfileActive()) return []; if (input.command === "fj") { const keys = yield* readKeys(input.cwd).pipe(Effect.result); if (Result.isFailure(keys)) { @@ -632,6 +644,14 @@ export const make = Effect.gen(function* () { }); const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input); const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) { + if (isIsolationProfileActive()) { + return yield* new ForgejoCliError({ + command: "tea", + cwd: input.cwd, + reason: "authentication", + detail: "Forgejo API access is disabled in the isolation profile.", + }); + } const repository = yield* resolveTarget( input, input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5893c21ff772..6fcfe1e03566 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The isolation regression fixture claims and removes a private profile root. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Cache from "effect/Cache"; import * as TestClock from "effect/testing/TestClock"; @@ -10,6 +14,7 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import { ISOLATION_ROOT_ENV, prepareIsolationProfile } from "@t3tools/shared/isolationRoot"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -46,6 +51,36 @@ afterEach(() => { mockRun.mockReset(); }); +it.live("does not probe gh credentials or launch gh in a claimed isolation profile", () => { + const parent = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-github-isolation-test-")); + const profileRoot = NodePath.join(parent, "profile"); + prepareIsolationProfile(profileRoot, [NodePath.join(parent, "account-home")]); + const previousRoot = process.env[ISOLATION_ROOT_ENV]; + process.env[ISOLATION_ROOT_ENV] = profileRoot; + + return Effect.gen(function* () { + const github = yield* GitHubCli.make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: mockRun, + }), + ); + const error = yield* github + .execute({ cwd: "/repo", args: ["auth", "status"] }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "GitHubCliAuthenticationError"); + expect(mockRun).not.toHaveBeenCalled(); + }).pipe( + Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)), + Effect.ensuring( + Effect.sync(() => { + if (previousRoot === undefined) delete process.env[ISOLATION_ROOT_ENV]; + else process.env[ISOLATION_ROOT_ENV] = previousRoot; + NodeFS.rmSync(parent, { recursive: true, force: true }); + }), + ), + ); +}); + it.effect("shares quota checks, preserves the reserve, and resumes after reset", () => Effect.gen(function* () { let probes = 0; diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index c525740efeae..f045e32f35db 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -16,6 +16,7 @@ import { type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; +import { isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; @@ -402,6 +403,13 @@ export const make = Effect.gen(function* () { const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")( function* (input) { + if (isIsolationProfileActive()) { + return yield* new GitHubCliAuthenticationError({ + command: "gh", + cwd: input.cwd, + cause: new Error("GitHub CLI access is disabled in the isolation profile."), + }); + } const credential = yield* PinnedGitHubCredential; if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) { return yield* new GitHubCliCommandError({ diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 30aa22995b95..7b6dcae7293c 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -25,6 +25,28 @@ import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import { firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; +it.effect( + "isolation discovery returns without constructing providers or probing host tools", + () => { + let liveProviderLayerConstructed = false; + const discoveryLayer = SourceControlDiscovery.layerForIsolationProfile( + true, + (): Layer.Layer => { + liveProviderLayerConstructed = true; + throw new Error("live source-control adapters must not be constructed in isolation"); + }, + ); + + return Effect.gen(function* () { + const service = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* service.discover; + assert.isFalse(liveProviderLayerConstructed); + assert.deepStrictEqual(result.sourceControlProviders, []); + assert.isTrue(result.versionControlSystems.every((item) => item.status === "missing")); + }).pipe(Effect.provide(discoveryLayer)); + }, +); + const sourceControlProviderRegistryTestLayer = (input: { readonly bitbucket: Partial; readonly process: Partial; diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 2628360780e1..90423924e889 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -141,3 +141,28 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(SourceControlDiscovery, make); + +/** Isolation profiles report no external account discovery or host tool probes. */ +export const layerDisabled = Layer.succeed( + SourceControlDiscovery, + SourceControlDiscovery.of({ + discover: Effect.succeed({ + versionControlSystems: VCS_PROBES.map((entry) => ({ + kind: entry.kind, + label: entry.label, + implemented: entry.implemented, + status: "missing" as const, + version: Option.none(), + installHint: entry.installHint, + detail: Option.some(entry.installHint), + })), + sourceControlProviders: [], + }), + }), +); + +export const layerForIsolationProfile = ( + isolationProfile: boolean, + makeLiveLayer: () => Layer.Layer, +): Layer.Layer => + isolationProfile ? layerDisabled : makeLiveLayer(); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index d8893b29e089..50a82d46feb8 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -337,3 +337,16 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(SourceControlProviderRegistry, make); + +/** Isolation profiles expose no credential-backed remote source control services. */ +export const layerDisabled = Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + resolveLink: () => undefined, + get: (kind) => Effect.succeed(unsupportedProvider(kind)), + resolveHandle: () => + Effect.succeed({ provider: unsupportedProvider("unknown"), context: null }), + resolve: () => Effect.succeed(unsupportedProvider("unknown")), + discover: Effect.succeed([]), + }), +); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index c02c168a8afc..1e12279297b4 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -20,6 +20,7 @@ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import packageJson from "../../package.json" with { type: "json" }; +import { isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import * as ServerConfig from "../config.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; @@ -84,6 +85,12 @@ function serverOsFromNodePlatform(platform: string): ClientOs { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { + if (isIsolationProfileActive()) { + return AnalyticsService.of({ + record: () => Effect.void, + flush: Effect.void, + }); + } const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index c68dfbbcd9ae..2551b9b93a0a 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,4 +1,4 @@ -import * as NodeOS from "node:os"; +import { effectiveHomeDirectory } from "@t3tools/shared/isolationRoot"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -299,5 +299,5 @@ export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierFo ); export const getTelemetryIdentifier = Effect.suspend(() => - getTelemetryIdentifierForHome(NodeOS.homedir()), + getTelemetryIdentifierForHome(effectiveHomeDirectory(process.env, [])), ); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 274daf74e354..77ff7da53e20 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -28,6 +28,7 @@ import { UsageReadError, } from "@t3tools/contracts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -709,4 +710,6 @@ export const make = Effect.gen(function* () { return { readSummary, refreshRates } as const; }); -export const layer = Layer.effect(UsageService, make); +// Disposable packaged profiles must never scan provider transcripts, even +// when a configured provider instance points at an account-owned directory. +export const layer = isIsolationProfileActive() ? layerTest : Layer.effect(UsageService, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e38cad7aaca7..ed3e42e5d715 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -178,6 +178,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import { isIsolationProfileActive } from "@t3tools/shared/isolationRoot"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -3862,21 +3863,23 @@ export const websocketRpcRouteLayer = Layer.unwrap( // mutation invalidates the HTTP diff cache that every client reads from. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), Layer.provide( - SourceControlDiscovery.layer.pipe( - Layer.provide( - SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubCli.layer, - GitLabCli.layer, - ForgejoCli.layer, + SourceControlDiscovery.layerForIsolationProfile(isIsolationProfileActive(), () => + SourceControlDiscovery.layer.pipe( + Layer.provide( + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, + ForgejoCli.layer, + ), + ), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provide( + VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), ), - ), - Layer.provideMerge(GitVcsDriver.layer), - Layer.provide( - VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), ), ), ), diff --git a/packages/shared/package.json b/packages/shared/package.json index ee8f3e3f3810..5bf88f3438aa 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -283,6 +283,10 @@ "types": "./src/hostProcess.ts", "import": "./src/hostProcess.ts" }, + "./isolationRoot": { + "types": "./src/isolationRoot.ts", + "import": "./src/isolationRoot.ts" + }, "./testing/longTempDir": { "types": "./src/testing/longTempDir.ts", "import": "./src/testing/longTempDir.ts" diff --git a/packages/shared/src/isolationRoot.test.ts b/packages/shared/src/isolationRoot.test.ts new file mode 100644 index 000000000000..a19f07cada29 --- /dev/null +++ b/packages/shared/src/isolationRoot.test.ts @@ -0,0 +1,159 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Filesystem ownership and symlink rejection need native lstat fixtures. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + effectiveHomeDirectory, + IsolationRootError, + parseIsolationRoot, + prepareIsolationProfile, + resolveDesktopIsolationProfile, + setElectronIsolationPaths, +} from "./isolationRoot.ts"; + +const scratchRoots: string[] = []; + +const makeScratchRoot = (): string => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-isolation-root-test-")); + scratchRoots.push(root); + return root; +}; + +afterEach(() => { + for (const root of scratchRoots.splice(0)) { + NodeFS.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("isolationRoot", () => { + it("requires exactly one absolute root and rejects malformed selections", () => { + expect(() => parseIsolationRoot(["--isolation-root"])).toThrow(IsolationRootError); + expect(() => parseIsolationRoot(["--isolation-root", "relative"])).toThrow( + "requires an absolute directory path", + ); + expect(() => + parseIsolationRoot(["--isolation-root", "/tmp/a", "--isolation-root=/tmp/b"]), + ).toThrow("may be supplied only once"); + expect(parseIsolationRoot([], {})).toBeUndefined(); + expect(parseIsolationRoot(["--isolation-root", "/tmp/t3-profile"], {})).toBe("/tmp/t3-profile"); + expect( + parseIsolationRoot(["--isolation-root", "/tmp/t3-profile"], { + T3CODE_ISOLATION_ROOT: "/tmp/t3-profile", + }), + ).toBe("/tmp/t3-profile"); + expect(() => + parseIsolationRoot(["--isolation-root", "/tmp/t3-profile"], { + T3CODE_ISOLATION_ROOT: "/tmp/other-profile", + }), + ).toThrow("does not match the explicit"); + }); + + it("routes all desktop and T3 state paths beneath a claimed profile and reuses its marker", () => { + const parent = makeScratchRoot(); + const profileRoot = NodePath.join(parent, "profile"); + const paths = prepareIsolationProfile(profileRoot, [NodePath.join(parent, "account-home")]); + + expect(paths.homeDirectory).toBe(profileRoot); + expect(paths.appDataDirectory).toBe( + NodePath.join(profileRoot, "Library", "Application Support"), + ); + expect(paths.userDataDirectory).toBe(NodePath.join(profileRoot, "userData")); + expect(paths.sessionDataDirectory).toBe(NodePath.join(profileRoot, "sessionData")); + expect(paths.t3Home).toBe(NodePath.join(profileRoot, ".t3")); + expect(prepareIsolationProfile(profileRoot, [NodePath.join(parent, "account-home")])).toEqual( + paths, + ); + const setPathCalls: Array<[string, string]> = []; + setElectronIsolationPaths((name, value) => setPathCalls.push([name, value]), paths); + expect(setPathCalls).toEqual([ + ["home", paths.homeDirectory], + ["appData", paths.appDataDirectory], + ["userData", paths.userDataDirectory], + ["sessionData", paths.sessionDataDirectory], + ]); + }); + + it("rejects a profile that overlaps account home, has unowned files, or traverses a symlink", () => { + const parent = makeScratchRoot(); + const accountHome = NodePath.join(parent, "account-home"); + NodeFS.mkdirSync(accountHome); + expect(() => + prepareIsolationProfile(NodePath.join(accountHome, "nested"), [accountHome]), + ).toThrow("separate from the account home"); + + const occupied = NodePath.join(parent, "occupied"); + NodeFS.mkdirSync(occupied); + NodeFS.writeFileSync(NodePath.join(occupied, "settings.json"), "synthetic"); + expect(() => prepareIsolationProfile(occupied, [accountHome])).toThrow("unowned files"); + + const realDirectory = NodePath.join(parent, "real"); + NodeFS.mkdirSync(realDirectory); + const symlinkDirectory = NodePath.join(parent, "linked"); + NodeFS.symlinkSync(realDirectory, symlinkDirectory, "dir"); + expect(() => + prepareIsolationProfile(NodePath.join(symlinkDirectory, "profile"), [accountHome]), + ).toThrow("symbolic links"); + + const caseVariantHome = NodePath.join(parent, "BUSINESSACCOUNT"); + expect(() => + prepareIsolationProfile(NodePath.join(parent, "businessaccount", "profile"), [ + caseVariantHome, + ]), + ).toThrow("separate from the account home"); + }); + + it("rejects a symlinked marker before reading its target", () => { + const parent = makeScratchRoot(); + const root = NodePath.join(parent, "profile"); + NodeFS.mkdirSync(root, { mode: 0o700 }); + const outsideMarker = NodePath.join(parent, "outside-marker.json"); + NodeFS.writeFileSync(outsideMarker, JSON.stringify({ version: 1, root }), { mode: 0o600 }); + NodeFS.symlinkSync(outsideMarker, NodePath.join(root, ".t3code-isolation-profile.json")); + + expect(() => prepareIsolationProfile(root, [NodePath.join(parent, "account-home")])).toThrow( + "owned by this account and private", + ); + }); + + it("uses the internal root before its fallback without consulting HOME or CODEX_HOME", () => { + const root = NodePath.join(makeScratchRoot(), "profile"); + prepareIsolationProfile(root, [NodePath.join(NodeOS.tmpdir(), "different-account-home")]); + expect( + effectiveHomeDirectory( + { T3CODE_ISOLATION_ROOT: root, HOME: "/outside/home", CODEX_HOME: "/outside/codex" }, + [], + "/outside/default", + ), + ).toBe(root); + expect( + effectiveHomeDirectory({ T3CODE_ISOLATION_ROOT: root, HOME: "/outside/home" }, [ + "desktop", + "--isolation-root", + root, + ]), + ).toBe(root); + expect(effectiveHomeDirectory({}, [], "/normal/home")).toBe("/normal/home"); + }); + + it("only enables the explicit profile for packaged macOS and rejects an empty internal root", () => { + expect(() => + resolveDesktopIsolationProfile({ + argv: ["--isolation-root", "/tmp/profile"], + isPackaged: false, + platform: "darwin", + }), + ).toThrow("only supported by packaged macOS builds"); + const parent = makeScratchRoot(); + expect(() => + resolveDesktopIsolationProfile({ + argv: ["--isolation-root", NodePath.join(parent, "profile")], + isPackaged: true, + platform: "darwin", + accountHomes: [NodePath.join(parent, "account-home")], + }), + ).not.toThrow(); + expect(() => parseIsolationRoot([], { T3CODE_ISOLATION_ROOT: " " })).toThrow("cannot be empty"); + }); +}); diff --git a/packages/shared/src/isolationRoot.ts b/packages/shared/src/isolationRoot.ts new file mode 100644 index 000000000000..4734c6fcfc5c --- /dev/null +++ b/packages/shared/src/isolationRoot.ts @@ -0,0 +1,269 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Electron requires synchronous profile validation before app readiness and before eager imports. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export const ISOLATION_ROOT_ENV = "T3CODE_ISOLATION_ROOT"; +const ISOLATION_ROOT_FLAG = "--isolation-root"; +const PROFILE_MARKER = ".t3code-isolation-profile.json"; +const PROFILE_VERSION = 1; + +export class IsolationRootError extends Error { + constructor(message: string) { + super(message); + this.name = "IsolationRootError"; + } +} + +export interface IsolationProfilePaths { + readonly root: string; + readonly homeDirectory: string; + readonly appDataDirectory: string; + readonly userDataDirectory: string; + readonly sessionDataDirectory: string; + readonly t3Home: string; +} + +export function setElectronIsolationPaths( + setPath: (name: string, value: string) => void, + profile: IsolationProfilePaths, +): void { + setPath("home", profile.homeDirectory); + setPath("appData", profile.appDataDirectory); + setPath("userData", profile.userDataDirectory); + setPath("sessionData", profile.sessionDataDirectory); +} + +export function parseIsolationRoot( + argv: ReadonlyArray, + environment: Readonly> = process.env, +): string | undefined { + const argumentValues: Array = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === undefined) continue; + if (argument === ISOLATION_ROOT_FLAG) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new IsolationRootError(`${ISOLATION_ROOT_FLAG} requires an absolute directory path.`); + } + argumentValues.push(value); + index += 1; + } else if (argument.startsWith(`${ISOLATION_ROOT_FLAG}=`)) { + argumentValues.push(argument.slice(ISOLATION_ROOT_FLAG.length + 1)); + } + } + + const configuredValue = environment[ISOLATION_ROOT_ENV]; + let internalValue: string | undefined; + if (configuredValue !== undefined) { + internalValue = configuredValue.trim(); + if (!internalValue) { + throw new IsolationRootError(`${ISOLATION_ROOT_ENV} cannot be empty.`); + } + } + if (argumentValues.length > 1) { + throw new IsolationRootError(`${ISOLATION_ROOT_FLAG} may be supplied only once.`); + } + const argumentValue = argumentValues[0]; + if (argumentValue !== undefined && internalValue !== undefined) { + if ( + !NodePath.isAbsolute(argumentValue) || + NodePath.normalize(argumentValue) !== NodePath.normalize(internalValue) + ) { + throw new IsolationRootError( + `${ISOLATION_ROOT_ENV} does not match the explicit ${ISOLATION_ROOT_FLAG}.`, + ); + } + } + if (argumentValue === undefined && internalValue === undefined) return undefined; + + const raw = argumentValue ?? internalValue ?? ""; + if (!raw || raw.includes("\0") || !NodePath.isAbsolute(raw)) { + throw new IsolationRootError(`${ISOLATION_ROOT_FLAG} requires an absolute directory path.`); + } + return NodePath.normalize(raw); +} + +const overlaps = (left: string, right: string): boolean => { + // macOS default volumes are case-insensitive. Case-fold on every platform so + // the same profile selection is safe when validated on a case-sensitive host. + const relative = NodePath.relative(left.toLowerCase(), right.toLowerCase()); + return relative === "" || (!relative.startsWith(`..${NodePath.sep}`) && relative !== ".."); +}; + +function assertNoSymlinkComponents(root: string): void { + const parsed = NodePath.parse(root); + let current = parsed.root; + for (const component of root.slice(parsed.root.length).split(NodePath.sep).filter(Boolean)) { + current = NodePath.join(current, component); + try { + if (NodeFS.lstatSync(current).isSymbolicLink()) { + throw new IsolationRootError("The isolation profile path cannot contain symbolic links."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + break; + } + } +} + +function assertOwnedPrivatePath(path: string, directory: boolean): void { + const stat = NodeFS.lstatSync(path); + const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined; + if ( + stat.isSymbolicLink() || + (directory ? !stat.isDirectory() : !stat.isFile()) || + (currentUid !== undefined && stat.uid !== currentUid) || + (stat.mode & 0o077) !== 0 + ) { + throw new IsolationRootError( + "The isolation profile must be owned by this account and private.", + ); + } +} + +function claimProfile(root: string, accountHomes: ReadonlyArray): void { + assertNoSymlinkComponents(root); + const canonicalRoot = NodePath.resolve(root); + for (const accountHome of accountHomes) { + const canonicalHome = NodePath.resolve(accountHome); + if (overlaps(canonicalRoot, canonicalHome) || overlaps(canonicalHome, canonicalRoot)) { + throw new IsolationRootError("The isolation profile must be separate from the account home."); + } + } + + NodeFS.mkdirSync(canonicalRoot, { recursive: true, mode: 0o700 }); + assertOwnedPrivatePath(canonicalRoot, true); + const markerPath = NodePath.join(canonicalRoot, PROFILE_MARKER); + try { + assertOwnedPrivatePath(markerPath, false); + const marker = JSON.parse(NodeFS.readFileSync(markerPath, "utf8")) as { + readonly version?: unknown; + readonly root?: unknown; + }; + if (marker.version !== PROFILE_VERSION || marker.root !== canonicalRoot) { + throw new IsolationRootError("The isolation profile marker does not match this path."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + if (error instanceof IsolationRootError) throw error; + throw new IsolationRootError("The isolation profile marker is invalid."); + } + const entries = NodeFS.readdirSync(canonicalRoot); + if (entries.length > 0) { + throw new IsolationRootError("The isolation profile path already contains unowned files."); + } + try { + NodeFS.writeFileSync( + markerPath, + `${JSON.stringify({ version: PROFILE_VERSION, root: canonicalRoot })}\n`, + { encoding: "utf8", flag: "wx", mode: 0o600 }, + ); + } catch { + throw new IsolationRootError("Could not claim the empty isolation profile directory."); + } + } +} + +export function assertClaimedIsolationProfile(root: string): void { + if (!NodePath.isAbsolute(root) || root.includes("\0")) { + throw new IsolationRootError("The isolation profile path must be absolute."); + } + const canonicalRoot = NodePath.resolve(root); + assertNoSymlinkComponents(canonicalRoot); + const markerPath = NodePath.join(canonicalRoot, PROFILE_MARKER); + try { + assertOwnedPrivatePath(canonicalRoot, true); + assertOwnedPrivatePath(markerPath, false); + const marker = JSON.parse(NodeFS.readFileSync(markerPath, "utf8")) as { + readonly version?: unknown; + readonly root?: unknown; + }; + if (marker.version !== PROFILE_VERSION || marker.root !== canonicalRoot) { + throw new IsolationRootError("The isolation profile marker does not match this path."); + } + } catch (error) { + if (error instanceof IsolationRootError) throw error; + throw new IsolationRootError("The isolation profile is not claimed by T3 Code."); + } + for (const directory of [ + "Library", + NodePath.join("Library", "Application Support"), + "userData", + "sessionData", + ".t3", + ]) { + const path = NodePath.join(canonicalRoot, directory); + assertNoSymlinkComponents(path); + assertOwnedPrivatePath(path, true); + } +} + +export function isolationRootFromEnvironment( + environment: Readonly> = process.env, +): string | undefined { + const root = parseIsolationRoot([], environment); + if (root !== undefined) assertClaimedIsolationProfile(root); + return root; +} + +export const isIsolationProfileActive = ( + environment: Readonly> = process.env, +): boolean => isolationRootFromEnvironment(environment) !== undefined; + +export function resolveDesktopIsolationProfile(input: { + readonly argv: ReadonlyArray; + readonly isPackaged: boolean; + readonly platform: NodeJS.Platform; + readonly accountHomes?: ReadonlyArray; +}): IsolationProfilePaths | undefined { + const root = parseIsolationRoot(input.argv, {}); + if (root === undefined) return undefined; + if (!input.isPackaged || input.platform !== "darwin") { + throw new IsolationRootError("--isolation-root is only supported by packaged macOS builds."); + } + return prepareIsolationProfile(root, input.accountHomes); +} + +export function prepareIsolationProfile( + root: string, + accountHomes: ReadonlyArray = [NodeOS.homedir(), NodeOS.userInfo().homedir], +): IsolationProfilePaths { + if (!NodePath.isAbsolute(root) || root.includes("\0")) { + throw new IsolationRootError("The isolation profile path must be absolute."); + } + const canonicalRoot = NodePath.resolve(root); + claimProfile(canonicalRoot, accountHomes); + + const profile: IsolationProfilePaths = { + root: canonicalRoot, + homeDirectory: canonicalRoot, + appDataDirectory: NodePath.join(canonicalRoot, "Library", "Application Support"), + userDataDirectory: NodePath.join(canonicalRoot, "userData"), + sessionDataDirectory: NodePath.join(canonicalRoot, "sessionData"), + t3Home: NodePath.join(canonicalRoot, ".t3"), + }; + for (const directory of [ + profile.appDataDirectory, + profile.userDataDirectory, + profile.sessionDataDirectory, + profile.t3Home, + ]) { + assertNoSymlinkComponents(directory); + NodeFS.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertNoSymlinkComponents(directory); + } + return profile; +} + +export function effectiveHomeDirectory( + environment: Readonly> = process.env, + argv: ReadonlyArray = process.argv, + fallback?: string, +): string { + const root = parseIsolationRoot(argv, environment); + if (root === undefined) return fallback ?? NodeOS.homedir(); + if (environment[ISOLATION_ROOT_ENV] !== undefined) assertClaimedIsolationProfile(root); + return root; +} From f60ae7224c2c8956d347ffd850f0a8efa5d5c945 Mon Sep 17 00:00:00 2001 From: Kameron Smith <112618179+nullStack65@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:55:42 -0400 Subject: [PATCH 2/2] chore: trigger normal CI for reviewed isolation repair