diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3a9a8167d38cc..95649ae853193 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -150,6 +150,7 @@ import { spawnClaudeProcess } from "../../provider/Drivers/ClaudeProcess.ts"; import { claudeSignedOutMessage, claudeAccountSettings, + parseClaudeAccountDir, resolveClaudeHomePath, type ClaudeRuntime, } from "../../provider/Drivers/ClaudeHome.ts"; @@ -1484,7 +1485,10 @@ export function makeClaudeQueryOptions(input: { ...(typeof querySettings === "object" && querySettings !== null ? querySettings : {}), autoCompactWindow: Number(input.settings.autoCompactWindow), } as ClaudeSdkSettings); - const accountSettings = input.settings ? claudeAccountSettings(input.settings) : undefined; + const accountSettings = + input.settings?.setupMode === "cli" + ? claudeAccountSettings(parseClaudeAccountDir(input.settings.homePath)) + : undefined; // A settings file path cannot take extra keys, so it goes through unchanged. const effectiveQuerySettings = input.toolSearchOverride === undefined || typeof compactQuerySettings === "string" @@ -1543,7 +1547,11 @@ export function makeClaudeQueryOptions(input: { }, }, spawnClaudeCodeProcess: (options) => - spawnClaudeProcess(options, input.platform, input.settings!.homePath), + spawnClaudeProcess( + options, + input.platform, + parseClaudeAccountDir(input.settings!.homePath), + ), }), ...(input.onUserDialog === undefined ? {} : { onUserDialog: input.onUserDialog }), ...(input.supportedDialogKinds === undefined @@ -11237,8 +11245,6 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( const queryRunner = yield* ClaudeAgentSdkQueryRunner; const serverConfig = yield* ServerConfig.ServerConfig; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - if (runtime.mode === "cli") - yield* fileSystem.makeDirectory(runtime.configDir, { recursive: true, mode: 0o700 }); const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const binaryPath = yield* resolveClaudeSdkExecutablePath( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index bee32644d9a0c..b7b4c93415c5b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -22,6 +22,7 @@ import { vi } from "vite-plus/test"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { parseClaudeAccountDir } from "../../provider/Drivers/ClaudeHome.ts"; import { spawnClaudeProcess } from "../../provider/Drivers/ClaudeProcess.ts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; @@ -474,7 +475,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro signal: AbortSignal.any([]), }, HostProcess.Platform.defaultValue(), - mode === "cli" ? dir : undefined, + mode === "cli" ? parseClaudeAccountDir(dir) : undefined, ) as NodeChildProcess.ChildProcess; yield* Effect.addFinalizer(() => Effect.sync(() => { diff --git a/apps/server/src/provider/ClaudeCliAuth.ts b/apps/server/src/provider/ClaudeCliAuth.ts index 081ecdf6106fa..10229706385e7 100644 --- a/apps/server/src/provider/ClaudeCliAuth.ts +++ b/apps/server/src/provider/ClaudeCliAuth.ts @@ -27,7 +27,9 @@ export const makeClaudeCliAuth = Effect.fn("makeClaudeCliAuth")(function* (optio const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const { runtime, instanceId } = options; - const settings = encodeSettings(claudeAccountSettings(runtime.config)); + const settings = encodeSettings( + claudeAccountSettings(runtime.mode === "cli" ? runtime.configDir : undefined), + ); const spawn = Effect.fnUntraced(function* (args: ReadonlyArray) { yield* fileSystem.makeDirectory(runtime.configDir, { recursive: true, mode: 0o700 }); const environment = { ...runtime.environment, BROWSER: "false" }; diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 3f4cada8a7fcd..2720b7218fc4f 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -39,6 +39,7 @@ import { import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; import { claudeAccountSettings, + parseClaudeAccountDir, hasClaudeManagedMcpConfig, makeClaudeEnvironment, } from "./Drivers/ClaudeHome.ts"; @@ -223,12 +224,18 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { // SessionStart hooks would run on every health check. settings: { disableAllHooks: true, - ...(input.config ? claudeAccountSettings(input.config) : {}), + ...(input.config?.setupMode === "cli" + ? claudeAccountSettings(parseClaudeAccountDir(input.config.homePath)) + : {}), }, ...(input.config?.setupMode === "cli" ? { spawnClaudeCodeProcess: (options: Parameters[0]) => - spawnClaudeProcess(options, input.platform, input.config!.homePath), + spawnClaudeProcess( + options, + input.platform, + parseClaudeAccountDir(input.config!.homePath), + ), } : {}), allowedTools: [], diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.test.ts b/apps/server/src/provider/Drivers/ClaudeDriver.test.ts index 8f005773d9460..9ba658ce5a688 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.test.ts @@ -5,6 +5,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { ProviderInstanceId } from "@t3tools/contracts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -126,8 +127,16 @@ it.effect( instanceId, enabled: true, displayName: "Private", - environment: [{ name: "T3_TEST_EMAIL", value: "after@example.test", sensitive: false }], - config: { ...ClaudeDriver.defaultConfig(), setupMode: "cli", binaryPath }, + environment: [ + { name: "T3_TEST_EMAIL", value: "after@example.test", sensitive: false }, + { name: "CLAUDE_CONFIG_DIR", value: path.join(root, "shared"), sensitive: false }, + ], + config: { + ...ClaudeDriver.defaultConfig(), + setupMode: "cli", + binaryPath, + shareProfile: false, + }, }); expect((yield* instance.snapshot.refresh).auth.email).toBe("before@example.test"); expect((yield* instance.snapshot.refresh).auth.email).toBe("before@example.test"); @@ -161,3 +170,44 @@ it.effect( expect(query.mock.calls).toHaveLength(2); }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + +for (const conflict of [false, true]) { + it.effect.skipIf(!symlinksSupported)( + `materializes the profile at create with conflict=${conflict}`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { stateDir } = yield* ServerConfig.ServerConfig; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-driver-overlay-" }); + const shared = path.join(root, "shared"); + const instanceId = ProviderInstanceId.make("overlay"); + const account = path.join(stateDir, "providers", "claude", instanceId, "cli"); + yield* fs.makeDirectory(shared); + if (conflict) { + yield* fs.makeDirectory(account, { recursive: true }); + yield* fs.writeFileString(path.join(account, "settings.json"), "account"); + } + const create = ClaudeDriver.create({ + instanceId, + enabled: false, + displayName: "Overlay", + environment: [{ name: "CLAUDE_CONFIG_DIR", value: shared, sensitive: false }], + config: { ...ClaudeDriver.defaultConfig(), setupMode: "cli" }, + }); + if (conflict) { + const error = yield* create.pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "ProviderDriverError" }); + expect(error.detail).toContain(path.join(account, "settings.json")); + expect(yield* fs.readFileString(path.join(account, "settings.json"))).toBe("account"); + } else { + const instance = yield* create; + expect(yield* fs.readLink(path.join(account, "settings.json"))).toBe( + path.join(shared, "settings.json"), + ); + expect(instance.continuationIdentity.continuationKey).toBe(`claude:home:${account}`); + expect(instance.auth?.credentialBinding).toEqual({ owner: "provider", key: account }); + } + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); +} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 19a3b14e73885..4f945e47558f1 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -59,6 +59,7 @@ import { } from "@t3tools/provider-core/server/snapshotSettings"; import { makeClaudeCapabilitiesCacheKey, + materializeClaudeHome, makeClaudeContinuationGroupKey, resolveClaudeRuntime, } from "./ClaudeHome.ts"; @@ -151,6 +152,22 @@ export const ClaudeDriver: ProviderDriver< stateDir, }); const { config: effectiveConfig, environment: processEnv, configDir } = runtime; + yield* materializeClaudeHome(runtime).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + cause._tag === "PlatformError" + ? `Could not create Claude account directory '${configDir}'. Check its permissions, or turn off Use your Claude profile.` + : cause._tag === "HomeOverlayFileSystemError" + ? `${cause.message} Check filesystem permissions and symlink support, or turn off Use your Claude profile for this account.` + : `${cause.message} Move the conflicting entry away, or turn off Use your Claude profile for this account.`, + cause, + }), + ), + ); const auth = runtime.mode === "cli" ? yield* makeClaudeCliAuth({ instanceId, runtime }) : undefined; const withAccess = (task: Effect.Effect) => diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index b9eb3b806ce0a..29837febbd3a9 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -4,14 +4,22 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, makeClaudeEnvironment, + resolveClaudeRuntime, + materializeClaudeHome, resolveClaudeHomePath, } from "./ClaudeHome.ts"; +const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); + it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => @@ -47,6 +55,105 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); + for (const shareProfile of [true, false]) { + it.effect.skipIf(!symlinksSupported)( + `keeps private accounts separate with shareProfile=${shareProfile}`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); + const shared = path.join(root, "shared"); + const runtime = yield* resolveClaudeRuntime({ + instanceId: ProviderInstanceId.make("private"), + stateDir: root, + config: decodeClaudeSettings({ + setupMode: "cli", + homePath: "/ignored", + shareProfile, + }), + environment: { + CLAUDE_CONFIG_DIR: shared, + ANTHROPIC_API_KEY: "host", + CLAUDE_SECURESTORAGE_CONFIG_DIR: "", + }, + }); + const accountDir = path.join(root, "providers", "claude", "private", "cli"); + expect(runtime.config.homePath).toBe(accountDir); + expect(runtime.environment).toEqual({ + CLAUDE_CONFIG_DIR: accountDir, + CLAUDE_SECURESTORAGE_CONFIG_DIR: accountDir, + }); + expect(yield* makeClaudeContinuationGroupKey(runtime.config, runtime.environment)).toBe( + `claude:home:${accountDir}`, + ); + // Skills bind to an account; plugins can sync before the account is bound. + const buckets = { + skills: "11111111-1111-1111-1111-111111111111_22222222-2222-2222-2222-222222222222", + plugins: "11111111-1111-1111-1111-111111111111_unbound", + } as const; + for (const name of ["plugins", "skills"] as const) { + const bucket = buckets[name]; + yield* fs.makeDirectory(path.join(accountDir, name, "synced", bucket), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(accountDir, name, "synced", bucket, "skill.md"), + "skill", + ); + yield* fs.writeFileString( + path.join(accountDir, name, "synced", `.bucket-${bucket}`), + "marker", + ); + } + // Only a profile directory that exists gets a link; agents stays absent. + for (const name of ["commands", "output-styles", "rules", "hooks"]) { + yield* fs.makeDirectory(path.join(shared, name), { recursive: true }); + } + yield* materializeClaudeHome(runtime); + if (shareProfile) { + expect(yield* fs.exists(path.join(accountDir, "agents"))).toBe(false); + expect(yield* fs.exists(path.join(shared, "agents"))).toBe(false); + for (const name of [ + "plugins", + "skills", + "commands", + "output-styles", + "rules", + "hooks", + "settings.json", + "CLAUDE.md", + "keybindings.json", + ]) { + expect(yield* fs.readLink(path.join(accountDir, name))).toBe( + path.join(shared, name), + ); + } + expect(yield* fs.exists(path.join(shared, "settings.json"))).toBe(false); + for (const name of ["plugins", "skills"] as const) { + const bucket = buckets[name]; + expect( + yield* fs.readFileString(path.join(shared, name, "synced", bucket, "skill.md")), + ).toBe("skill"); + expect( + yield* fs.readFileString(path.join(shared, name, "synced", `.bucket-${bucket}`)), + ).toBe("marker"); + } + if (runtime.mode !== "cli") throw new Error("Expected CLI runtime"); + yield* materializeClaudeHome({ + ...runtime, + overlay: { ...runtime.overlay, active: false }, + }); + expect(yield* fs.readDirectory(accountDir)).toEqual([]); + } + expect(yield* fs.exists(path.join(accountDir, "projects"))).toBe(false); + expect(yield* fs.readDirectory(accountDir)).toEqual( + shareProfile ? [] : ["plugins", "skills"], + ); + }), + ); + } + it.effect("separates capability probes by cwd", () => Effect.gen(function* () { const config = { binaryPath: "claude", homePath: "" }; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 248a3084de971..400b7075881b6 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -5,6 +5,12 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { + materializeHomeOverlay, + type HomeOverlay, + type OverlayPolicy, + type OverlayFold, +} from "@t3tools/provider-core/server/homeOverlay"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -40,7 +46,61 @@ const CLAUDE_ACCOUNT_ENVIRONMENT = [ "ANTHROPIC_FOUNDRY_RESOURCE", ] as const; -export function claudeCliEnvironment(environment: NodeJS.ProcessEnv, configDir: string) { +export const ClaudeAccountDir = Schema.NonEmptyString.check( + Schema.isPattern(/^(?:\/|[A-Za-z]:[\\/]|\\\\)/), +).pipe(Schema.brand("ClaudeAccountDir")); +export type ClaudeAccountDir = typeof ClaudeAccountDir.Type; +export const parseClaudeAccountDir = Schema.decodeSync(ClaudeAccountDir); + +const SYNC_BUCKET = /^(?:\.bucket-)?[0-9a-f-]{36}_(?:[0-9a-f-]{36}|unbound)$/i; +const SYNCED_BUCKETS: OverlayFold = { + _tag: "FoldDirectory", + children: (name) => + SYNC_BUCKET.test(name) + ? { _tag: "Move", kind: name.startsWith(".bucket-") ? "file" : "directory" } + : { _tag: "Conflict" }, +}; +const CLAUDE_PROFILE_FILES = ["settings.json", "CLAUDE.md", "keybindings.json"]; +// The CLI writes into plugins and skills, so those exist before linking; the rest link once present. +const CLAUDE_PROFILE_WRITABLE_DIRECTORIES = ["plugins", "skills"]; +const CLAUDE_PROFILE_DIRECTORIES = ["agents", "commands", "output-styles", "rules", "hooks"]; +const CLAUDE_PROFILE_ENTRIES: ReturnType = [ + ...CLAUDE_PROFILE_FILES.map((name) => ({ name, adopt: { _tag: "Conflict" as const } })), + ...[...CLAUDE_PROFILE_WRITABLE_DIRECTORIES, ...CLAUDE_PROFILE_DIRECTORIES].map((name) => ({ + name, + ensureDirectory: CLAUDE_PROFILE_WRITABLE_DIRECTORIES.includes(name), + adopt: + name === "plugins" || name === "skills" + ? { + _tag: "FoldDirectory" as const, + children: (child: string): OverlayFold => + child === "synced" + ? SYNCED_BUCKETS + : { + _tag: + name === "plugins" && child === "plugin-directory-cache-v2.json" + ? "Drop" + : "Conflict", + }, + } + : { _tag: "Conflict" as const }, + })), +]; + +const CLAUDE_OVERLAY_POLICY: OverlayPolicy = { + shared: (sharedNames) => + CLAUDE_PROFILE_ENTRIES.filter( + (entry) => + entry.ensureDirectory || + !CLAUDE_PROFILE_DIRECTORIES.includes(entry.name) || + sharedNames.includes(entry.name), + ), + mustBeReal: [".claude.json", ".credentials.json", "sessions", "session-env", "backups"], + healIfLinked: [], + retired: CLAUDE_PROFILE_ENTRIES.map((entry) => entry.name), +}; + +export function claudeCliEnvironment(environment: NodeJS.ProcessEnv, configDir: ClaudeAccountDir) { const next = { ...environment }; for (const key of Object.keys(next)) { const name = key.toUpperCase(); @@ -57,9 +117,9 @@ export function claudeCliEnvironment(environment: NodeJS.ProcessEnv, configDir: } export function claudeAccountSettings( - config: Pick, + configDir: ClaudeAccountDir | undefined, ): ClaudeSdkSettings | undefined { - if (config.setupMode !== "cli") return undefined; + if (configDir === undefined) return undefined; return { forceLoginMethod: "claudeai", apiKeyHelper: "", @@ -68,18 +128,28 @@ export function claudeAccountSettings( env: { ...Object.fromEntries(CLAUDE_ACCOUNT_ENVIRONMENT.map((key) => [key, ""])), ...Object.fromEntries(CLAUDE_BACKEND_ENVIRONMENT.map((key) => [key, "0"])), - CLAUDE_CONFIG_DIR: config.homePath, - CLAUDE_SECURESTORAGE_CONFIG_DIR: config.homePath, + CLAUDE_CONFIG_DIR: configDir, + CLAUDE_SECURESTORAGE_CONFIG_DIR: configDir, }, }; } -export interface ClaudeRuntime { - readonly mode: "existing" | "cli"; - readonly config: ClaudeSettings; - readonly configDir: string; +export type ClaudeRuntime = { readonly environment: NodeJS.ProcessEnv; -} +} & ( + | { + readonly mode: "existing"; + readonly config: ClaudeSettings; + readonly configDir: string; + readonly overlay: undefined; + } + | { + readonly mode: "cli"; + readonly config: ClaudeSettings & { readonly homePath: ClaudeAccountDir }; + readonly configDir: ClaudeAccountDir; + readonly overlay: HomeOverlay; + } +); export const resolveClaudeRuntime = Effect.fn("resolveClaudeRuntime")(function* (input: { readonly instanceId: ProviderInstanceId; @@ -87,18 +157,40 @@ export const resolveClaudeRuntime = Effect.fn("resolveClaudeRuntime")(function* readonly environment: NodeJS.ProcessEnv; readonly stateDir: string; }): Effect.fn.Return { + if (input.config.setupMode !== "cli") { + return { + mode: "existing", + config: input.config, + configDir: yield* resolveClaudeHomePath(input.config, input.environment), + environment: yield* makeClaudeEnvironment(input.config, input.environment), + overlay: undefined, + }; + } const path = yield* Path.Path; - const mode = input.config.setupMode ?? "existing"; - const configDir = - mode === "cli" - ? path.join(input.stateDir, "providers", "claude", input.instanceId, "cli") - : yield* resolveClaudeHomePath(input.config, input.environment); - const config = mode === "cli" ? { ...input.config, homePath: configDir } : input.config; - const environment = - mode === "cli" - ? claudeCliEnvironment(input.environment, configDir) - : yield* makeClaudeEnvironment(config, input.environment); - return { mode, config, configDir, environment }; + const configDir = parseClaudeAccountDir( + path.join(input.stateDir, "providers", "claude", input.instanceId, "cli"), + ); + return { + mode: "cli", + config: { ...input.config, homePath: configDir }, + configDir, + environment: claudeCliEnvironment(input.environment, configDir), + overlay: { + sharedRoot: yield* resolveClaudeHomePath({ homePath: "" }, input.environment), + accountDir: configDir, + policy: CLAUDE_OVERLAY_POLICY, + active: input.config.shareProfile, + }, + }; +}); + +export const materializeClaudeHome = Effect.fn("materializeClaudeHome")(function* ( + runtime: ClaudeRuntime, +) { + if (!runtime.overlay) return; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(runtime.configDir, { recursive: true, mode: 0o700 }); + yield* materializeHomeOverlay(runtime.overlay); }); export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( diff --git a/apps/server/src/provider/Drivers/ClaudeProcess.ts b/apps/server/src/provider/Drivers/ClaudeProcess.ts index d2093815e3e66..ab5b7bf85bf6b 100644 --- a/apps/server/src/provider/Drivers/ClaudeProcess.ts +++ b/apps/server/src/provider/Drivers/ClaudeProcess.ts @@ -5,7 +5,7 @@ import type { // @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK takes a Node ChildProcess for the CLI. import * as NodeChildProcess from "node:child_process"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; -import { claudeCliEnvironment } from "./ClaudeHome.ts"; +import { claudeCliEnvironment, type ClaudeAccountDir } from "./ClaudeHome.ts"; const CLAUDE_GROUP_WATCH_SCRIPT = [ "{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } /dev/null 2>&1 &", @@ -16,7 +16,7 @@ const CLAUDE_GROUP_WATCH_SCRIPT = [ export function spawnClaudeProcess( options: ClaudeSpawnOptions, platform: NodeJS.Platform, - configDir?: string, + configDir?: ClaudeAccountDir, onExit?: () => void, onStderr?: (chunk: string) => void, ): ClaudeSpawnedProcess { diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 4ea345d8b9a48..975be6a3fbf4d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { discoverClaudeSkills } from "./ClaudeSkills.ts"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; @@ -20,60 +21,108 @@ const writeSkill = Effect.fn(function* ( }); it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { - it.effect("discovers enabled plugin skills with manifest names and ignores skill overrides", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const plugin = path.join(configDir, "plugins", "cache", "shop", "plugin", "1.0.0"); - const disabled = path.join(configDir, "plugins", "disabled"); - yield* writeSkill(path.join(disabled, "skills"), "hidden", "# Hidden"); - yield* writeSkill(path.join(plugin, "skills"), "plain", "# Plain"); - yield* writeSkill( - path.join(plugin, "engineering"), - "review", - "---\nname: review-alias\ndescription: Review changes.\ndisable-model-invocation: true\nuser-invocable: false\n---\n", - ); - yield* fs.makeDirectory(path.join(plugin, ".claude-plugin"), { recursive: true }); - yield* fs.writeFileString( - path.join(plugin, ".claude-plugin", "plugin.json"), - '{"name":"manifest-plugin","skills":["./engineering/review"]}', - ); - yield* fs.writeFileString( - path.join(configDir, "plugins", "installed_plugins.json"), - JSON.stringify({ - plugins: { - "plugin@shop": [{ scope: "user", installPath: plugin }], - "disabled@shop": [{ scope: "user", installPath: disabled }], - "unconfigured@shop": [{ scope: "user", installPath: disabled }], + it.effect.each(["current", "other-cache", "other-marketplaces", "other-existing"])( + "discovers enabled plugin skills with manifest names from %s and ignores skill overrides", + (location) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const branch = location === "other-marketplaces" ? "marketplaces" : "cache"; + // A plugins/cache segment inside the home path must not confuse the rebase. + const otherAccount = path.join(tempDir, "plugins", "cache", "other-account"); + const plugin = path.join( + location === "other-existing" ? otherAccount : configDir, + "plugins", + branch, + "shop", + "plugin", + "1.0.0", + ); + const recorded = + location === "current" || location === "other-existing" + ? plugin + : path.join(tempDir, "deleted-account", "plugins", branch, "shop", "plugin", "1.0.0"); + const disabled = path.join(configDir, "plugins", "disabled"); + yield* writeSkill(path.join(disabled, "skills"), "hidden", "# Hidden"); + yield* writeSkill(path.join(plugin, "skills"), "plain", "# Plain"); + yield* writeSkill( + path.join(plugin, "engineering"), + "review", + "---\nname: review-alias\ndescription: Review changes.\ndisable-model-invocation: true\nuser-invocable: false\n---\n", + ); + yield* fs.makeDirectory(path.join(plugin, ".claude-plugin"), { recursive: true }); + yield* fs.writeFileString( + path.join(plugin, ".claude-plugin", "plugin.json"), + '{"name":"manifest-plugin","skills":["./engineering/review"]}', + ); + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ + plugins: { + "plugin@shop": [{ scope: "user", installPath: recorded }], + "disabled@shop": [{ scope: "user", installPath: disabled }], + "unconfigured@shop": [{ scope: "user", installPath: disabled }], + }, + }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true,"disabled@shop":false,"uninstalled@shop":true,"other@shop":["^1.0.0"]},"skillOverrides":{"manifest-plugin:plain":"off"}}', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + assert.deepEqual(skills, [ + { + name: "manifest-plugin:plain", + path: path.join(plugin, "skills", "plain", "SKILL.md"), + enabled: true, + scope: "user", }, - }), - ); - yield* fs.writeFileString( - path.join(configDir, "settings.json"), - '{"enabledPlugins":{"plugin@shop":true,"disabled@shop":false,"uninstalled@shop":true,"other@shop":["^1.0.0"]},"skillOverrides":{"manifest-plugin:plain":"off"}}', - ); + { + name: "manifest-plugin:review-alias", + path: path.join(plugin, "engineering", "review", "SKILL.md"), + enabled: true, + scope: "user", + description: "Review changes.", + userInvocationOnly: true, + userInvocable: false, + }, + ]); + }), + ); - const skills = yield* discoverClaudeSkills({ homePath: configDir }); - assert.deepEqual(skills, [ - { - name: "manifest-plugin:plain", - path: path.join(plugin, "skills", "plain", "SKILL.md"), - enabled: true, - scope: "user", - }, - { - name: "manifest-plugin:review-alias", - path: path.join(plugin, "engineering", "review", "SKILL.md"), - enabled: true, - scope: "user", - description: "Review changes.", - userInvocationOnly: true, - userInvocable: false, - }, - ]); - }), + it.effect.skipIf(!symlinksSupported)( + "discovers profile plugin skills through account links", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-linked-skills-" }); + const shared = path.join(root, "shared"); + const account = path.join(root, "account"); + const plugin = path.join(shared, "plugins", "cache", "shop", "plugin", "1"); + yield* writeSkill(path.join(plugin, "skills"), "review", "# Review"); + yield* fs.writeFileString( + path.join(shared, "settings.json"), + '{"enabledPlugins":{"plugin@shop":true}}', + ); + yield* fs.writeFileString( + path.join(shared, "plugins", "installed_plugins.json"), + JSON.stringify({ + plugins: { "plugin@shop": [{ scope: "user", installPath: plugin }] }, + }), + ); + yield* fs.makeDirectory(account); + yield* fs.symlink(path.join(shared, "plugins"), path.join(account, "plugins")); + yield* fs.symlink(path.join(shared, "settings.json"), path.join(account, "settings.json")); + const skills = yield* discoverClaudeSkills({ homePath: account }); + assert.deepEqual( + skills.map((skill) => skill.name), + ["plugin:review"], + ); + }), ); it.effect("merges plugin enablement and selects installs in native registry order", () => diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 680e305f9f4d6..8c3fd5036f6a0 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -225,6 +225,17 @@ function isInsideDirectory(path: Path.Path, directory: string, candidate: string return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); } +// An install made from another account records its own plugins root; the native loader +// falls back to the same path under this root when the recorded one is gone. +function rebaseClaudePluginPath(path: Path.Path, pluginsPath: string, installPath: string) { + const match = installPath + .replaceAll("\\", "/") + .match(/^(?:.*\/)?plugins\/(cache|marketplaces)\/(.+)$/); + if (!match) return undefined; + const rebased = path.join(pluginsPath, match[1]!, match[2]!); + return rebased === path.resolve(installPath) ? undefined : rebased; +} + const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* ( configDirPath: string, cwd: string | undefined, @@ -257,7 +268,12 @@ const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* for (const [key, entries] of Object.entries(installed.plugins).sort()) { if (enabledPlugins.get(key) !== true) continue; const applicable = yield* Effect.filter( - entries.flatMap((entry) => Option.toArray(decodeInstalledPlugin(entry))), + entries + .flatMap((entry) => Option.toArray(decodeInstalledPlugin(entry))) + .flatMap((record) => { + const rebased = rebaseClaudePluginPath(path, pluginsPath, record.installPath); + return rebased === undefined ? [record] : [record, { ...record, installPath: rebased }]; + }), (record) => Effect.gen(function* () { if (record.scope === "user" || record.scope === "managed") return true; diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 65e5b23f8ca9d..8f61518f81ce7 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -3,8 +3,13 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as PlatformError from "effect/PlatformError"; +import { + materializeHomeOverlay, + type HomeOverlayError, + type OverlayAdopt, + type OverlayPolicy, +} from "@t3tools/provider-core/server/homeOverlay"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -15,7 +20,7 @@ export interface CodexHomeLayout { readonly continuationKey: string; } -const KNOWN_SHARED_DIRECTORIES = [ +const KNOWN_SHARED_DIRECTORIES = new Set([ "sessions", "archived_sessions", "sqlite", @@ -27,19 +32,48 @@ const KNOWN_SHARED_DIRECTORIES = [ "logs", "mcp-oauth-locks", "thread-writer-locks", -] as const; +]); -// auth.lock guards the shadow home's private auth.json. -const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "auth.lock", "models_cache.json"]); // Finder maintains metadata separately for each directory, including shadow homes. const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); // Shadow homes may create local runtime locks first; replace them with shared links because they guard shared state. -const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set([ - "mcp-oauth-locks", - "thread-writer-locks", - ".sqlite-maintenance.lock", +const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Map([ + ["mcp-oauth-locks", { _tag: "Replace" }], + [ + "thread-writer-locks", + { + _tag: "Replace", + // Codex deletes a thread lock on release, so one that is left may still guard a live + // writer. Keep the local directory until a later setup finds none. + defer: { + when: (lockNames) => + lockNames.some((name) => name.endsWith(".lock") && name !== ".coordination.lock"), + warning: + "Codex shadow home still has thread writer locks; it will share them on a later setup.", + }, + }, + ], + [".sqlite-maintenance.lock", { _tag: "Replace" }], ]); +const CODEX_OVERLAY_POLICY: OverlayPolicy = { + shared: (sharedNames) => + [ + ...new Set([ + ...KNOWN_SHARED_DIRECTORIES, + ...sharedNames.filter((name) => !SHADOW_LOCAL_ENTRY_NAMES.has(name)), + ]), + ].map((name) => ({ + name, + adopt: REPLACEABLE_SHARED_RUNTIME_ENTRIES.get(name) ?? { _tag: "Conflict" }, + ensureDirectory: KNOWN_SHARED_DIRECTORIES.has(name), + })), + mustBeReal: ["auth.json"], + // auth.lock guards the shadow home's private auth.json. + healIfLinked: ["auth.lock", "models_cache.json"], + retired: [], +}; + function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { const expanded = value && value.trim().length > 0 ? expandHomePath(value, home) : path.join(home, ".codex"); @@ -85,7 +119,16 @@ export class CodexShadowHomeFileSystemError extends Schema.TaggedError { - return yield* input.fileSystem.readLink(input.linkPath).pipe( - Effect.map((target): LinkState => ({ _tag: "Symlink", target })), - Effect.catchTags({ - PlatformError: (cause) => { - if (cause.reason._tag === "NotFound") { - return Effect.succeed({ _tag: "Missing" }); - } - if (isNotSymlinkError(cause)) { - return Effect.succeed({ _tag: "NotSymlink" }); - } - return new CodexShadowHomeFileSystemError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - operation: "readLink", - path: input.linkPath, - entryName: input.entryName, - cause, - }); - }, - }), - ); -}); - -const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly sharedHomePath: string; - readonly effectiveHomePath: string; - readonly entryName: string; -}): Effect.fn.Return { - const path = yield* Path.Path; - const privatePath = path.join(input.effectiveHomePath, input.entryName); - const state = yield* readLinkState({ - ...input, - linkPath: privatePath, - }); - if (state._tag === "Symlink") { - yield* input.fileSystem.remove(privatePath).pipe( - Effect.catchTags({ - PlatformError: (cause) => - new CodexShadowHomeFileSystemError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - operation: "remove", - path: privatePath, - entryName: input.entryName, - cause, - }), - }), - ); - } -}); - -const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly sharedHomePath: string; - readonly effectiveHomePath: string; - readonly entryName: string; -}): Effect.fn.Return { - const path = yield* Path.Path; - const target = path.join(input.sharedHomePath, input.entryName); - const link = path.join(input.effectiveHomePath, input.entryName); - const state = yield* readLinkState({ - ...input, - linkPath: link, - }); - - const createLink = input.fileSystem.symlink(target, link).pipe( - Effect.catchTags({ - PlatformError: (cause) => - new CodexShadowHomeFileSystemError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - operation: "symlink", - path: link, - targetPath: target, - entryName: input.entryName, - cause, - }), - }), - ); - - if (state._tag === "NotSymlink") { - if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) { - return yield* new CodexShadowHomeEntryConflictError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName: input.entryName, - linkPath: link, - targetPath: target, +function toCodexShadowHomeError(error: HomeOverlayError): CodexShadowHomeError { + const context = { + sharedHomePath: error.sharedRoot, + effectiveHomePath: error.accountDir, + }; + switch (error._tag) { + case "HomeOverlayFileSystemError": + return new CodexShadowHomeFileSystemError({ + ...context, + operation: error.operation, + path: error.path, + targetPath: error.targetPath, + entryName: error.entryName, + cause: error.cause, }); - } - - if (input.entryName === "thread-writer-locks") { - const lockNames = yield* input.fileSystem.readDirectory(link).pipe( - Effect.catchTags({ - PlatformError: (cause) => - new CodexShadowHomeFileSystemError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - operation: "readDirectory", - path: link, - entryName: input.entryName, - cause, - }), - }), - ); - // Codex deletes a thread lock on release, so one that is left may still guard a live - // writer. Keep the local directory until a later setup finds none. - if (lockNames.some((name) => name.endsWith(".lock") && name !== ".coordination.lock")) { - return yield* Effect.logWarning( - "Codex shadow home still has thread writer locks; it will share them on a later setup.", - { path: link }, - ); - } - } - - yield* input.fileSystem.remove(link, { recursive: true }).pipe( - Effect.catchTags({ - PlatformError: (cause) => - new CodexShadowHomeFileSystemError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - operation: "remove", - path: link, - entryName: input.entryName, - cause, - }), - }), - ); - return yield* createLink; - } - - if (state._tag === "Missing") { - return yield* createLink; - } - - const resolvedExisting = path.resolve(path.dirname(link), state.target); - if (resolvedExisting !== target) { - yield* input.fileSystem.remove(link).pipe( - Effect.catchTags({ - PlatformError: (cause) => - new CodexShadowHomeFileSystemError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - operation: "remove", - path: link, - entryName: input.entryName, - cause, - }), - }), - ); - yield* createLink; - } -}); - -const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")( - function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly sharedHomePath: string; - readonly effectiveHomePath: string; - }): Effect.fn.Return { - const path = yield* Path.Path; - const entryName = "auth.json"; - const authPath = path.join(input.effectiveHomePath, entryName); - const state = yield* readLinkState({ - ...input, - entryName, - linkPath: authPath, - }); - if (state._tag === "Symlink") { - return yield* new CodexShadowHomePrivateEntrySymlinkError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName, - path: authPath, + case "HomeOverlayPathConflictError": + return new CodexShadowHomePathConflictError(context); + case "HomeOverlayEntryConflictError": + return new CodexShadowHomeEntryConflictError({ + ...context, + entryName: error.entryName, + linkPath: error.linkPath, + targetPath: error.targetPath, }); - } - }, -); + case "HomeOverlayPrivateEntrySymlinkError": + return new CodexShadowHomePrivateEntrySymlinkError({ + ...context, + entryName: error.entryName, + path: error.path, + }); + } +} export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( layout: CodexHomeLayout, ) { const effectiveHomePath = layout.effectiveHomePath; if (!effectiveHomePath) return; - if (layout.mode === "authOverlay" && layout.sharedHomePath === effectiveHomePath) { - return yield* new CodexShadowHomePathConflictError({ - sharedHomePath: layout.sharedHomePath, - effectiveHomePath, - }); - } - - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const makeDirectory = (directoryPath: string) => - fileSystem.makeDirectory(directoryPath, { recursive: true }).pipe( + if (layout.mode === "direct") { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeDirectory(effectiveHomePath, { recursive: true }).pipe( Effect.catchTags({ PlatformError: (cause) => new CodexShadowHomeFileSystemError({ sharedHomePath: layout.sharedHomePath, effectiveHomePath, operation: "makeDirectory", - path: directoryPath, + path: effectiveHomePath, cause, }), }), ); - - if (layout.mode === "direct") return yield* makeDirectory(effectiveHomePath); - - yield* Effect.all( - [ - makeDirectory(layout.sharedHomePath), - makeDirectory(effectiveHomePath), - ...KNOWN_SHARED_DIRECTORIES.map((directory) => - makeDirectory(path.join(layout.sharedHomePath, directory)), - ), - ], - { concurrency: "unbounded" }, - ); - - const sharedEntryNames = yield* fileSystem.readDirectory(layout.sharedHomePath).pipe( - Effect.catchTags({ - PlatformError: (cause) => - new CodexShadowHomeFileSystemError({ - sharedHomePath: layout.sharedHomePath, - effectiveHomePath, - operation: "readDirectory", - path: layout.sharedHomePath, - cause, - }), - }), - ); - const entries = new Set(KNOWN_SHARED_DIRECTORIES); - for (const entryName of sharedEntryNames) { - if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) { - entries.add(entryName); - } } - yield* Effect.forEach( - PRIVATE_ENTRY_NAMES, - (entryName) => - entryName === "auth.json" - ? Effect.void - : removePrivateSymlink({ - fileSystem, - sharedHomePath: layout.sharedHomePath, - effectiveHomePath, - entryName, - }), - { discard: true }, - ); - - yield* Effect.forEach( - entries, - (entryName) => { - if (PRIVATE_ENTRY_NAMES.has(entryName)) { - return Effect.void; - } - return ensureSymlink({ - fileSystem, - sharedHomePath: layout.sharedHomePath, - effectiveHomePath, - entryName, - }); - }, - { discard: true }, - ); - - yield* ensureShadowAuthIsPrivate({ - fileSystem, - sharedHomePath: layout.sharedHomePath, - effectiveHomePath, - }); + yield* materializeHomeOverlay({ + sharedRoot: layout.sharedHomePath, + accountDir: effectiveHomePath, + policy: CODEX_OVERLAY_POLICY, + active: true, + }).pipe(Effect.mapError(toCodexShadowHomeError)); }); export function codexContinuationIdentity(layout: CodexHomeLayout) { diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index f2e938b8f71f6..b4ee50fee4add 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -100,6 +100,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => enabled: false, binaryPath: "claude", homePath: "", + shareProfile: true, customModels: [], launchArgs: "", autoCompactWindow: "", diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 856ffe9056a46..f0fbe2f1bfd0d 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 7975, "codexAdditionalContextBytes": 9701, - "mcpToolListJsonBytes": 60481 + "mcpToolListJsonBytes": 60654 } diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index dd4e49f16eb22..9ad81ac1b693b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1516,7 +1516,10 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( environment: resolved, stateDir: input.stateDir, }).pipe(Effect.provideService(Path.Path, input.path)); - resolved = { ...runtime.environment, ...claudeAccountSettings(runtime.config)?.env }; + resolved = { + ...runtime.environment, + ...claudeAccountSettings(runtime.mode === "cli" ? runtime.configDir : undefined)?.env, + }; } } diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index d0187d42f5f92..d5e4bfdf44a31 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -31,6 +31,7 @@ import { } from "../provider/ClaudeModelCatalog.ts"; import { claudeAccountSettings, + parseClaudeAccountDir, hasClaudeManagedMcpConfig, makeClaudeEnvironment, } from "../provider/Drivers/ClaudeHome.ts"; @@ -175,7 +176,11 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), - ...claudeAccountSettings(claudeSettings), + ...claudeAccountSettings( + claudeSettings.setupMode === "cli" + ? parseClaudeAccountDir(claudeSettings.homePath) + : undefined, + ), }; const settingsJson = yield* encodeJsonForOperation( operation, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 35cb837a07dda..61866ad589c78 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -170,7 +170,8 @@ const CONNECTION_OPTIONS = { { value: "cli", label: "Sign in with Claude Code", - description: "Add an account with its own Claude settings and history.", + description: + "Add an account with its own login and history that shares your Claude settings, skills, and plugins.", icon: TerminalIcon, }, { diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 0d408e59fee84..6735408b44fca 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -41,20 +41,18 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ forkOwned: 1 }); }); - it("stores false boolean fields when true is the default", () => { - const next = nextProviderConfigWithFieldValue( - undefined, - { - key: "experimental", - control: "switch", - label: "Experimental", - clearWhenEmpty: "omit", - defaultBooleanValue: true, - }, - false, - ); - - expect(next).toEqual({ experimental: false }); + it("shows the profile switch only to signed-in Claude accounts and stores it off", () => { + const claude = providerClients.get(ProviderDriverKind.make("claudeAgent"))!; + const cliFields = deriveProviderSettingsFields(claude, { setupMode: "cli" }); + const shareProfile = cliFields.find((field) => field.key === "shareProfile")!; + expect(shareProfile.defaultBooleanValue).toBe(true); + expect(cliFields.some((field) => field.key === "homePath")).toBe(false); + const existingFields = deriveProviderSettingsFields(claude); + expect(existingFields.some((field) => field.key === "shareProfile")).toBe(false); + expect(existingFields.some((field) => field.key === "homePath")).toBe(true); + expect(nextProviderConfigWithFieldValue(undefined, shareProfile, false)).toEqual({ + shareProfile: false, + }); }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 92c89d74e29cc..9ff0fe9e9e75d 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -109,8 +109,8 @@ export function deriveProviderSettingsFields( if (formAnnotation.hidden) return []; if ( definition.driverKind === "claudeAgent" && - readProviderConfigString(value, "setupMode") === "cli" && - key === "homePath" + ((readProviderConfigString(value, "setupMode") === "cli" && key === "homePath") || + (readProviderConfigString(value, "setupMode") !== "cli" && key === "shareProfile")) ) return []; if (isLocalAcp && key !== "source" && key !== "commandPath") return []; diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 0b46bde64a340..f4e3ac9310f3f 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -12,9 +12,13 @@ code from Claude into T3 Code. You can sign in from another device when the T3 Code environment runs remotely. To sign out, open that account's provider settings. -Each account has its own Claude configuration and conversation history. T3 Code -does not copy global settings, skills, or plugins from your existing Claude -installation. The account can still use project instructions and project skills. +Accounts added with **Sign in with Claude Code** share your Claude profile in +`~/.claude`, including settings, skills, plugins, agents, and `CLAUDE.md`. Changes +from any account, such as enabling a plugin or allowing a tool, apply to all +accounts. Each account keeps its own login, trust decisions, MCP servers, and +conversation history. Accounts added before this version start sharing too. Turn +off **Use your Claude profile** in an account's provider settings to give it a +profile of its own. To reuse an installed CLI account, choose **Use existing Claude Code**. Leave **CLAUDE_CONFIG_DIR path** empty to use the CLI's default configuration. For a diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 9d1caa79e6871..a254f99cb8008 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -581,7 +581,13 @@ describe("machine-local provider settings", () => { }); it("marks home and binary paths on Claude", () => { - expect(machineLocalSettingsFields(ClaudeSettings)).toEqual(["binaryPath", "homePath"]); + expect(machineLocalSettingsFields(ClaudeSettings)).toEqual([ + "binaryPath", + "homePath", + "shareProfile", + ]); + expect(decodeClaudeSettings({ setupMode: "cli" }).shareProfile).toBe(true); + expect(decodeClaudeSettings({ shareProfile: false }).shareProfile).toBe(false); }); it("keeps secret and machine-local classifications apart", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ac6b94630ba74..6a17501f87f54 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -760,6 +760,16 @@ export const ClaudeSettings = makeProviderSettingsSchema( providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, }), ), + shareProfile: Schema.Boolean.pipe( + machineLocalSetting, + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Use your Claude profile", + description: + "Shares settings, plugins, skills, agents, commands and CLAUDE.md from ~/.claude. Changes made from this account apply to all accounts. Login, trust decisions, MCP servers and conversation history stay with this account.", + providerSettingsForm: { control: "switch" }, + }), + ), customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), @@ -804,7 +814,14 @@ export const ClaudeSettings = makeProviderSettingsSchema( ), }, { - order: ["binaryPath", "homePath", "autoCompactWindow", "idleCompaction", "launchArgs"], + order: [ + "binaryPath", + "homePath", + "shareProfile", + "autoCompactWindow", + "idleCompaction", + "launchArgs", + ], }, ); export type ClaudeSettings = typeof ClaudeSettings.Type; diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index ab93310316ac4..25f672566214c 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -47,6 +47,10 @@ "types": "./src/server/IdAllocator.ts", "import": "./src/server/IdAllocator.ts" }, + "./server/homeOverlay": { + "types": "./src/server/homeOverlay.ts", + "import": "./src/server/homeOverlay.ts" + }, "./server/instanceEnvironment": { "types": "./src/server/instanceEnvironment.ts", "import": "./src/server/instanceEnvironment.ts" diff --git a/packages/provider-core/src/server/homeOverlay.test.ts b/packages/provider-core/src/server/homeOverlay.test.ts new file mode 100644 index 0000000000000..440fe7783bf9b --- /dev/null +++ b/packages/provider-core/src/server/homeOverlay.test.ts @@ -0,0 +1,313 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { materializeHomeOverlay, type OverlayPolicy } from "./homeOverlay.ts"; + +const allowListPolicy: OverlayPolicy = { + shared: () => [ + { name: "settings.json", adopt: { _tag: "Conflict" } }, + { name: "plugins", adopt: { _tag: "Conflict" }, ensureDirectory: true }, + { name: "credentials.json", adopt: { _tag: "Conflict" } }, + ], + mustBeReal: ["credentials.json"], + healIfLinked: [], + retired: ["settings.json", "plugins"], +}; + +const makeHomes = Effect.fn("homeOverlay.test.makeHomes")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-home-overlay-" }); + return { sharedRoot: path.join(root, "shared"), accountDir: path.join(root, "account") }; +}); + +const linkTarget = Effect.fn("homeOverlay.test.linkTarget")(function* (linkPath: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.readLink(linkPath).pipe(Effect.orElseSucceed(() => undefined)); +}); + +it.layer(NodeServices.layer)("materializeHomeOverlay", (it) => { + describe("active overlay", () => { + it.effect.skipIf(!symlinksSupported)( + "links only the policy's entries and never its private names", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { sharedRoot, accountDir } = yield* makeHomes(); + yield* fileSystem.makeDirectory(path.join(sharedRoot, "projects"), { recursive: true }); + + yield* materializeHomeOverlay({ + sharedRoot, + accountDir, + policy: allowListPolicy, + active: true, + }); + + expect(yield* linkTarget(path.join(accountDir, "settings.json"))).toBe( + path.join(sharedRoot, "settings.json"), + ); + expect(yield* fileSystem.exists(path.join(sharedRoot, "settings.json"))).toBe(false); + expect(yield* linkTarget(path.join(accountDir, "plugins"))).toBe( + path.join(sharedRoot, "plugins"), + ); + expect((yield* fileSystem.stat(path.join(sharedRoot, "plugins"))).type).toBe("Directory"); + expect(yield* fileSystem.exists(path.join(accountDir, "projects"))).toBe(false); + expect(yield* linkTarget(path.join(accountDir, "credentials.json"))).toBeUndefined(); + }), + ); + + it.effect.skipIf(!symlinksSupported)("rejects a linked private entry before linking", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { sharedRoot, accountDir } = yield* makeHomes(); + yield* fileSystem.makeDirectory(accountDir, { recursive: true }); + yield* fileSystem.symlink( + path.join(sharedRoot, "credentials.json"), + path.join(accountDir, "credentials.json"), + ); + + const error = yield* materializeHomeOverlay({ + sharedRoot, + accountDir, + policy: allowListPolicy, + active: true, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "HomeOverlayPrivateEntrySymlinkError", + entryName: "credentials.json", + }); + expect(yield* fileSystem.exists(path.join(accountDir, "plugins"))).toBe(false); + }), + ); + + it.effect.skipIf(!symlinksSupported)("changes nothing when run again", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { sharedRoot, accountDir } = yield* makeHomes(); + const overlay = { sharedRoot, accountDir, policy: allowListPolicy, active: true }; + yield* materializeHomeOverlay(overlay); + + const mutations: Array = []; + const recordingFileSystem: FileSystem.FileSystem = { + ...fileSystem, + symlink: (target, link) => { + mutations.push(`symlink ${link}`); + return fileSystem.symlink(target, link); + }, + remove: (target, options) => { + mutations.push(`remove ${target}`); + return fileSystem.remove(target, options); + }, + }; + yield* materializeHomeOverlay(overlay).pipe( + Effect.provideService(FileSystem.FileSystem, recordingFileSystem), + ); + + expect(mutations).toEqual([]); + }), + ); + + it.effect.skipIf(!symlinksSupported)("accepts a link a concurrent setup created first", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { sharedRoot, accountDir } = yield* makeHomes(); + const racingFileSystem: FileSystem.FileSystem = { + ...fileSystem, + symlink: (target, link) => + fileSystem.symlink(target, link).pipe(Effect.andThen(fileSystem.symlink(target, link))), + }; + + yield* materializeHomeOverlay({ + sharedRoot, + accountDir, + policy: allowListPolicy, + active: true, + }).pipe(Effect.provideService(FileSystem.FileSystem, racingFileSystem)); + + expect(yield* linkTarget(path.join(accountDir, "plugins"))).toBe( + path.join(sharedRoot, "plugins"), + ); + }), + ); + }); + + describe("directory adoption", () => { + const policy: OverlayPolicy = { + ...allowListPolicy, + shared: () => [ + { name: "settings.json", adopt: { _tag: "Conflict" } }, + { + name: "plugins", + ensureDirectory: true, + adopt: { + _tag: "FoldDirectory", + children: (name) => + name === "synced" + ? { + _tag: "FoldDirectory", + children: (child) => ({ + _tag: "Move", + kind: child.startsWith(".bucket-") ? "file" : "directory", + }), + } + : name === "plugin-directory-cache-v2.json" + ? { _tag: "Drop" } + : { _tag: "Conflict" }, + }, + }, + ], + }; + + it.effect.skipIf(!symlinksSupported)( + "folds buckets and markers and converges after a published file", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homes = yield* makeHomes(); + const local = path.join(homes.accountDir, "plugins"); + const shared = path.join(homes.sharedRoot, "plugins"); + yield* fs.makeDirectory(path.join(local, "synced", "org_account"), { recursive: true }); + yield* fs.makeDirectory(path.join(shared, "synced"), { recursive: true }); + yield* fs.writeFileString(path.join(local, "synced", "org_account", "skill.md"), "skill"); + yield* fs.writeFileString(path.join(local, "synced", ".bucket-org_account"), "marker"); + yield* fs.link( + path.join(local, "synced", ".bucket-org_account"), + path.join(shared, "synced", ".bucket-org_account"), + ); + yield* fs.writeFileString(path.join(local, "plugin-directory-cache-v2.json"), "cache"); + const overlay = { ...homes, policy, active: true }; + yield* materializeHomeOverlay(overlay); + yield* materializeHomeOverlay(overlay); + expect(yield* fs.readLink(local)).toBe(shared); + expect( + yield* fs.readFileString(path.join(shared, "synced", "org_account", "skill.md")), + ).toBe("skill"); + expect(yield* fs.readFileString(path.join(shared, "synced", ".bucket-org_account"))).toBe( + "marker", + ); + expect(yield* fs.exists(path.join(shared, "plugin-directory-cache-v2.json"))).toBe(false); + }), + ); + + for (const peer of ["file", "symlink"]) { + it.effect.skipIf(!symlinksSupported)( + `does not overwrite a marker ${peer} published by a peer`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homes = yield* makeHomes(); + const source = path.join(homes.accountDir, "plugins", "synced", ".bucket-org_account"); + const target = path.join(homes.sharedRoot, "plugins", "synced", ".bucket-org_account"); + yield* fs.makeDirectory(path.dirname(source), { recursive: true }); + yield* fs.writeFileString(source, "account"); + const racing: FileSystem.FileSystem = { + ...fs, + link: (from, to) => + (peer === "file" ? fs.writeFileString(to, "peer") : fs.symlink(from, to)).pipe( + Effect.andThen(fs.link(from, to)), + ), + }; + const error = yield* materializeHomeOverlay({ ...homes, policy, active: true }).pipe( + Effect.provideService(FileSystem.FileSystem, racing), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "HomeOverlayEntryConflictError", + linkPath: source, + }); + expect(yield* fs.readFileString(source)).toBe("account"); + if (peer === "file") expect(yield* fs.readFileString(target)).toBe("peer"); + else expect(yield* fs.readLink(target)).toBe(source); + }), + ); + } + + for (const entry of [ + "settings.json", + "plugins/installed_plugins.json", + "plugins/synced/.bucket-org_account", + "plugins/synced/org_account", + ]) { + it.effect.skipIf(!symlinksSupported)( + `preserves conflicting ${entry} without overwriting shared content`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homes = yield* makeHomes(); + const source = path.join(homes.accountDir, entry); + const target = path.join(homes.sharedRoot, entry); + yield* fs.makeDirectory(path.dirname(source), { recursive: true }); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + const directory = entry.endsWith("/org_account"); + if (directory) { + yield* fs.makeDirectory(source); + yield* fs.makeDirectory(target); + } + const sourceFile = directory ? path.join(source, "skill.md") : source; + const targetFile = directory ? path.join(target, "skill.md") : target; + yield* fs.writeFileString(sourceFile, "account"); + yield* fs.writeFileString(targetFile, "shared"); + const error = yield* materializeHomeOverlay({ ...homes, policy, active: true }).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "HomeOverlayEntryConflictError", + linkPath: source, + }); + expect(yield* fs.readFileString(sourceFile)).toBe("account"); + expect(yield* fs.readFileString(targetFile)).toBe("shared"); + }), + ); + } + }); + + describe("inactive overlay", () => { + it.effect.skipIf(!symlinksSupported)("retires only links into the shared root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { sharedRoot, accountDir } = yield* makeHomes(); + const elsewhere = path.join(path.dirname(sharedRoot), "elsewhere"); + yield* materializeHomeOverlay({ + sharedRoot, + accountDir, + policy: allowListPolicy, + active: true, + }); + yield* fileSystem.remove(path.join(accountDir, "settings.json")); + yield* fileSystem.symlink( + path.join(elsewhere, "settings.json"), + path.join(accountDir, "settings.json"), + ); + yield* fileSystem.symlink(path.join(sharedRoot, "agents"), path.join(accountDir, "agents")); + + yield* materializeHomeOverlay({ + sharedRoot, + accountDir, + policy: allowListPolicy, + active: false, + }); + + expect(yield* fileSystem.exists(path.join(accountDir, "plugins"))).toBe(false); + expect(yield* fileSystem.exists(path.join(sharedRoot, "plugins"))).toBe(true); + expect(yield* linkTarget(path.join(accountDir, "settings.json"))).toBe( + path.join(elsewhere, "settings.json"), + ); + expect(yield* linkTarget(path.join(accountDir, "agents"))).toBe( + path.join(sharedRoot, "agents"), + ); + }), + ); + }); +}); diff --git a/packages/provider-core/src/server/homeOverlay.ts b/packages/provider-core/src/server/homeOverlay.ts new file mode 100644 index 0000000000000..2936e5f6f7a4a --- /dev/null +++ b/packages/provider-core/src/server/homeOverlay.ts @@ -0,0 +1,435 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem has no lstat or nonrecursive directory removal. +import * as NodeFSP from "node:fs/promises"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +export type OverlayFold = + | { readonly _tag: "Conflict" } + | { readonly _tag: "Drop" } + | { readonly _tag: "Move"; readonly kind: "file" | "directory" } + | { readonly _tag: "FoldDirectory"; readonly children: (name: string) => OverlayFold }; + +export type OverlayAdopt = + | Extract + | { readonly _tag: "Conflict" } + | { + readonly _tag: "Replace"; + readonly defer?: { + readonly when: (childNames: ReadonlyArray) => boolean; + readonly warning: string; + }; + }; + +interface OverlayEntry { + readonly name: string; + readonly adopt: OverlayAdopt; + /** Created in the shared root first so the link never dangles. */ + readonly ensureDirectory?: boolean; +} + +export interface OverlayPolicy { + /** Entries to link, given the shared root's current names. Private names are never linked. */ + readonly shared: (sharedNames: ReadonlyArray) => ReadonlyArray; + /** A symlink here is a hard error, checked before any link changes. */ + readonly mustBeReal: ReadonlyArray; + /** A symlink here is derived state from an older layout and is removed. */ + readonly healIfLinked: ReadonlyArray; + /** Links removed while the overlay is inactive, only when they point at the shared root's entry. */ + readonly retired: ReadonlyArray; +} + +export interface HomeOverlay { + readonly sharedRoot: string; + readonly accountDir: string; + readonly policy: OverlayPolicy; + readonly active: boolean; +} + +const HomeOverlayContext = { + sharedRoot: Schema.String, + accountDir: Schema.String, +}; + +export class HomeOverlayFileSystemError extends Schema.TaggedError()( + "HomeOverlayFileSystemError", + { + ...HomeOverlayContext, + operation: Schema.Literals([ + "readLink", + "makeDirectory", + "readDirectory", + "remove", + "symlink", + "stat", + "link", + "rename", + ]), + path: Schema.String, + targetPath: Schema.optional(Schema.String), + entryName: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const target = this.targetPath === undefined ? "" : ` to '${this.targetPath}'`; + return `Home overlay filesystem operation '${this.operation}' failed for '${this.path}'${target}.`; + } +} + +export class HomeOverlayPathConflictError extends Schema.TaggedError()( + "HomeOverlayPathConflictError", + HomeOverlayContext, +) { + override get message(): string { + return `Home overlay path '${this.accountDir}' must be different from the shared root '${this.sharedRoot}'.`; + } +} + +export class HomeOverlayEntryConflictError extends Schema.TaggedError()( + "HomeOverlayEntryConflictError", + { + ...HomeOverlayContext, + entryName: Schema.String, + linkPath: Schema.String, + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Cannot link home overlay entry '${this.entryName}' because '${this.linkPath}' already exists and is not a symlink.`; + } +} + +export class HomeOverlayPrivateEntrySymlinkError extends Schema.TaggedError()( + "HomeOverlayPrivateEntrySymlinkError", + { + ...HomeOverlayContext, + entryName: Schema.String, + path: Schema.String, + }, +) { + override get message(): string { + return `Home overlay private entry '${this.entryName}' at '${this.path}' must be a real file, not a symlink.`; + } +} + +export type HomeOverlayError = + | HomeOverlayFileSystemError + | HomeOverlayPathConflictError + | HomeOverlayEntryConflictError + | HomeOverlayPrivateEntrySymlinkError; + +type LinkState = + | { readonly _tag: "Missing" } + | { readonly _tag: "NotSymlink" } + | { readonly _tag: "Symlink"; readonly target: string }; + +function isNotSymlinkError(error: PlatformError.PlatformError): boolean { + const cause = error.reason.cause; + return ( + error.reason._tag === "Unknown" && + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "EINVAL" + ); +} + +export const materializeHomeOverlay = Effect.fn("materializeHomeOverlay")(function* ( + overlay: HomeOverlay, +): Effect.fn.Return { + const { sharedRoot, accountDir, policy } = overlay; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const fileSystemError = + ( + operation: HomeOverlayFileSystemError["operation"], + filePath: string, + details: { readonly entryName?: string; readonly targetPath?: string } = {}, + ) => + (cause: PlatformError.PlatformError) => + new HomeOverlayFileSystemError({ + sharedRoot, + accountDir, + operation, + path: filePath, + ...details, + cause, + }); + + const readLinkState = (entryName: string, linkPath: string) => + fileSystem.readLink(linkPath).pipe( + Effect.map((target): LinkState => ({ _tag: "Symlink", target })), + Effect.catchTags({ + PlatformError: (cause) => { + if (cause.reason._tag === "NotFound") { + return Effect.succeed({ _tag: "Missing" }); + } + if (isNotSymlinkError(cause)) { + return Effect.succeed({ _tag: "NotSymlink" }); + } + return Effect.fail(fileSystemError("readLink", linkPath, { entryName })(cause)); + }, + }), + ); + + const remove = (entryName: string, linkPath: string, options?: { recursive: boolean }) => + fileSystem + .remove(linkPath, options) + .pipe(Effect.mapError(fileSystemError("remove", linkPath, { entryName }))); + + const makeDirectory = (directoryPath: string) => + fileSystem + .makeDirectory(directoryPath, { recursive: true }) + .pipe(Effect.mapError(fileSystemError("makeDirectory", directoryPath))); + + const pointsAt = (linkPath: string, state: LinkState, target: string) => + state._tag === "Symlink" && path.resolve(path.dirname(linkPath), state.target) === target; + + if (!overlay.active) { + return yield* Effect.forEach( + policy.retired, + Effect.fn("materializeHomeOverlay.retire")(function* (entryName) { + const linkPath = path.join(accountDir, entryName); + const state = yield* readLinkState(entryName, linkPath); + if (pointsAt(linkPath, state, path.join(sharedRoot, entryName))) { + yield* remove(entryName, linkPath); + } + }), + { discard: true }, + ); + } + + if (sharedRoot === accountDir) { + return yield* new HomeOverlayPathConflictError({ sharedRoot, accountDir }); + } + + const fold = Effect.fn("materializeHomeOverlay.fold")(function* ( + source: string, + target: string, + rule: OverlayFold, + ): Effect.fn.Return { + const entryName = path.relative(accountDir, source); + const conflict = new HomeOverlayEntryConflictError({ + sharedRoot, + accountDir, + entryName, + linkPath: source, + targetPath: target, + }); + const sourceState = yield* readLinkState(entryName, source); + if (sourceState._tag === "Missing") return; + if (sourceState._tag === "Symlink" || rule._tag === "Conflict") return yield* conflict; + const info = yield* fileSystem + .stat(source) + .pipe(Effect.mapError(fileSystemError("stat", source))); + if (rule._tag === "Drop") { + if (info.type !== "File") return yield* conflict; + return yield* remove(entryName, source); + } + const targetState = yield* readLinkState(entryName, target); + if (rule._tag === "Move") { + if (info.type !== (rule.kind === "directory" ? "Directory" : "File")) return yield* conflict; + if (info.type === "Directory") { + if (targetState._tag !== "Missing") return yield* conflict; + const missing = yield* Effect.tryPromise({ + try: () => + NodeFSP.lstat(target).then( + () => false, + (cause: NodeJS.ErrnoException) => { + if (cause.code === "ENOENT") return true; + throw cause; + }, + ), + catch: (cause) => + new HomeOverlayFileSystemError({ + sharedRoot, + accountDir, + operation: "stat", + path: target, + cause, + }), + }); + if (!missing) return yield* conflict; + return yield* fileSystem.rename(source, target).pipe( + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "AlreadyExists" + ? Effect.fail(conflict) + : Effect.fail(fileSystemError("rename", source, { targetPath: target })(cause)), + }), + ); + } + if (info.type !== "File" || targetState._tag === "Symlink") return yield* conflict; + const sameFile = Effect.tryPromise({ + try: () => NodeFSP.lstat(target, { bigint: true }), + catch: (cause) => + new HomeOverlayFileSystemError({ + sharedRoot, + accountDir, + operation: "stat", + path: target, + cause, + }), + }).pipe( + Effect.map( + (destination) => + destination.isFile() && + info.dev === destination.dev && + Option.isSome(info.ino) && + info.ino.value === destination.ino, + ), + ); + if (targetState._tag !== "Missing") { + if (!(yield* sameFile)) return yield* conflict; + } else { + yield* fileSystem.link(source, target).pipe( + Effect.catchTags({ + PlatformError: Effect.fn("materializeHomeOverlay.acceptPublishedFile")( + function* (cause) { + if (cause.reason._tag === "AlreadyExists") { + if (yield* sameFile) return; + return yield* conflict; + } + return yield* fileSystemError("link", source, { targetPath: target })(cause); + }, + ), + }), + ); + } + return yield* remove(entryName, source); + } + if (info.type !== "Directory" || targetState._tag === "Symlink") return yield* conflict; + if (targetState._tag === "NotSymlink") { + const destination = yield* fileSystem + .stat(target) + .pipe(Effect.mapError(fileSystemError("stat", target))); + if (destination.type !== "Directory") return yield* conflict; + } + yield* makeDirectory(target); + const children = yield* fileSystem + .readDirectory(source) + .pipe(Effect.mapError(fileSystemError("readDirectory", source))); + for (const name of children) { + yield* fold(path.join(source, name), path.join(target, name), rule.children(name)); + } + yield* Effect.tryPromise({ + try: () => NodeFSP.rmdir(source), + catch: (cause) => + new HomeOverlayFileSystemError({ + sharedRoot, + accountDir, + operation: "remove", + path: source, + cause, + }), + }); + }); + + const ensureLink = Effect.fn("materializeHomeOverlay.ensureLink")(function* ( + entry: OverlayEntry, + ) { + const target = path.join(sharedRoot, entry.name); + const link = path.join(accountDir, entry.name); + const state = yield* readLinkState(entry.name, link); + if (pointsAt(link, state, target)) return; + + const symlinkError = fileSystemError("symlink", link, { + entryName: entry.name, + targetPath: target, + }); + const createLink = fileSystem.symlink(target, link).pipe( + Effect.catchTags({ + PlatformError: Effect.fn("materializeHomeOverlay.acceptRacedLink")(function* ( + cause: PlatformError.PlatformError, + ) { + // A peer setup for the same home may have linked it first. + if (cause.reason._tag === "AlreadyExists") { + if (pointsAt(link, yield* readLinkState(entry.name, link), target)) return; + } + return yield* symlinkError(cause); + }), + }), + ); + + if (state._tag === "Missing") return yield* createLink; + if (state._tag === "Symlink") { + yield* remove(entry.name, link); + return yield* createLink; + } + + if (entry.adopt._tag === "Conflict") { + return yield* new HomeOverlayEntryConflictError({ + sharedRoot, + accountDir, + entryName: entry.name, + linkPath: link, + targetPath: target, + }); + } + if (entry.adopt._tag === "FoldDirectory") { + yield* fold(link, target, entry.adopt); + return yield* createLink; + } + const defer = entry.adopt.defer; + if (defer) { + const childNames = yield* fileSystem + .readDirectory(link) + .pipe(Effect.mapError(fileSystemError("readDirectory", link, { entryName: entry.name }))); + if (defer.when(childNames)) { + return yield* Effect.logWarning(defer.warning, { path: link }); + } + } + yield* remove(entry.name, link, { recursive: true }); + yield* createLink; + }); + + yield* Effect.all([makeDirectory(sharedRoot), makeDirectory(accountDir)], { + concurrency: "unbounded", + }); + + yield* Effect.forEach( + policy.mustBeReal, + Effect.fn("materializeHomeOverlay.rejectLinkedPrivateEntry")(function* (entryName) { + const entryPath = path.join(accountDir, entryName); + const state = yield* readLinkState(entryName, entryPath); + if (state._tag === "Symlink") { + return yield* new HomeOverlayPrivateEntrySymlinkError({ + sharedRoot, + accountDir, + entryName, + path: entryPath, + }); + } + }), + { discard: true }, + ); + + const sharedNames = yield* fileSystem + .readDirectory(sharedRoot) + .pipe(Effect.mapError(fileSystemError("readDirectory", sharedRoot))); + const privateNames = new Set([...policy.mustBeReal, ...policy.healIfLinked]); + const entries = policy.shared(sharedNames).filter((entry) => !privateNames.has(entry.name)); + + yield* Effect.forEach( + entries.filter((entry) => entry.ensureDirectory), + (entry) => makeDirectory(path.join(sharedRoot, entry.name)), + { concurrency: "unbounded", discard: true }, + ); + + yield* Effect.forEach( + policy.healIfLinked, + Effect.fn("materializeHomeOverlay.healLinkedPrivateEntry")(function* (entryName) { + const entryPath = path.join(accountDir, entryName); + const state = yield* readLinkState(entryName, entryPath); + if (state._tag === "Symlink") yield* remove(entryName, entryPath); + }), + { discard: true }, + ); + + yield* Effect.forEach(entries, ensureLink, { discard: true }); +});