From 58df35b5187753f5f3de7499ef1287785ca6fbcc Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 22:09:27 -0700 Subject: [PATCH] feat: share managed provider accounts through a private T3 server Clover can select any shared Codex or Claude account on an execution host without signing in on every machine. A normal private T3 server coordinates fenced refresh claims and keeps refresh credentials out of native Codex homes. Claude setup tokens and shared session directories live in T3-owned homes; ordinary profiles stay separate and sharing remains opt-in. Run the server and provider CLIs in a container with persistent private state, provider homes, archives, and workspaces. Distribute a loadable Linux image with the canonical Compose configuration and public-feed example so infra can deploy without a source checkout or registry. Disable repository hooks and the unused compiler launcher probe during container builds. The image bootstrap downloads missing verified server/UI/assets archives and prefetches updates while running; code activation waits for a confirmed restart. Issue a separate revocable authority credential to each execution host. Keep account secrets away from the public build share. Scoped tests exercise refresh takeover, lost commit acknowledgements, private storage, permissions, reconnection, and real Claude process environment injection. Assisted-by: gpt-6.1-sol --- .dockerignore | 33 ++ Dockerfile | 32 ++ apps/server/src/binCli.ts | 2 + apps/server/src/cli/shared.ts | 121 +++++++ apps/server/src/http.ts | 81 ++++- .../Adapters/CodexAdapterV2.ts | 6 +- .../src/provider/Drivers/ClaudeDriver.ts | 57 +++- .../src/provider/Drivers/CodexDriver.ts | 107 +++++- .../src/provider/Drivers/claudeUsage.ts | 11 +- .../server/src/provider/Drivers/codexUsage.ts | 11 +- .../src/provider/ManagedProviderHome.ts | 139 ++++++++ .../provider/ProviderInstanceRegistry.test.ts | 97 +++++- .../src/provider/SharedAccountClient.test.ts | 192 +++++++++++ .../src/provider/SharedAccountClient.ts | 322 ++++++++++++++++++ .../src/provider/SharedAccounts.test.ts | 207 +++++++++++ apps/server/src/provider/SharedAccounts.ts | 223 ++++++++++++ .../src/provider/SharedAccountsHttp.test.ts | 70 ++++ apps/server/src/provider/SharedCodexAuth.ts | 33 ++ .../src/provider/SharedProfiles.test.ts | 308 +++++++++++++++++ apps/server/src/provider/SharedProfiles.ts | 237 +++++++++++++ .../ProviderInstanceRegistryLive.fixture.mjs | 10 + apps/server/src/server.ts | 28 ++ .../settings/ProviderSettingsForm.test.ts | 8 + .../settings/ProviderSettingsForm.tsx | 2 + compose.yaml | 22 ++ docs/user/shared-server.md | 38 +++ packages/contracts/src/index.ts | 1 + packages/contracts/src/settings.ts | 11 + packages/contracts/src/sharedAccounts.ts | 153 +++++++++ 29 files changed, 2535 insertions(+), 27 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 apps/server/src/cli/shared.ts create mode 100644 apps/server/src/provider/ManagedProviderHome.ts create mode 100644 apps/server/src/provider/SharedAccountClient.test.ts create mode 100644 apps/server/src/provider/SharedAccountClient.ts create mode 100644 apps/server/src/provider/SharedAccounts.test.ts create mode 100644 apps/server/src/provider/SharedAccounts.ts create mode 100644 apps/server/src/provider/SharedAccountsHttp.test.ts create mode 100644 apps/server/src/provider/SharedCodexAuth.ts create mode 100644 apps/server/src/provider/SharedProfiles.test.ts create mode 100644 apps/server/src/provider/SharedProfiles.ts create mode 100644 compose.yaml create mode 100644 docs/user/shared-server.md create mode 100644 packages/contracts/src/sharedAccounts.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..d65e2935f08790af1d5e420afb12aa81aa1b5442 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +**/node_modules +**/dist +**/dist-* +**/.vite-plus +**/.t3 +.jj +.git +.repos +.plans +.env +.env.* +**/.env +**/.env.* +**/*.log +**/*.sqlite +**/*.sqlite-* +**/.DS_Store +artifacts +release +releases +tmp +build +.logs +release-mock +.pnpm-store +.electron-runtime +.showcase +**/target +**/.generated +**/.showcase +**/.playwright +**/playwright-report +.audits diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a5285a1b27a211385e8f11421e1e39a01f7331db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:24-bookworm-slim AS build +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/* +RUN corepack enable +ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 +ENV VP_GIT_HOOKS=0 +WORKDIR /src +COPY . . +# The unused compiler's launcher probe loops under x64 emulation. +RUN sed -i 's/^ tsc-rs: true$/ tsc-rs: false/' pnpm-workspace.yaml && pnpm install --frozen-lockfile +RUN pnpm exec vp run --filter t3 build +RUN pnpm --filter t3 deploy --prod --legacy /opt/knife/apps/server +RUN node --input-type=module -e 'import { fingerprintForkServerRuntime } from "./scripts/lib/fork-runtime.ts"; import { writeFile } from "node:fs/promises"; await writeFile("/opt/knife/knife-server-runtime", await fingerprintForkServerRuntime(process.cwd()));' + +FROM node:24-bookworm-slim +ARG CODEX_VERSION=0.163.0 +ARG CLAUDE_VERSION=2.1.301 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git openssh-client libsecret-1-0 libdbus-1-3 tini && rm -rf /var/lib/apt/lists/* +RUN npm install --global @openai/codex@${CODEX_VERSION} @anthropic-ai/claude-code@${CLAUDE_VERSION} && npm cache clean --force +COPY --from=build --chown=node:node /opt/knife /opt/knife +COPY scripts/container-runtime.ts /opt/knife/scripts/container-runtime.ts +COPY packages/shared/package.json /opt/knife/packages/shared/package.json +COPY packages/shared/src/forkUpdateDirectory.ts /opt/knife/packages/shared/src/forkUpdateDirectory.ts +RUN mkdir -p /opt/knife/node_modules/@t3tools && ln -s /opt/knife/packages/shared /opt/knife/node_modules/@t3tools/shared && ln -s /opt/knife/apps/server/node_modules/effect /opt/knife/node_modules/effect +COPY --chmod=755 infra/container/knife /usr/local/bin/knife +RUN mkdir /data /archives /workspaces && chown node:node /data /archives /workspaces +USER node +WORKDIR /workspaces +ENV T3CODE_HOME=/data +EXPOSE 3773 +VOLUME ["/data", "/home/node", "/archives", "/workspaces"] +ENTRYPOINT ["/usr/bin/tini", "--", "knife"] +CMD ["serve", "--host", "0.0.0.0", "--port", "3773"] diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index e78374287815b1f7c0d5493a9892491c78e875a4..d9197ac91731b8f8cba425482be1d06ed33fa851 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -26,6 +26,7 @@ import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { themeCommand } from "./cli/theme.ts"; import { traceCommand } from "./cli/trace.ts"; +import { sharedCommand } from "./cli/shared.ts"; import { triageCommand } from "./cli/triage.ts"; const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -84,6 +85,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => servicePreflightCommand, themeCommand, traceCommand, + sharedCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/cli/shared.ts b/apps/server/src/cli/shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a585335c7be58e3c7047c659e3455b82d34c83f --- /dev/null +++ b/apps/server/src/cli/shared.ts @@ -0,0 +1,121 @@ +import { + AuthProvidersManageScope, + ProviderInstanceId, + SharedAccountError, + SharedAccountId, + SharedProfilesStatus, + type SharedProfilesRequest, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { authLocationFlags, resolveCliAuthConfig, type CliAuthLocationFlags } from "./config.ts"; + +const encodeStatus = Schema.encodeEffect(Schema.fromJsonString(SharedProfilesStatus)); + +const run = (flags: CliAuthLocationFlags, request: SharedProfilesRequest) => + Effect.gen(function* () { + const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel); + const state = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(state)) return yield* new SharedAccountError({ reason: "connection" }); + return yield* Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const http = yield* HttpClient.HttpClient; + const result = yield* Effect.acquireUseRelease( + auth.issueSession({ scopes: [AuthProvidersManageScope], label: "t3 shared cli" }), + (session) => + HttpClientRequest.post(`${state.value.origin}/api/shared/profiles`).pipe( + HttpClientRequest.setHeader("authorization", `Bearer ${session.token}`), + HttpClientRequest.bodyJson(request), + Effect.flatMap(http.execute), + Effect.flatMap((response) => + response.status === 200 + ? HttpClientResponse.schemaBodyJson(SharedProfilesStatus)(response) + : HttpClientResponse.schemaBodyJson(SharedAccountError)(response).pipe( + Effect.flatMap(Effect.fail), + ), + ), + Effect.timeout("30 seconds"), + ), + (session) => auth.revokeSession(session.sessionId).pipe(Effect.ignore({ log: true })), + ); + yield* Console.log(yield* encodeStatus(result)); + }).pipe( + Effect.scoped, + Effect.provide( + EnvironmentAuth.layerRuntime.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), + ), + ), + Effect.provide(FetchHttpClient.layer), + ); + }); + +const status = Command.make("status", authLocationFlags).pipe( + Command.withDescription("List shared accounts on this host."), + Command.withHandler((flags) => run(flags, { type: "status" })), +); +const sync = Command.make("sync", authLocationFlags).pipe( + Command.withDescription("Refresh shared profiles from the shared server."), + Command.withHandler((flags) => run(flags, { type: "sync" })), +); +const connect = Command.make("connect", { + ...authLocationFlags, + url: Argument.String("url"), + tokenFile: Flag.String("token-file").pipe( + Flag.withDescription("Private file containing this host’s shared-server bearer token."), + ), +}).pipe( + Command.withDescription("Connect this host to a private T3 server."), + Command.withHandler((flags) => + run(flags, { type: "connect", url: flags.url, tokenFile: flags.tokenFile }), + ), +); +const disconnect = Command.make("disconnect", authLocationFlags).pipe( + Command.withDescription("Disconnect and disable this host’s shared profiles."), + Command.withHandler((flags) => run(flags, { type: "disconnect" })), +); +const importAccount = Command.make("import", { + ...authLocationFlags, + instanceId: Argument.String("instance-id").pipe(Argument.withSchema(ProviderInstanceId)), + tokenFile: Flag.String("token-file").pipe( + Flag.withDescription("Private file containing a Claude setup token."), + Flag.optional, + ), + replaceId: Flag.String("replace").pipe( + Flag.withSchema(SharedAccountId), + Flag.withDescription("Replace the credentials of an existing shared account."), + Flag.optional, + ), +}).pipe( + Command.withDescription("Copy a signed-in profile into managed shared accounts."), + Command.withHandler((flags) => + run(flags, { + type: "import", + instanceId: flags.instanceId, + ...(Option.isSome(flags.tokenFile) ? { tokenFile: flags.tokenFile.value } : {}), + ...(Option.isSome(flags.replaceId) ? { replaceId: flags.replaceId.value } : {}), + }), + ), +); +const remove = Command.make("remove", { + ...authLocationFlags, + id: Argument.String("account-id").pipe(Argument.withSchema(SharedAccountId)), +}).pipe( + Command.withDescription("Remove a shared account from every connected host."), + Command.withHandler((flags) => run(flags, { type: "remove", id: flags.id })), +); + +export const sharedCommand = Command.make("shared").pipe( + Command.withDescription("Manage shared provider accounts."), + Command.withSubcommands([status, connect, disconnect, importAccount, sync, remove]), +); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 60a5ae22972ea651872c72ddadeb73071a5e4c96..0700736c8a691b6e67553222cf2ff3bed42045fe 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -2,6 +2,11 @@ import * as Mime from "effect/http/Mime"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthProvidersManageScope, + SharedAccountRequest, + SharedProfilesRequest, + SharedAccountError, + type AuthEnvironmentScope, EnvironmentHttpApi, } from "@t3tools/contracts"; import { isDevProxiedPath } from "@t3tools/shared/devProxy"; @@ -9,6 +14,7 @@ import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tool import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -39,6 +45,8 @@ import { } from "./assets/AttachmentUpload.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as SharedAccounts from "./provider/SharedAccounts.ts"; +import * as SharedProfiles from "./provider/SharedProfiles.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, @@ -299,9 +307,7 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = ( - scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, -) => +const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -321,6 +327,75 @@ const authenticateRawRouteWithScope = ( } }); +export const layerSharedAccountRoute = HttpRouter.add( + "POST", + "/api/shared/accounts", + Effect.gen(function* () { + yield* authenticateRawRouteWithScope(AuthProvidersManageScope); + const request = yield* HttpServerRequest.HttpServerRequest; + const accounts = yield* SharedAccounts.SharedAccounts; + const input = yield* request.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(SharedAccountRequest)), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }), + ), + ); + return HttpServerResponse.jsonUnsafe(yield* accounts.execute(input), { + headers: { "cache-control": "no-store" }, + }); + }).pipe( + Effect.catchTags({ + SharedAccountError: (error) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { _tag: error._tag, reason: error.reason }, + { + status: error.reason === "missing" ? 404 : error.reason === "stale" ? 409 : 503, + headers: { "cache-control": "no-store" }, + }, + ), + ), + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + Effect.withTracerEnabled(false), + ), +); + +export const layerSharedProfilesRoute = HttpRouter.add( + "POST", + "/api/shared/profiles", + Effect.gen(function* () { + yield* authenticateRawRouteWithScope(AuthProvidersManageScope); + const request = yield* HttpServerRequest.HttpServerRequest; + const profiles = yield* SharedProfiles.SharedProfiles; + const input = yield* request.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(SharedProfilesRequest)), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "setup", cause: Redacted.make(cause) }), + ), + ); + return HttpServerResponse.jsonUnsafe(yield* profiles.execute(input), { + headers: { "cache-control": "no-store" }, + }); + }).pipe( + Effect.catchTags({ + SharedAccountError: (error) => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { _tag: error._tag, reason: error.reason }, + { status: 503, headers: { "cache-control": "no-store" } }, + ), + ), + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + Effect.withTracerEnabled(false), + ), +); + export const layerServerEnvironmentHttpApi = HttpApiBuilder.group( EnvironmentHttpApi, "metadata", diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index c2d95be7fd2507edda2fee0d21ff794e337d7455..93804ad5133192d986842703a1fb1e0d03a61760 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1603,7 +1603,7 @@ export type CodexAdapterV2DriverEnv = export const createCodexAdapterV2 = ( { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick = {}, ) => Effect.gen(function* () { const clientFactory = yield* CodexAppServerClientFactory; @@ -1702,6 +1702,9 @@ export interface CodexAdapterV2Options { * Codex with a current access token. */ readonly resolveRuntime?: Effect.Effect; + readonly authenticate?: ( + client: CodexClient.CodexAppServerClient["Service"], + ) => Effect.Effect; readonly crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; @@ -1831,6 +1834,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ); yield* client.notify("initialized", undefined); + if (adapterOptions.authenticate) yield* adapterOptions.authenticate(client); yield* Ref.set(initialized, true); }), ); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index a379280969ae6756d6b31eeb9bd7e04bfe801061..07e2ab1e53c648e8f250d5bd5fa9462721309b34 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -24,6 +24,7 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; +import * as ManagedProviderHome from "../ManagedProviderHome.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; @@ -96,6 +97,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }); export type ClaudeDriverEnv = + | ManagedProviderHome.ManagedProviderHome | ClaudeAdapterV2DriverEnv | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner @@ -109,7 +111,11 @@ export type ClaudeDriverEnv = | ProviderEventLoggers.ProviderEventLoggers | ServerConfig.ServerConfig; -export const ClaudeDriver: ProviderDriver = { +export const ClaudeDriver: ProviderDriver< + ClaudeSettings, + ClaudeDriverEnv, + Path.Path | ServerConfig.ServerConfig +> = { driverKind: DRIVER_KIND, metadata: { displayName: "Claude", @@ -118,8 +124,53 @@ export const ClaudeDriver: ProviderDriver decodeClaudeSettings({}), usage: claudeUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + create: ({ + instanceId, + displayName, + accentColor, + environment: configuredEnvironment, + enabled, + config: configured, + }) => Effect.gen(function* () { + const sharedId = configured.sharedAccountId; + const managedHomes = sharedId ? yield* ManagedProviderHome.ManagedProviderHome : undefined; + const shared = + sharedId && managedHomes + ? yield* Effect.gen(function* () { + const credentials = yield* managedHomes.prepare("claudeAgent", sharedId); + const paths = yield* managedHomes.paths("claudeAgent", sharedId); + return { credentials, paths }; + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: cause.message, + cause, + }), + ), + ) + : undefined; + const config = shared ? { ...configured, homePath: shared.paths.account } : configured; + const environment = shared + ? [ + ...configuredEnvironment.filter( + ({ name }) => + !["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"].includes( + name, + ), + ), + { + name: "CLAUDE_CODE_OAUTH_TOKEN", + value: shared.credentials.accessToken, + sensitive: true, + }, + { name: "ANTHROPIC_API_KEY", value: "", sensitive: true }, + { name: "ANTHROPIC_AUTH_TOKEN", value: "", sensitive: true }, + ] + : configuredEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -156,7 +207,7 @@ export const ClaudeDriver: ProviderDriver = { +export const CodexDriver: ProviderDriver< + CodexSettings, + CodexDriverEnv, + Path.Path | ServerConfig.ServerConfig +> = { driverKind: DRIVER_KIND, metadata: { displayName: "Codex", @@ -136,8 +146,33 @@ export const CodexDriver: ProviderDriver decodeCodexSettings({}), usage: codexUsageReader, - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + create: ({ instanceId, displayName, accentColor, environment, enabled, config: configured }) => Effect.gen(function* () { + const sharedId = configured.sharedAccountId; + const managedHomes = sharedId ? yield* ManagedProviderHome.ManagedProviderHome : undefined; + const sharedAccounts = sharedId ? yield* SharedAccountClient.SharedAccountClient : undefined; + const managedPaths = + sharedId && managedHomes + ? yield* managedHomes.paths("codex", sharedId).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: cause.message, + cause, + }), + ), + ) + : undefined; + const config = managedPaths + ? { + ...configured, + setupMode: "existing" as const, + homePath: managedPaths.shared, + shadowHomePath: managedPaths.account, + } + : configured; if (config.setupMode === "managed") return yield* makeManagedCodexProvider({ instanceId, @@ -182,6 +217,24 @@ export const CodexDriver: ProviderDriver ({ + config: effectiveConfig, + environment: processEnv, + revision: credentials.accessToken, + })), + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId, + operation: "shared-account", + detail: cause.message, + }), + ), + ) + : undefined; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect( makeCodexMaintenanceResolver(homeLayout.sharedHomePath), @@ -205,7 +258,18 @@ export const CodexDriver: ProviderDriver snapshot.applyUsageLimits(update) }, + { + onUsageLimits: (update) => snapshot.applyUsageLimits(update), + ...(resolveSharedRuntime ? { resolveRuntime: resolveSharedRuntime } : {}), + ...(sharedId && sharedAccounts + ? { + authenticate: (client) => + authenticateSharedCodex(client, (force) => + sharedAccounts.access(sharedId, force), + ), + } + : {}), + }, ).pipe( Effect.mapError( (cause) => @@ -225,15 +289,26 @@ export const CodexDriver: ProviderDriver stampIdentity(applyCodexModelCatalog(draft, catalog)), - { concurrent: true }, - ), - ), + const checkProvider = Effect.gen(function* () { + const readiness = yield* (resolveSharedRuntime ?? Effect.void).pipe(Effect.result); + yield* modelCatalog.refreshInBackground; + return yield* Effect.zipWith( + checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + currentCatalog, + (draft, catalog) => + stampIdentity({ + ...applyCodexModelCatalog(draft, catalog), + ...(Result.isFailure(readiness) + ? { + status: "error" as const, + auth: { status: "unknown" as const }, + message: readiness.failure.message, + } + : {}), + }), + { concurrent: true }, + ); + }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), @@ -277,6 +352,7 @@ export const CodexDriver: ProviderDriver value.models)), + resolveSharedRuntime, ); const snapshotForCwd = (cwd: string) => !effectiveConfig.enabled @@ -319,6 +395,7 @@ export const CodexDriver: ProviderDriver Effect.gen(function* () { + if (resolveSharedRuntime) yield* resolveSharedRuntime; const { client } = yield* withCodexAppServerClient({ binaryPath: effectiveConfig.binaryPath, homePath: effectiveConfig.homePath, @@ -327,6 +404,10 @@ export const CodexDriver: ProviderDriver + sharedAccounts.access(sharedId, force), + ); const response = yield* client.request("account/rateLimitResetCredit/consume", { idempotencyKey, }); diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index d0ad4202954cc7f39a0a2ac4ef7e3a691cf66da0..533777608d3ca5d6d447e97d9bac578c69ff573d 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -18,6 +18,8 @@ import { type UsageRecord, } from "@t3tools/provider-core/server/usage"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as ServerConfig from "../../config.ts"; +import { sharedProviderHomePaths } from "../ManagedProviderHome.ts"; /** * Parses one line of a Claude Code transcript. @@ -103,7 +105,7 @@ export const claudeUsageFormat: TranscriptUsageFormat = { parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)), }; -export const claudeUsageReader: ProviderUsageReader = { +export const claudeUsageReader = { kind: "transcripts", provider: "claude", format: claudeUsageFormat, @@ -111,6 +113,11 @@ export const claudeUsageReader: ProviderUsageReader = // An undecodable config has no trustworthy home to read. if (config === undefined) return []; const path = yield* Path.Path; + if (config.sharedAccountId) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const home = sharedProviderHomePaths(path, stateDir, "claudeAgent", config.sharedAccountId); + return [{ dir: path.join(home.shared, "projects") }]; + } const homeDirectory = yield* HostProcess.HomeDirectory; const configured = config.homePath.trim(); const home = configured @@ -118,4 +125,4 @@ export const claudeUsageReader: ProviderUsageReader = : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDirectory, ".claude"); return [{ dir: path.resolve(home, "projects") }]; }), -}; +} satisfies ProviderUsageReader; diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index 1ffcb3aecfcd00fee55cb5893798cda2d01f8f7f..0be2fada1c1f66f3d351bb44c0f3cd69ab158e84 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -21,6 +21,8 @@ import { import type { UsageTokenTotals } from "@t3tools/contracts"; import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts"; +import * as ServerConfig from "../../config.ts"; +import { sharedProviderHomePaths } from "../ManagedProviderHome.ts"; /** * Rolling state for a single Codex rollout file. @@ -239,7 +241,7 @@ export const codexUsageFormat: TranscriptUsageFormat = { sharedSessionsAcrossFiles: true, }; -export const codexUsageReader: ProviderUsageReader = { +export const codexUsageReader = { kind: "transcripts", provider: "codex", format: codexUsageFormat, @@ -247,6 +249,11 @@ export const codexUsageReader: ProviderUsageReader = { // An undecodable config has no trustworthy home to read. if (config === undefined) return []; const path = yield* Path.Path; + if (config.sharedAccountId) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const home = sharedProviderHomePaths(path, stateDir, "codex", config.sharedAccountId); + return [{ dir: path.join(home.shared, "sessions") }]; + } const environmentHome = environment.CODEX_HOME?.trim(); const layout = yield* resolveCodexHomeLayout( config.setupMode !== "managed" && @@ -258,4 +265,4 @@ export const codexUsageReader: ProviderUsageReader = { ); return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }]; }), -}; +} satisfies ProviderUsageReader; diff --git a/apps/server/src/provider/ManagedProviderHome.ts b/apps/server/src/provider/ManagedProviderHome.ts new file mode 100644 index 0000000000000000000000000000000000000000..4853c75b1bb0d2060f11c9023476215d6df58530 --- /dev/null +++ b/apps/server/src/provider/ManagedProviderHome.ts @@ -0,0 +1,139 @@ +import { SharedAccountError, SharedAccountId, type SharedAccountAccess } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import * as ServerConfig from "../config.ts"; +import * as SharedAccountClient from "./SharedAccountClient.ts"; + +const decodeId = Schema.decodeEffect(SharedAccountId); +const encodeAuth = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const isSharedAccountError = Schema.is(SharedAccountError); + +const CLAUDE_SHARED_DIRECTORIES = [ + "projects", + "tasks", + "todos", + "plans", + "skills", + "plugins", + "cache", + "shell-snapshots", + "session-env", +]; + +export function sharedProviderHomePaths( + path: Path.Path, + stateDir: string, + driver: SharedAccountAccess["driver"], + id: string, +) { + const root = path.join(stateDir, "providers", driver); + return { shared: path.join(root, "shared"), account: path.join(root, "accounts", id) }; +} + +export class ManagedProviderHome extends Context.Service< + ManagedProviderHome, + { + readonly paths: ( + driver: SharedAccountAccess["driver"], + id: string, + ) => Effect.Effect< + { + readonly shared: string; + readonly account: string; + }, + SharedAccountError + >; + readonly prepare: ( + driver: SharedAccountAccess["driver"], + id: string, + ) => Effect.Effect; + } +>()("t3/provider/ManagedProviderHome") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const { stateDir } = yield* ServerConfig.ServerConfig; + const accounts = yield* SharedAccountClient.SharedAccountClient; + const locks = yield* KeyedLock.make(); + + const paths = Effect.fn("ManagedProviderHome.paths")(function* ( + driver: SharedAccountAccess["driver"], + inputId: string, + ) { + const id = yield* decodeId(inputId).pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "missing", cause: Redacted.make(cause) }), + ), + ); + return sharedProviderHomePaths(path, stateDir, driver, id); + }); + + const prepare = Effect.fn("ManagedProviderHome.prepare")(function* ( + driver: SharedAccountAccess["driver"], + id: string, + ) { + const credentials = yield* accounts.access(id); + if (credentials.driver !== driver) return yield* new SharedAccountError({ reason: "identity" }); + const home = yield* paths(driver, id); + + yield* Effect.gen(function* () { + yield* fs.makeDirectory(home.shared, { recursive: true }); + yield* fs.makeDirectory(home.account, { recursive: true }); + yield* fs.chmod(home.account, 0o700); + if (driver === "claudeAgent") { + for (const entry of CLAUDE_SHARED_DIRECTORIES) { + const target = path.join(home.shared, entry); + const link = path.join(home.account, entry); + yield* fs.makeDirectory(target, { recursive: true }); + if (!(yield* fs.exists(link))) yield* fs.symlink(target, link); + else { + const existing = yield* fs.readLink(link); + if (path.resolve(path.dirname(link), existing) !== target) + return yield* new SharedAccountError({ reason: "storage" }); + } + } + } + if (credentials.driver === "codex") { + const json = yield* encodeAuth({ + auth_mode: "chatgptAuthTokens", + tokens: { + access_token: credentials.accessToken, + id_token: credentials.idToken, + account_id: credentials.accountId, + refresh_token: "", + }, + last_refresh: DateTime.formatIso(yield* DateTime.now), + }); + const temporary = path.join(home.account, `${yield* crypto.randomUUIDv4()}.tmp`); + yield* fs.writeFileString(temporary, json, { mode: 0o600 }); + yield* fs.chmod(temporary, 0o600); + yield* fs.rename(temporary, path.join(home.account, "auth.json")); + } + }).pipe( + Effect.mapError((cause) => + isSharedAccountError(cause) + ? cause + : new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + + return credentials; + }); + + return ManagedProviderHome.of({ + paths, + prepare: (driver, id) => locks.withLock(`${driver}:${id}`, prepare(driver, id)), + }); +}); + +export const layer = Layer.effect(ManagedProviderHome, make); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 7ba4e9b41d59fb9bc82a4f26b16d7d6eaada2915..1c274ae5cb1c50d975b029eae3a359e64d5776b9 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -1,4 +1,7 @@ import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; +import * as ManagedProviderHome from "./ManagedProviderHome.ts"; +import * as SharedAccountClient from "./SharedAccountClient.ts"; +import * as SharedAccounts from "./SharedAccounts.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -68,6 +71,12 @@ import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; +const layerSharedAccounts = ManagedProviderHome.layer.pipe( + Layer.provideMerge(SharedAccountClient.layer), + Layer.provideMerge(SharedAccounts.layer), + Layer.provideMerge(ServerSecretStore.layer), +); + const layerTestHttpClient = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -215,7 +224,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { managedDirectory: "unused-managed-installation", }), ), - Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), Layer.provideMerge( Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { getEnvironmentId: Effect.succeed( @@ -238,7 +246,10 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), ); - const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); + const layerBase = ProviderHostLive.layer.pipe( + Layer.provideMerge(layerSharedAccounts), + Layer.provideMerge(layerBaseDeps), + ); const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( Layer.provideMerge(layerBase), ); @@ -472,6 +483,83 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); + it.live( + "launches shared Claude accounts with their managed token and a common continuation home", + () => + Effect.gen(function* () { + if (yield* HostProcess.isWindows) return; + const fixtures = yield* makeTildeProviderFixtures(); + const accounts = yield* SharedAccounts.SharedAccounts; + const a = yield* accounts.execute({ + type: "import", + label: "First", + credentials: { driver: "claudeAgent", accessToken: "first-managed-token" }, + }); + const b = yield* accounts.execute({ + type: "import", + label: "Second", + credentials: { driver: "claudeAgent", accessToken: "second-managed-token" }, + }); + if (a.type !== "imported" || b.type !== "imported") + return yield* Effect.die("Expected two shared accounts"); + const firstId = ProviderInstanceId.make("first_shared_claude"); + const secondId = ProviderInstanceId.make("second_shared_claude"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: { + [firstId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + sharedAccountId: a.account.id, + binaryPath: fixtures.claudeBinaryPath, + }), + environment: [ + { + name: "T3_CLAUDE_REQUIRED_TOKEN", + value: "first-managed-token", + sensitive: false, + }, + { name: "CLAUDE_CODE_OAUTH_TOKEN", value: "wrong-token", sensitive: true }, + { name: "ANTHROPIC_API_KEY", value: "wrong-key", sensitive: true }, + ], + }, + [secondId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + sharedAccountId: b.account.id, + binaryPath: fixtures.claudeBinaryPath, + }), + environment: [ + { + name: "T3_CLAUDE_REQUIRED_TOKEN", + value: "second-managed-token", + sensitive: false, + }, + { name: "ANTHROPIC_AUTH_TOKEN", value: "wrong-key", sensitive: true }, + ], + }, + }, + }); + const first = yield* registry.getInstance(firstId); + const second = yield* registry.getInstance(secondId); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(yield* first!.snapshot.refresh).toMatchObject({ + status: "ready", + auth: { status: "authenticated" }, + }); + expect(yield* second!.snapshot.refresh).toMatchObject({ + status: "ready", + auth: { status: "authenticated" }, + }); + expect(first!.continuationIdentity).toEqual(second!.continuationIdentity); + }).pipe(Effect.provide(layerTest)), + ); + const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -657,7 +745,10 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), ); - const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); + const layerBase = ProviderHostLive.layer.pipe( + Layer.provideMerge(layerSharedAccounts), + Layer.provideMerge(layerBaseDeps), + ); const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( Layer.provideMerge(layerBase), ); diff --git a/apps/server/src/provider/SharedAccountClient.test.ts b/apps/server/src/provider/SharedAccountClient.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..89bf525208fde4b07b1b714dcebed3827981db40 --- /dev/null +++ b/apps/server/src/provider/SharedAccountClient.test.ts @@ -0,0 +1,192 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { assert, it } from "@effect/vitest"; +import { SharedAccountRequest, SharedAccountError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Cause from "effect/Cause"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SharedAccounts from "./SharedAccounts.ts"; +import * as SharedAccountClient from "./SharedAccountClient.ts"; + +const decodeRequest = Schema.decodeEffect(Schema.fromJsonString(SharedAccountRequest)); +const isSharedAccountError = Schema.is(SharedAccountError); + +const jwt = (expiresAt: number) => + `eyJhbGciOiJub25lIn0.${Buffer.from(JSON.stringify({ exp: expiresAt / 1000 })).toString("base64url")}.signature`; +const fixture = Effect.gen(function* () { + const bytes = new Map(); + const store = ServerSecretStore.ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))), + set: (key, value) => + Effect.sync(() => { + bytes.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + bytes.delete(key); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + const hub = yield* SharedAccounts.SharedAccounts.pipe( + Effect.provide(SharedAccounts.layer), + Effect.provideService(ServerSecretStore.ServerSecretStore, store), + ); + const imported = yield* hub.execute({ + type: "import", + label: "Personal", + credentials: { + driver: "codex", + accountId: "account-a", + accessToken: jwt(1), + refreshToken: "refresh-a", + idToken: "id-a", + expiresAt: 1, + }, + }); + if (imported.type !== "imported") return yield* Effect.die("Expected an account"); + let refreshes = 0; + let offline = false; + let loseCommitResponse = false; + let corruptResponse = false; + let expireClaim = false; + const http = HttpClient.make((request) => + Effect.gen(function* () { + if (offline) return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); + if (corruptResponse) + return HttpClientResponse.fromWeb(request, new Response("review-access-secret-sentinel")); + if (request.url === "https://auth.openai.com/oauth/token") { + refreshes++; + assert.strictEqual(request.body._tag, "Uint8Array"); + if (request.body._tag !== "Uint8Array") return yield* Effect.die("Expected JSON"); + assert.strictEqual( + JSON.parse(new TextDecoder().decode(request.body.body)).refresh_token, + "refresh-a", + ); + return HttpClientResponse.fromWeb( + request, + Response.json({ + access_token: jwt(600_000), + refresh_token: "refresh-b", + id_token: "id-b", + }), + ); + } + assert.strictEqual(request.headers.authorization, "Bearer machine-token"); + assert.strictEqual(request.url, "https://shared.example.test/api/shared/accounts"); + if (request.body._tag !== "Uint8Array") return yield* Effect.die("Expected JSON"); + const input = yield* decodeRequest(new TextDecoder().decode(request.body.body)).pipe( + Effect.orDie, + ); + const response = yield* hub + .execute(input) + .pipe(Effect.catchTags({ SharedAccountError: (error) => Effect.succeed(error) })); + if (!isSharedAccountError(response) && response.type === "refresh" && expireClaim) { + expireClaim = false; + return HttpClientResponse.fromWeb(request, Response.json({ ...response, expiresAt: 0 })); + } + if (input.type === "commit" && loseCommitResponse) { + loseCommitResponse = false; + return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); + } + return HttpClientResponse.fromWeb( + request, + Response.json(response, { status: isSharedAccountError(response) ? 404 : 200 }), + ); + }), + ); + const layer = SharedAccountClient.layer.pipe( + Layer.provide(Layer.succeed(SharedAccounts.SharedAccounts, hub)), + Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, store)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), + ); + const restart = SharedAccountClient.SharedAccountClient.pipe(Effect.provide(layer)); + const client = yield* restart; + yield* client.configure({ url: "https://shared.example.test/", token: "machine-token" }); + return { + id: imported.account.id, + client, + restart, + hub, + refreshes: () => refreshes, + goOffline: () => { + offline = true; + }, + loseResponse: () => { + loseCommitResponse = true; + }, + corruptResponse: () => { + corruptResponse = true; + }, + expireClaim: () => { + expireClaim = true; + }, + hasJournal: () => [...bytes.keys()].some((key) => key.startsWith("shared-account-refresh-")), + }; +}).pipe(Effect.provide(NodeCrypto.layer)); + +it.effect( + "refreshes once and recovers a committed rotation after losing the response and restarting", + () => + Effect.gen(function* () { + const f = yield* fixture; + f.loseResponse(); + assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "connection"); + assert.isTrue(f.hasJournal()); + const restarted = yield* f.restart; + assert.strictEqual((yield* restarted.access(f.id)).accessToken, jwt(600_000)); + assert.strictEqual(f.refreshes(), 1); + assert.isFalse(f.hasJournal()); + }).pipe(Effect.provide(NodeCrypto.layer)), +); + +it.effect( + "uses cached access while disconnected, but never expires or force-refreshes from the cache", + () => + Effect.gen(function* () { + const f = yield* fixture; + const access = yield* f.client.access(f.id); + f.goOffline(); + assert.deepStrictEqual(yield* f.client.access(f.id), access); + assert.strictEqual( + (yield* f.client.access(f.id, true).pipe(Effect.flip)).reason, + "connection", + ); + yield* TestClock.adjust("11 minutes"); + assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "connection"); + }).pipe(Effect.provide(NodeCrypto.layer)), +); + +it.effect("does not revive an account removed on the shared server from its cached token", () => + Effect.gen(function* () { + const f = yield* fixture; + yield* f.client.access(f.id); + yield* f.hub.execute({ type: "remove", id: f.id }); + assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "missing"); + }).pipe(Effect.provide(NodeCrypto.layer)), +); + +it.effect("redacts a malformed credential response from the failure trace", () => + Effect.gen(function* () { + const f = yield* fixture; + f.corruptResponse(); + const result = yield* f.client.execute({ type: "list" }).pipe(Effect.exit); + if (result._tag !== "Failure") return yield* Effect.die("Expected a malformed response error"); + const trace = Cause.pretty(result.cause); + assert.isFalse(trace.includes("review-access")); + assert.isFalse(trace.includes("machine-token")); + }).pipe(Effect.provide(NodeCrypto.layer)), +); + +it.effect("rejects an expired refresh claim before contacting the token endpoint", () => + Effect.gen(function* () { + const f = yield* fixture; + f.expireClaim(); + assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "stale"); + assert.strictEqual(f.refreshes(), 0); + }).pipe(Effect.provide(NodeCrypto.layer)), +); diff --git a/apps/server/src/provider/SharedAccountClient.ts b/apps/server/src/provider/SharedAccountClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7660723d55f2064bda0a8ca11c2d51f10e2b504 --- /dev/null +++ b/apps/server/src/provider/SharedAccountClient.ts @@ -0,0 +1,322 @@ +import { + SharedAccountAccess, + SharedAccountId, + SharedAccountError, + SharedAccountResponse, + SharedAccountCommit, + type SharedAccountRequest, +} from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; +import { decodeJwt } from "jose"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SharedAccounts from "./SharedAccounts.ts"; + +const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString }); +const CachedAccess = Schema.Struct({ revision: Schema.Int, credentials: SharedAccountAccess }); +const Tokens = Schema.Struct({ + access_token: Schema.NonEmptyString, + refresh_token: Schema.optionalKey(Schema.NonEmptyString), + id_token: Schema.optionalKey(Schema.NonEmptyString), +}); + +const decodeConnection = Schema.decodeEffect(Schema.fromJsonString(Connection)); +const encodeConnection = Schema.encodeEffect(Schema.fromJsonString(Connection)); +const decodeAccess = Schema.decodeEffect(Schema.fromJsonString(CachedAccess)); +const encodeAccess = Schema.encodeEffect(Schema.fromJsonString(CachedAccess)); +const decodeCommit = Schema.decodeEffect(Schema.fromJsonString(SharedAccountCommit)); +const encodeCommit = Schema.encodeEffect(Schema.fromJsonString(SharedAccountCommit)); +const decodeId = Schema.decodeEffect(SharedAccountId); +const isSharedAccountError = Schema.is(SharedAccountError); + +export class SharedAccountClient extends Context.Service< + SharedAccountClient, + { + readonly configure: (input: typeof Connection.Type) => Effect.Effect; + readonly disconnect: Effect.Effect; + readonly configured: Effect.Effect; + readonly execute: ( + request: SharedAccountRequest, + ) => Effect.Effect; + readonly access: ( + id: string, + forceRefresh?: boolean, + ) => Effect.Effect; + } +>()("t3/provider/SharedAccountClient") {} + +const make = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const crypto = yield* Crypto.Crypto; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const local = yield* SharedAccounts.SharedAccounts; + const locks = yield* KeyedLock.make(); + + const connection = secrets.get("shared-server").pipe( + Effect.flatMap((bytes) => + Option.isNone(bytes) + ? Effect.succeedNone + : decodeConnection(new TextDecoder().decode(bytes.value)).pipe(Effect.map(Option.some)), + ), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + + const requestRemote = (target: typeof Connection.Type, input: SharedAccountRequest) => + HttpClientRequest.post(`${target.url}/api/shared/accounts`).pipe( + HttpClientRequest.setHeader("authorization", `Bearer ${target.token}`), + HttpClientRequest.bodyJson(input), + Effect.flatMap(http.execute), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? HttpClientResponse.schemaBodyJson(SharedAccountResponse)(response) + : HttpClientResponse.schemaBodyJson(SharedAccountError)(response).pipe( + Effect.flatMap(Effect.fail), + ), + ), + Effect.mapError((cause) => + isSharedAccountError(cause) + ? cause + : new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }), + ), + Effect.timeout("10 seconds"), + Effect.catchTags({ + TimeoutError: (cause) => + new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }), + }), + ); + + const execute = Effect.fn("SharedAccountClient.execute")(function* (input: SharedAccountRequest) { + const target = yield* connection; + return yield* Option.isNone(target) ? local.execute(input) : requestRemote(target.value, input); + }); + + const configure = Effect.fn("SharedAccountClient.configure")(function* ( + input: typeof Connection.Type, + ) { + const url = yield* Effect.try({ + try: () => { + const value = new URL(input.url); + if ( + !["https:", "http:"].includes(value.protocol) || + value.username || + value.password || + value.search || + value.hash + ) + throw new Error("Expected an HTTP server URL"); + return value.toString().replace(/\/$/u, ""); + }, + catch: (cause) => + new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }), + }); + const target = { ...input, url }; + yield* requestRemote(target, { type: "list" }); + const json = yield* encodeConnection(target).pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + yield* secrets + .set("shared-server", new TextEncoder().encode(json)) + .pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + }); + + const access = Effect.fn("SharedAccountClient.access")(function* ( + accountId: string, + forceRefresh = false, + ) { + const id = yield* decodeId(accountId).pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "missing", cause: Redacted.make(cause) }), + ), + ); + const target = yield* connection; + const origin = Option.isSome(target) ? target.value.url : "local"; + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(origin)).pipe( + Effect.map((bytes) => Buffer.from(bytes).toString("hex")), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + const cacheKey = `shared-account-access-${digest}-${id}`; + const pendingKey = `shared-account-refresh-${digest}-${id}`; + const pending = yield* secrets.get(pendingKey).pipe( + Effect.flatMap((bytes) => + Option.isNone(bytes) + ? Effect.succeedNone + : decodeCommit(new TextDecoder().decode(bytes.value)).pipe(Effect.map(Option.some)), + ), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + if (Option.isSome(pending)) { + yield* execute(pending.value).pipe( + Effect.catchTags({ + SharedAccountError: (error) => + error.reason === "stale" ? Effect.void : Effect.fail(error), + }), + ); + yield* secrets + .remove(pendingKey) + .pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + } + const cached = yield* secrets.get(cacheKey).pipe( + Effect.flatMap((bytes) => + Option.isNone(bytes) + ? Effect.succeedNone + : decodeAccess(new TextDecoder().decode(bytes.value)).pipe(Effect.map(Option.some)), + ), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + const request: SharedAccountRequest = { + type: "acquire", + id, + ...(forceRefresh && Option.isSome(cached) ? { refreshRevision: cached.value.revision } : {}), + }; + const now = yield* Clock.currentTimeMillis; + let response = yield* execute(request).pipe( + Effect.catchTags({ + SharedAccountError: (error) => { + if (error.reason !== "connection" || forceRefresh || Option.isNone(cached)) + return Effect.fail(error); + const value = cached.value; + if (value.credentials.driver === "codex" && value.credentials.expiresAt <= now) + return Effect.fail(error); + return Effect.succeed({ + type: "access", + account: { id, driver: value.credentials.driver, label: id, revision: value.revision }, + credentials: value.credentials, + }); + }, + }), + ); + while (response.type === "busy") { + if ((yield* Clock.currentTimeMillis) - now >= 8_000) + return yield* new SharedAccountError({ reason: "busy" }); + yield* Effect.sleep("200 millis"); + response = yield* execute(request); + } + if (response.type === "refresh") { + const claim = response; + response = yield* Effect.gen(function* () { + if ((yield* Clock.currentTimeMillis) + 8_000 >= claim.expiresAt) + return yield* new SharedAccountError({ reason: "stale" }); + const tokens = yield* HttpClientRequest.post("https://auth.openai.com/oauth/token").pipe( + HttpClientRequest.bodyJson({ + client_id: "app_EMoamEEZ73f0CkXaXp7hrann", + grant_type: "refresh_token", + refresh_token: claim.credentials.refreshToken, + }), + Effect.flatMap(http.execute), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(Tokens)), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "refresh", cause: Redacted.make(cause) }), + ), + Effect.timeout("8 seconds"), + Effect.catchTags({ + TimeoutError: (cause) => + new SharedAccountError({ reason: "refresh", cause: Redacted.make(cause) }), + }), + ); + const expiresAt = yield* Effect.try({ + try: () => { + const exp = decodeJwt(tokens.access_token).exp; + if (typeof exp !== "number") throw new Error("Missing token expiry"); + return exp * 1000; + }, + catch: (cause) => + new SharedAccountError({ reason: "refresh", cause: Redacted.make(cause) }), + }); + const commit: typeof SharedAccountCommit.Type = { + type: "commit", + id, + revision: claim.account.revision, + leaseToken: claim.leaseToken, + credentials: { + ...claim.credentials, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? claim.credentials.refreshToken, + idToken: tokens.id_token ?? claim.credentials.idToken, + expiresAt, + }, + }; + const journal = yield* encodeCommit(commit).pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + yield* secrets + .set(pendingKey, new TextEncoder().encode(journal)) + .pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + const committed = yield* execute(commit); + yield* secrets + .remove(pendingKey) + .pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + return committed; + }).pipe(Effect.uninterruptible); + } + if (response.type !== "access") return yield* new SharedAccountError({ reason: "connection" }); + const json = yield* encodeAccess({ + revision: response.account.revision, + credentials: response.credentials, + }).pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + yield* secrets + .set(cacheKey, new TextEncoder().encode(json)) + .pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + return response.credentials; + }); + + return SharedAccountClient.of({ + execute, + configure, + access: (id, force) => locks.withLock(id, access(id, force)), + configured: connection.pipe(Effect.map(Option.isSome)), + disconnect: secrets + .remove("shared-server") + .pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ), + }); +}); + +export const layer = Layer.effect(SharedAccountClient, make); diff --git a/apps/server/src/provider/SharedAccounts.test.ts b/apps/server/src/provider/SharedAccounts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..449a65165c7ee990681ea920e4c5c4f7d408136e --- /dev/null +++ b/apps/server/src/provider/SharedAccounts.test.ts @@ -0,0 +1,207 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +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 * as TestClock from "effect/testing/TestClock"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SharedAccounts from "./SharedAccounts.ts"; + +const credentials = { + driver: "codex" as const, + accountId: "account-a", + accessToken: "access-a", + refreshToken: "refresh-a", + idToken: "id-a", + expiresAt: 600_000, +}; + +it.effect( + "replacing credentials fences an earlier refresher and retries do not create extra accounts", + () => + Effect.gen(function* () { + const { service, id } = yield* fixture(); + const claim = yield* service.execute({ type: "acquire", id, refreshRevision: 1 }); + if (claim.type !== "refresh") return yield* Effect.die("Expected a refresh claim"); + const replaced = yield* service.execute({ + type: "import", + replaceId: id, + label: "Personal", + credentials: { ...credentials, refreshToken: "replacement" }, + }); + if (replaced.type !== "imported") return yield* Effect.die("Expected replacement"); + assert.strictEqual(replaced.account.revision, 2); + assert.strictEqual( + (yield* service + .execute({ type: "commit", id, revision: 1, leaseToken: claim.leaseToken, credentials }) + .pipe(Effect.flip)).reason, + "stale", + ); + assert.deepStrictEqual( + yield* service.execute({ + type: "import", + replaceId: id, + label: "Personal", + credentials: { ...credentials, refreshToken: "replacement" }, + }), + replaced, + ); + assert.deepStrictEqual( + yield* service.execute({ type: "import", label: "Personal", credentials }), + replaced, + ); + }), +); + +function fixture() { + const data = new Map(); + const store = ServerSecretStore.ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), + set: (key, value) => + Effect.sync(() => { + data.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + data.delete(key); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + const layer = SharedAccounts.layer.pipe( + Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, store)), + Layer.provide(NodeCrypto.layer), + ); + return Effect.gen(function* () { + const service = yield* SharedAccounts.SharedAccounts.pipe(Effect.provide(layer)); + const imported = yield* service.execute({ type: "import", label: "Personal", credentials }); + if (imported.type !== "imported") return yield* Effect.die("Expected an imported account"); + return { + service, + id: imported.account.id, + restart: SharedAccounts.SharedAccounts.pipe(Effect.provide(layer)), + }; + }); +} + +it.effect("lists account labels without credential or refresh-claim material", () => + Effect.gen(function* () { + const { service, id } = yield* fixture(); + assert.deepStrictEqual(yield* service.execute({ type: "list" }), { + type: "list", + accounts: [{ id, label: "Personal", driver: "codex", revision: 1 }], + }); + assert.deepStrictEqual(yield* service.execute({ type: "acquire", id }), { + type: "access", + account: { id, label: "Personal", driver: "codex", revision: 1 }, + credentials: { + driver: "codex", + accountId: "account-a", + accessToken: "access-a", + idToken: "id-a", + expiresAt: 600_000, + }, + }); + }), +); + +it.effect("only one host acquires a refresh claim and its replacement survives restart", () => + Effect.gen(function* () { + const { service, id, restart } = yield* fixture(); + const results = yield* Effect.all( + [ + service.execute({ type: "acquire", id, refreshRevision: 1 }), + service.execute({ type: "acquire", id, refreshRevision: 1 }), + ], + { concurrency: "unbounded" }, + ); + const claim = results.find((result) => result.type === "refresh"); + if (claim?.type !== "refresh") return yield* Effect.die("Expected a refresh claim"); + assert.strictEqual(results.filter((result) => result.type === "refresh").length, 1); + assert.strictEqual(results.filter((result) => result.type === "busy").length, 1); + + const result = yield* service.execute({ + type: "commit", + id, + revision: 1, + leaseToken: claim.leaseToken, + credentials: { ...credentials, accessToken: "access-b", refreshToken: "refresh-b" }, + }); + if (result.type !== "access") return yield* Effect.die("Expected current access"); + assert.strictEqual(result.account.revision, 2); + assert.strictEqual(result.credentials.accessToken, "access-b"); + const next = yield* restart; + assert.deepStrictEqual( + yield* next.execute({ type: "acquire", id, refreshRevision: 1 }), + result, + ); + }), +); + +it.effect("a new host takes over an expired claim and fences out the previous host", () => + Effect.gen(function* () { + const { service, id, restart } = yield* fixture(); + const first = yield* service.execute({ type: "acquire", id, refreshRevision: 1 }); + if (first.type !== "refresh") return yield* Effect.die("Expected a refresh claim"); + const next = yield* restart; + assert.strictEqual( + (yield* next.execute({ type: "acquire", id, refreshRevision: 1 })).type, + "busy", + ); + yield* TestClock.adjust("31 seconds"); + const second = yield* next.execute({ type: "acquire", id, refreshRevision: 1 }); + if (second.type !== "refresh") return yield* Effect.die("Expected takeover"); + assert.notStrictEqual(second.leaseToken, first.leaseToken); + const rejected = yield* next + .execute({ type: "commit", id, revision: 1, leaseToken: first.leaseToken, credentials }) + .pipe(Effect.flip); + assert.strictEqual(rejected.reason, "stale"); + assert.strictEqual( + (yield* next.execute({ + type: "commit", + id, + revision: 1, + leaseToken: second.leaseToken, + credentials, + })).type, + "access", + ); + }), +); + +it.effect("does not change account identity when committing a refresh", () => + Effect.gen(function* () { + const { service, id } = yield* fixture(); + const claim = yield* service.execute({ type: "acquire", id, refreshRevision: 1 }); + if (claim.type !== "refresh") return yield* Effect.die("Expected a refresh claim"); + const rejected = yield* service + .execute({ + type: "commit", + id, + revision: 1, + leaseToken: claim.leaseToken, + credentials: { ...credentials, accountId: "wrong-account" }, + }) + .pipe(Effect.flip); + assert.strictEqual(rejected.reason, "identity"); + }), +); + +it.effect("Claude setup tokens need no refresh claim and removed accounts stay removed", () => + Effect.gen(function* () { + const { service, restart } = yield* fixture(); + const imported = yield* service.execute({ + type: "import", + label: "Claude", + credentials: { driver: "claudeAgent", accessToken: "setup-token" }, + }); + if (imported.type !== "imported") return yield* Effect.die("Expected an account"); + const id = imported.account.id; + const access = yield* service.execute({ type: "acquire", id }); + assert.strictEqual(access.type, "access"); + yield* service.execute({ type: "remove", id }); + const next = yield* restart; + const missing = yield* next.execute({ type: "acquire", id }).pipe(Effect.flip); + assert.strictEqual(missing.reason, "missing"); + }), +); diff --git a/apps/server/src/provider/SharedAccounts.ts b/apps/server/src/provider/SharedAccounts.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f99b98e949a0645fe90f0246224e6b5f33b3f14 --- /dev/null +++ b/apps/server/src/provider/SharedAccounts.ts @@ -0,0 +1,223 @@ +import { + SharedAccount, + SharedAccountCredentials, + SharedAccountError, + type SharedAccountRequest, + type SharedAccountResponse, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +const StoredAccount = Schema.Struct({ + ...SharedAccount.fields, + credentials: SharedAccountCredentials, + lease: Schema.NullOr(Schema.Struct({ token: Schema.String, expiresAt: Schema.Finite })), +}); +const StoredAccounts = Schema.fromJsonString(Schema.Array(StoredAccount)); +const decodeAccounts = Schema.decodeEffect(StoredAccounts); +const encodeAccounts = Schema.encodeEffect(StoredAccounts); +const sameCredentials = Schema.toEquivalence(SharedAccountCredentials); + +export class SharedAccounts extends Context.Service< + SharedAccounts, + { + readonly execute: ( + request: SharedAccountRequest, + ) => Effect.Effect; + } +>()("t3/provider/SharedAccounts") {} + +const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + + const read = secrets.get("shared-provider-accounts").pipe( + Effect.flatMap((bytes) => + Option.isNone(bytes) + ? Effect.succeed>([]) + : decodeAccounts(new TextDecoder().decode(bytes.value)), + ), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + + const write = (accounts: ReadonlyArray) => + encodeAccounts(accounts).pipe( + Effect.flatMap((json) => + secrets.set("shared-provider-accounts", new TextEncoder().encode(json)), + ), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + + const publicAccount = ({ id, label, driver, revision }: typeof StoredAccount.Type) => ({ + id, + label, + driver, + revision, + }); + const access = (account: typeof StoredAccount.Type): SharedAccountResponse => ({ + type: "access", + account: publicAccount(account), + credentials: + account.credentials.driver === "codex" + ? { + driver: "codex", + accessToken: account.credentials.accessToken, + idToken: account.credentials.idToken, + accountId: account.credentials.accountId, + expiresAt: account.credentials.expiresAt, + } + : account.credentials, + }); + + const execute = Effect.fn("SharedAccounts.execute")(function* (request: SharedAccountRequest) { + return yield* lock + .withPermits(1)( + Effect.gen(function* (): Effect.fn.Return { + const accounts = [...(yield* read)]; + if (request.type === "list") { + return { type: "list", accounts: accounts.map(publicAccount) }; + } + if (request.type === "import") { + if (request.replaceId) { + const index = accounts.findIndex((account) => account.id === request.replaceId); + const previous = accounts[index]; + if (!previous) return yield* new SharedAccountError({ reason: "missing" }); + if ( + previous.driver !== request.credentials.driver || + (previous.credentials.driver === "codex" && + request.credentials.driver === "codex" && + previous.credentials.accountId !== request.credentials.accountId) + ) + return yield* new SharedAccountError({ reason: "identity" }); + if (sameCredentials(previous.credentials, request.credentials)) + return { type: "imported", account: publicAccount(previous) }; + const updated = { + ...previous, + credentials: request.credentials, + revision: previous.revision + 1, + lease: null, + }; + accounts[index] = updated; + yield* write(accounts); + return { type: "imported", account: publicAccount(updated) }; + } + const existing = accounts.find(({ credentials }) => + credentials.driver === "codex" && request.credentials.driver === "codex" + ? credentials.accountId === request.credentials.accountId + : credentials.driver === "claudeAgent" && + request.credentials.driver === "claudeAgent" && + credentials.accessToken === request.credentials.accessToken, + ); + if (existing) return { type: "imported", account: publicAccount(existing) }; + + const id = yield* crypto + .randomUUIDv4() + .pipe( + Effect.mapError( + (cause) => + new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + const account: typeof StoredAccount.Type = { + id, + label: request.label, + driver: request.credentials.driver, + revision: 1, + credentials: request.credentials, + lease: null, + }; + accounts.push(account); + yield* write(accounts); + return { type: "imported", account: publicAccount(account) }; + } + + const index = accounts.findIndex((entry) => entry.id === request.id); + const account = accounts[index]; + if (!account) return yield* new SharedAccountError({ reason: "missing" }); + + if (request.type === "remove") { + accounts.splice(index, 1); + yield* write(accounts); + return { type: "removed" }; + } + if (request.type === "commit") { + if ( + account.revision === request.revision + 1 && + sameCredentials(account.credentials, request.credentials) + ) + return access(account); + + if ( + account.revision !== request.revision || + account.lease?.token !== request.leaseToken + ) { + return yield* new SharedAccountError({ reason: "stale" }); + } + if ( + account.credentials.driver !== "codex" || + account.credentials.accountId !== request.credentials.accountId + ) { + return yield* new SharedAccountError({ reason: "identity" }); + } + const updated = { + ...account, + credentials: request.credentials, + revision: account.revision + 1, + lease: null, + }; + accounts[index] = updated; + yield* write(accounts); + return access(updated); + } + + const now = yield* Clock.currentTimeMillis; + if ( + account.credentials.driver === "claudeAgent" || + (account.credentials.expiresAt > now + 300_000 && + request.refreshRevision !== account.revision) + ) { + return access(account); + } + if (account.lease && account.lease.expiresAt > now) { + return { type: "busy", retryAt: account.lease.expiresAt }; + } + const token = yield* crypto + .randomUUIDv4() + .pipe( + Effect.mapError( + (cause) => + new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + const lease = { token, expiresAt: now + 30_000 }; + accounts[index] = { ...account, lease }; + yield* write(accounts); + return { + type: "refresh", + account: publicAccount(account), + leaseToken: token, + credentials: account.credentials, + expiresAt: lease.expiresAt, + }; + }), + ) + .pipe(Effect.uninterruptible); + }); + + return SharedAccounts.of({ execute }); +}); + +export const layer = Layer.effect(SharedAccounts, make); diff --git a/apps/server/src/provider/SharedAccountsHttp.test.ts b/apps/server/src/provider/SharedAccountsHttp.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..62966a4d89042c2555a301e7fe3f9362785f2a7e --- /dev/null +++ b/apps/server/src/provider/SharedAccountsHttp.test.ts @@ -0,0 +1,70 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { AuthProvidersManageScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpRouter } from "effect/http"; +import * as ServerConfig from "../config.ts"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { layerSharedAccountRoute } from "../http.ts"; +import * as SharedAccounts from "./SharedAccounts.ts"; + +const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const layer = SharedAccounts.layer.pipe( + Layer.provideMerge(EnvironmentAuth.layerRuntime), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-shared-http-" })), + Layer.provide(NodeServices.layer), +); + +it.live( + "requires provider management authority and never returns credential causes in HTTP errors", + () => + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const accounts = yield* SharedAccounts.SharedAccounts; + const manager = yield* auth.issueSession({ scopes: [AuthProvidersManageScope] }); + const reader = yield* auth.issueSession({ scopes: [AuthOrchestrationReadScope] }); + const requestContext = Context.make(EnvironmentAuth.EnvironmentAuth, auth).pipe( + Context.add(SharedAccounts.SharedAccounts, accounts), + ); + const app = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler(layerSharedAccountRoute, { disableLogger: true }), + ), + (app) => Effect.promise(() => app.dispose()), + ); + const post = (body: unknown, token?: string) => + Effect.promise(() => + app.handler( + new Request("http://127.0.0.1/api/shared/accounts", { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: encode(body), + }), + requestContext, + ), + ); + assert.strictEqual((yield* post({ type: "list" })).status, 401); + assert.strictEqual((yield* post({ type: "list" }, reader.token)).status, 403); + const response = yield* post({ type: "list" }, manager.token); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get("cache-control"), "no-store"); + const invalid = yield* post( + { + type: "import", + label: "Personal", + credentials: { driver: "codex", accessToken: "private-value-in-malformed-input" }, + }, + manager.token, + ); + assert.strictEqual(invalid.status, 503); + const error = yield* Effect.promise(() => invalid.text()); + assert.isFalse(error.includes("private-value")); + assert.isFalse(error.includes("cause")); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/provider/SharedCodexAuth.ts b/apps/server/src/provider/SharedCodexAuth.ts new file mode 100644 index 0000000000000000000000000000000000000000..46835ebf7b4fed435a79e6d4ea6e88dfc6818b89 --- /dev/null +++ b/apps/server/src/provider/SharedCodexAuth.ts @@ -0,0 +1,33 @@ +import type { SharedAccountAccess, SharedAccountError } from "@t3tools/contracts"; +import type * as CodexClient from "effect-codex-app-server/client"; +import * as CodexErrors from "effect-codex-app-server/errors"; +import * as Effect from "effect/Effect"; + +export const authenticateSharedCodex = Effect.fn("authenticateSharedCodex")(function* ( + client: CodexClient.CodexAppServerClient["Service"], + access: (force: boolean) => Effect.Effect, +) { + const read = (force: boolean) => + access(force).pipe( + Effect.mapError((cause) => + CodexErrors.CodexAppServerRequestError.internalError(cause.message, undefined, { cause }), + ), + Effect.flatMap((credentials) => + credentials.driver === "codex" + ? Effect.succeed({ + accessToken: credentials.accessToken, + chatgptAccountId: credentials.accountId, + chatgptPlanType: null, + }) + : CodexErrors.CodexAppServerRequestError.internalError( + "This shared account is not a Codex account.", + ), + ), + ); + + yield* client.handleServerRequest("account/chatgptAuthTokens/refresh", () => read(true)); + yield* client.request("account/login/start", { + type: "chatgptAuthTokens", + ...(yield* read(false)), + }); +}); diff --git a/apps/server/src/provider/SharedProfiles.test.ts b/apps/server/src/provider/SharedProfiles.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b70f1a5fe7d310517104f15f5089b91e368e46eb --- /dev/null +++ b/apps/server/src/provider/SharedProfiles.test.ts @@ -0,0 +1,308 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CodexSettings, + ClaudeSettings, + ProviderDriverKind, + ProviderInstanceId, + type SharedAccount, + SharedAccountError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ManagedProviderHome from "./ManagedProviderHome.ts"; +import * as SharedAccounts from "./SharedAccounts.ts"; +import * as SharedAccountClient from "./SharedAccountClient.ts"; +import * as SharedProfiles from "./SharedProfiles.ts"; +import { codexUsageReader } from "./Drivers/codexUsage.ts"; +import { claudeUsageReader } from "./Drivers/claudeUsage.ts"; + +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); +const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); +const isSharedAccountError = Schema.is(SharedAccountError); + +const fixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const source = yield* fs.makeTempDirectoryScoped(); + const tokenFile = path.join(source, "claude-token"); + yield* fs.writeFileString(tokenFile, "sk-ant-oat01-test-setup-token\n", { mode: 0o600 }); + const accessToken = `eyJhbGciOiJub25lIn0.${Buffer.from(JSON.stringify({ exp: 3_000_000_000 })).toString("base64url")}.signature`; + yield* fs.writeFileString( + path.join(source, "auth.json"), + JSON.stringify({ + tokens: { + access_token: accessToken, + refresh_token: "original-refresh", + id_token: "original-id", + account_id: "account-a", + }, + }), + ); + const settings = ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("personal")]: { + driver: ProviderDriverKind.make("codex"), + displayName: "Personal", + config: { homePath: source }, + }, + [ProviderInstanceId.make("claude")]: { + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude", + config: {}, + }, + }, + }); + const config = ServerConfig.layerTest(process.cwd(), { prefix: "t3-shared-profiles-" }); + let remoteAccounts: ReadonlyArray = []; + const layer = SharedProfiles.layer.pipe( + Layer.provideMerge(ManagedProviderHome.layer), + Layer.provideMerge(SharedAccountClient.layer), + Layer.provideMerge(SharedAccounts.layer), + Layer.provideMerge(settings), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(config), + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ type: "list", accounts: remoteAccounts }), + ), + ), + ), + ), + ), + ); + return { + layer, + source, + tokenFile, + accessToken, + remoteAccounts: (value: ReadonlyArray) => { + remoteAccounts = value; + }, + }; +}); + +it.layer(NodeServices.layer)("managed shared profiles", (it) => { + it.effect( + "imports into private T3-owned homes without copying refresh credentials into native CLI storage", + () => + Effect.gen(function* () { + const f = yield* fixture; + yield* Effect.gen(function* () { + const profiles = yield* SharedProfiles.SharedProfiles; + const homes = yield* ManagedProviderHome.ManagedProviderHome; + const settings = yield* ServerSettings.ServerSettingsService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* profiles.execute({ + type: "import", + instanceId: ProviderInstanceId.make("personal"), + }); + const account = result.accounts[0]; + assert.isDefined(account); + if (!account) return yield* Effect.die("Expected an account"); + const instance = (yield* settings.getSettings).providerInstances[ + ProviderInstanceId.make(`shared-${account.id}`) + ]; + assert.strictEqual(instance?.displayName, "Personal"); + yield* homes.prepare("codex", account.id); + const paths = yield* homes.paths("codex", account.id); + const directories = yield* codexUsageReader.directories({ + instanceId: ProviderInstanceId.make("managed"), + config: yield* decodeCodexSettings({ + sharedAccountId: account.id, + homePath: f.source, + }), + environment: {}, + configured: true, + }); + assert.deepStrictEqual(directories, [{ dir: path.join(paths.shared, "sessions") }]); + const config = yield* ServerConfig.ServerConfig; + assert.isTrue(paths.account.startsWith(config.stateDir)); + const json = yield* fs.readFileString(path.join(paths.account, "auth.json")); + assert.isFalse(json.includes("original-refresh")); + assert.deepInclude(JSON.parse(json), { auth_mode: "chatgptAuthTokens" }); + assert.isTrue( + (yield* fs.readFileString(path.join(f.source, "auth.json"))).includes( + "original-refresh", + ), + ); + assert.strictEqual( + Number((yield* fs.stat(path.join(paths.account, "auth.json"))).mode) & 0o777, + 0o600, + ); + yield* profiles.execute({ type: "remove", id: account.id }); + assert.strictEqual( + (yield* settings.getSettings).providerInstances[ + ProviderInstanceId.make(`shared-${account.id}`) + ]?.enabled, + false, + ); + assert.strictEqual( + (yield* homes.prepare("codex", account.id).pipe(Effect.flip)).reason, + "missing", + ); + }).pipe(Effect.provide(f.layer)); + }), + ); + + it.effect( + "shares Claude session directories between accounts while keeping account configuration separate", + () => + Effect.gen(function* () { + const f = yield* fixture; + yield* Effect.gen(function* () { + const profiles = yield* SharedProfiles.SharedProfiles; + const homes = yield* ManagedProviderHome.ManagedProviderHome; + const hub = yield* SharedAccounts.SharedAccounts; + const settings = yield* ServerSettings.ServerSettingsService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* profiles.execute({ + type: "import", + instanceId: ProviderInstanceId.make("claude"), + tokenFile: f.tokenFile, + }); + const first = result.accounts[0]; + const second = yield* hub.execute({ + type: "import", + label: "Second", + credentials: { driver: "claudeAgent", accessToken: "second-setup-token" }, + }); + if (!first || second.type !== "imported") + return yield* Effect.die("Expected two accounts"); + yield* Effect.all( + [ + homes.prepare("claudeAgent", first.id), + homes.prepare("claudeAgent", first.id), + homes.prepare("claudeAgent", second.account.id), + ], + { concurrency: "unbounded" }, + ); + const a = yield* homes.paths("claudeAgent", first.id); + const b = yield* homes.paths("claudeAgent", second.account.id); + yield* fs.writeFileString( + path.join(a.account, "projects", "session.jsonl"), + "shared-session", + ); + assert.strictEqual( + yield* fs.readFileString(path.join(b.account, "projects", "session.jsonl")), + "shared-session", + ); + yield* fs.writeFileString( + path.join(a.account, ".claude.json"), + "private-account-settings", + ); + assert.isFalse(yield* fs.exists(path.join(b.account, ".claude.json"))); + assert.isFalse( + JSON.stringify(yield* settings.getSettings).includes("sk-ant-oat01-test-setup-token"), + ); + const directories = yield* claudeUsageReader.directories({ + instanceId: ProviderInstanceId.make("managed"), + config: yield* decodeClaudeSettings({ + sharedAccountId: first.id, + homePath: f.source, + }), + environment: {}, + configured: true, + }); + assert.strictEqual( + yield* fs.readFileString(path.join(directories[0]!.dir, "session.jsonl")), + "shared-session", + ); + yield* fs.writeFileString(f.tokenFile, "sk-ant-oat01-renewed-token"); + const renewed = yield* profiles.execute({ + type: "import", + instanceId: ProviderInstanceId.make("claude"), + tokenFile: f.tokenFile, + replaceId: first.id, + }); + assert.strictEqual( + renewed.accounts.find((account) => account.id === first.id)?.revision, + 2, + ); + const instance = (yield* settings.getSettings).providerInstances[ + ProviderInstanceId.make(`shared-${first.id}`) + ]; + assert.strictEqual( + (yield* decodeClaudeSettings(instance?.config)).sharedAccountRevision, + 2, + ); + assert.strictEqual( + (yield* homes.prepare("claudeAgent", first.id)).accessToken, + "sk-ant-oat01-renewed-token", + ); + }).pipe(Effect.provide(f.layer)); + }), + ); + + it.effect( + "disconnect disables managed instances and explicit reconnect restores them without overriding later manual disables", + () => + Effect.gen(function* () { + const f = yield* fixture; + yield* Effect.gen(function* () { + const profiles = yield* SharedProfiles.SharedProfiles; + const settings = yield* ServerSettings.ServerSettingsService; + const result = yield* profiles.execute({ + type: "import", + instanceId: ProviderInstanceId.make("personal"), + }); + const account = result.accounts[0]; + if (!account) return yield* Effect.die("Expected an account"); + f.remoteAccounts(result.accounts); + yield* profiles.execute({ type: "disconnect" }); + const current = yield* settings.getSettings; + assert.strictEqual( + current.providerInstances[ProviderInstanceId.make(`shared-${account.id}`)]?.enabled, + false, + ); + assert.notStrictEqual( + current.providerInstances[ProviderInstanceId.make("personal")]?.enabled, + false, + ); + const id = ProviderInstanceId.make(`shared-${account.id}`); + yield* profiles.execute({ + type: "connect", + url: "https://shared.example.test", + tokenFile: f.tokenFile, + }); + assert.strictEqual((yield* settings.getSettings).providerInstances[id]?.enabled, true); + const instance = (yield* settings.getSettings).providerInstances[id]; + if (!instance) return yield* Effect.die("Expected a managed instance"); + yield* settings.updateProviderInstance({ + operation: "upsert", + instanceId: id, + instance: { ...instance, enabled: false }, + }); + yield* profiles.execute({ type: "sync" }); + assert.strictEqual((yield* settings.getSettings).providerInstances[id]?.enabled, false); + assert.strictEqual( + (yield* profiles + .execute({ type: "import", instanceId: ProviderInstanceId.make("missing") }) + .pipe(Effect.flip)).reason, + "setup", + ); + assert.isTrue( + isSharedAccountError( + yield* (yield* ManagedProviderHome.ManagedProviderHome) + .prepare("codex", "../outside") + .pipe(Effect.flip), + ), + ); + }).pipe(Effect.provide(f.layer)); + }), + ); +}); diff --git a/apps/server/src/provider/SharedProfiles.ts b/apps/server/src/provider/SharedProfiles.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e565bf2f8299d8b00a55bb9ea97cd1bd1249175 --- /dev/null +++ b/apps/server/src/provider/SharedProfiles.ts @@ -0,0 +1,237 @@ +import { + ClaudeSettings, + CodexSettings, + ProviderDriverKind, + ProviderInstanceId, + SharedAccountError, + SharedAccountId, + type SharedProfilesRequest, + type SharedProfilesStatus, +} from "@t3tools/contracts"; +import { decodeJwt } from "jose"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as ServerSettings from "../serverSettings.ts"; +import { resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; +import * as SharedAccountClient from "./SharedAccountClient.ts"; + +const SharedConfig = Schema.Struct({ + sharedAccountId: SharedAccountId, + sharedAccountRevision: Schema.optionalKey(Schema.Int), +}); +const CodexAuthFile = Schema.fromJsonString( + Schema.Struct({ + tokens: Schema.Struct({ + access_token: Schema.NonEmptyString, + refresh_token: Schema.NonEmptyString, + id_token: Schema.NonEmptyString, + account_id: Schema.NonEmptyString, + }), + }), +); + +const isSharedConfig = Schema.is(SharedConfig); +const decodeSharedConfig = Schema.decodeUnknownOption(SharedConfig); +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); +const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); +const decodeCodexAuth = Schema.decodeEffect(CodexAuthFile); +const isSharedAccountError = Schema.is(SharedAccountError); +const readConfig = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)); + +export class SharedProfiles extends Context.Service< + SharedProfiles, + { + readonly execute: ( + request: SharedProfilesRequest, + ) => Effect.Effect; + } +>()("t3/provider/SharedProfiles") {} + +const make = Effect.gen(function* () { + const accounts = yield* SharedAccountClient.SharedAccountClient; + const settings = yield* ServerSettings.ServerSettingsService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lock = yield* Semaphore.make(1); + + const sync = (enable = false) => + Effect.gen(function* () { + const response = yield* accounts.execute({ type: "list" }); + if (response.type !== "list") return yield* new SharedAccountError({ reason: "connection" }); + const current = yield* settings.getSettings.pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + for (const account of response.accounts) { + const instanceId = ProviderInstanceId.make(`shared-${account.id}`); + const previous = current.providerInstances[instanceId]; + const config = + account.driver === "codex" + ? { + sharedAccountId: account.id, + setupMode: "existing", + } + : { sharedAccountId: account.id, sharedAccountRevision: account.revision }; + const previousShared = decodeSharedConfig(previous?.config); + if ( + previous?.displayName === account.label && + previousShared._tag === "Some" && + (!enable || previous.enabled !== false) && + (account.driver !== "claudeAgent" || + previousShared.value.sharedAccountRevision === account.revision) + ) + continue; + const previousConfig = Option.getOrElse(readConfig(previous?.config), () => ({})); + yield* settings + .updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { + ...previous, + ...(enable ? { enabled: true } : {}), + driver: ProviderDriverKind.make(account.driver), + displayName: account.label, + config: { + ...previousConfig, + ...config, + }, + }, + }) + .pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + } + const ids = new Set(response.accounts.map((account) => account.id)); + for (const [key, instance] of Object.entries(current.providerInstances)) { + const shared = decodeSharedConfig(instance.config); + if ( + shared._tag === "Some" && + !ids.has(shared.value.sharedAccountId) && + instance.enabled !== false + ) { + yield* settings + .updateProviderInstance({ + operation: "upsert", + instanceId: ProviderInstanceId.make(key), + instance: { ...instance, enabled: false }, + }) + .pipe( + Effect.mapError( + (cause) => + new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + } + } + return { connected: yield* accounts.configured, accounts: response.accounts }; + }); + + const execute = Effect.fn("SharedProfiles.execute")(function* (request: SharedProfilesRequest) { + return yield* lock.withPermit( + Effect.gen(function* () { + if (request.type === "disconnect") { + const current = yield* settings.getSettings.pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + for (const [id, instance] of Object.entries(current.providerInstances)) { + if (!isSharedConfig(instance.config) || instance.enabled === false) continue; + yield* settings + .updateProviderInstance({ + operation: "upsert", + instanceId: ProviderInstanceId.make(id), + instance: { ...instance, enabled: false }, + }) + .pipe( + Effect.mapError( + (cause) => + new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + } + yield* accounts.disconnect; + return { connected: false, accounts: [] }; + } + if (request.type === "connect") { + const token = yield* fs.readFileString(request.tokenFile).pipe( + Effect.map((value) => value.trim()), + Effect.mapError( + (cause) => new SharedAccountError({ reason: "setup", cause: Redacted.make(cause) }), + ), + ); + yield* accounts.configure({ url: request.url, token }); + } + if (request.type === "remove") yield* accounts.execute(request); + if (request.type === "import") { + const current = yield* settings.getSettings.pipe( + Effect.mapError( + (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), + ), + ); + const source = current.providerInstances[request.instanceId]; + if (!source || isSharedConfig(source.config)) + return yield* new SharedAccountError({ reason: "setup" }); + const credentials = yield* Effect.gen(function* () { + if (source.driver === "claudeAgent") { + if (!request.tokenFile) return yield* new SharedAccountError({ reason: "setup" }); + yield* decodeClaudeSettings(source.config ?? {}); + const accessToken = (yield* fs.readFileString(request.tokenFile)).trim(); + if (!accessToken.startsWith("sk-ant-oat")) + return yield* new SharedAccountError({ reason: "setup" }); + return { driver: "claudeAgent" as const, accessToken }; + } + if (source.driver !== "codex") + return yield* new SharedAccountError({ reason: "setup" }); + const config = yield* decodeCodexSettings(source.config ?? {}); + if (config.setupMode === "managed") + return yield* new SharedAccountError({ reason: "setup" }); + const home = yield* resolveCodexHomeLayout(config).pipe( + Effect.provideService(Path.Path, path), + ); + const auth = yield* fs + .readFileString(path.join(home.effectiveHomePath ?? home.sharedHomePath, "auth.json")) + .pipe(Effect.flatMap(decodeCodexAuth)); + const exp = yield* Effect.try(() => decodeJwt(auth.tokens.access_token).exp); + if (typeof exp !== "number") return yield* new SharedAccountError({ reason: "setup" }); + return { + driver: "codex" as const, + accessToken: auth.tokens.access_token, + refreshToken: auth.tokens.refresh_token, + idToken: auth.tokens.id_token, + accountId: auth.tokens.account_id, + expiresAt: exp * 1000, + }; + }).pipe( + Effect.mapError((cause) => + isSharedAccountError(cause) + ? cause + : new SharedAccountError({ reason: "setup", cause: Redacted.make(cause) }), + ), + ); + yield* accounts.execute({ + type: "import", + label: source.displayName ?? request.instanceId, + credentials, + ...(request.replaceId ? { replaceId: request.replaceId } : {}), + }); + } + return yield* sync(request.type === "connect"); + }), + ); + }); + + return SharedProfiles.of({ execute }); +}); + +export const layer = Layer.effect(SharedProfiles, make); diff --git a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs index 1726bceeab6236e7e1fac6b5857f841847625f26..475d4f25626f278f6064f81cd94834d450e3011b 100755 --- a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs +++ b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -1,6 +1,16 @@ #!/usr/bin/env node import * as NodeFS from "node:fs"; import * as NodeReadline from "node:readline"; +const requiredToken = process.env.T3_CLAUDE_REQUIRED_TOKEN; +if ( + requiredToken && + (process.env.CLAUDE_CODE_OAUTH_TOKEN !== requiredToken || + process.env.ANTHROPIC_API_KEY !== "" || + process.env.ANTHROPIC_AUTH_TOKEN !== "") +) { + process.stderr.write("claude: managed credentials did not reach the subprocess\n"); + process.exit(1); +} // Opt-in: act like a `claude` wrapper that only runs inside one directory. const requiredCwd = process.env.T3_CLAUDE_REQUIRED_CWD; if (requiredCwd && process.cwd() !== NodeFS.realpathSync(requiredCwd)) { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dd186689f34fa7f2982479727f4c708075d72cb5..6eb2926b367606badd6025f92017810d0b030069 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,7 @@ +import * as SharedAccounts from "./provider/SharedAccounts.ts"; +import * as SharedAccountClient from "./provider/SharedAccountClient.ts"; +import * as ManagedProviderHome from "./provider/ManagedProviderHome.ts"; +import * as SharedProfiles from "./provider/SharedProfiles.ts"; import * as UiSettings from "./uiSettings.ts"; import * as ScriptProgress from "./progress/ScriptProgress.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; @@ -549,6 +553,24 @@ const layerProviderInstallationRefresh = Layer.effectDiscard( ); const layerRuntimeCoreDependenciesBase = Layer.mergeAll( + Layer.effectDiscard( + Effect.gen(function* () { + const profiles = yield* SharedProfiles.SharedProfiles; + yield* ServerActivation.forkParked( + Effect.forever( + profiles.execute({ type: "sync" }).pipe( + Effect.catchTags({ + SharedAccountError: (error) => + Effect.logWarning("Shared account sync could not finish", { + reason: error.reason, + }), + }), + Effect.andThen(Effect.sleep("1 minute")), + ), + ), + ); + }), + ), AgentAwarenessRelay.layer, // Asks T3 Connect to deliver webhooks it held while this environment was offline. HeldHooksWaker.layer, @@ -626,6 +648,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ); const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( + Layer.provideMerge(SharedProfiles.layer), + Layer.provideMerge(ManagedProviderHome.layer), + Layer.provideMerge(SharedAccountClient.layer), + Layer.provideMerge(SharedAccounts.layer), Layer.provideMerge(layerPtyAdapter), // Search, prepare, status inspection, and turn launch share one registry // cache so every client and provider instance sees the same prepared agents. @@ -719,6 +745,8 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerOtlpTracesProxyRoute, ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, + ServerHttp.layerSharedAccountRoute, + ServerHttp.layerSharedProfilesRoute, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ff1de91addc9c863f6b90fe5fa7b0d7c0950f467..d6eb07f65a222897e656a5478bf8624dbab38056 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -8,6 +8,14 @@ import { } from "./ProviderSettingsForm"; describe("ProviderSettingsForm helpers", () => { + it("keeps managed account homes out of the editable runtime fields", () => { + const codex = providerClients.get(ProviderDriverKind.make("codex"))!; + expect( + deriveProviderSettingsFields(codex, { sharedAccountId: "managed-account" }).map( + (field) => field.key, + ), + ).toEqual(["binaryPath", "launchArgs"]); + }); it("derives visible provider config fields from the client definition schema", () => { const codex = providerClients.get(ProviderDriverKind.make("codex")); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index bdd87e24fca225333be0ac1eb375185241020e43..3248c110526189d8dffd969b1a2aff6ceebf3d20 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -92,6 +92,7 @@ export function deriveProviderSettingsFields( const isLocalAcp = definition.driverKind === "acpRegistry" && readProviderConfigString(value, "source") === "local"; + const sharedAccount = readProviderConfigString(value, "sharedAccountId"); const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); const orderedKeys = new Map( (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), @@ -110,6 +111,7 @@ export function deriveProviderSettingsFields( const fieldSchema = definition.settingsSchema.fields[key]!; const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); if (formAnnotation.hidden) return []; + if (sharedAccount && (key === "homePath" || key === "shadowHomePath")) return []; if (isLocalAcp && key !== "source" && key !== "commandPath") return []; const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b596bd0f190825e54a8256c37735ab456fcb5926 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,22 @@ +services: + knife: + image: ${KNIFE_IMAGE:-knife:local} + build: + context: . + restart: unless-stopped + stop_grace_period: 90s + ports: + - "${KNIFE_BIND_ADDRESS:-127.0.0.1}:${KNIFE_PORT:-3773}:3773" + environment: + KNIFE_UPDATE_URL: ${KNIFE_UPDATE_URL:?Set the public release feed URL} + T3CODE_HOME: /data + T3CODE_ARCHIVE_DIR: /archives + volumes: + - state:/data + - provider-home:/home/node + - ${KNIFE_ARCHIVES_DIRECTORY:?Set the host archive directory}:/archives + - ${KNIFE_WORKSPACES_DIRECTORY:?Set the host workspace directory}:/workspaces + +volumes: + state: + provider-home: diff --git a/docs/user/shared-server.md b/docs/user/shared-server.md new file mode 100644 index 0000000000000000000000000000000000000000..e84036522add4253fe83e243029801e84b02bc7d --- /dev/null +++ b/docs/user/shared-server.md @@ -0,0 +1,38 @@ +# Shared accounts + +A private T3 server can hold managed Codex and Claude accounts for your other hosts. Each host runs agents against its own workspaces. The shared server can run agents too. Provider homes and cached credentials live inside each host’s T3 home; existing CLI homes stay separate. This is opt-in and does not move native CLI history between machines. + +Start the shared server, then issue a separate revocable credential for each execution host. Keep the token in a private file: + +```sh +umask 077 +t3 auth session issue --label laptop --ttl 3650d --scope providers:manage --scope orchestration:read --scope orchestration:operate --token-only > laptop-token +``` + +On the host with your signed-in profiles, connect its running T3 server and import each profile by its instance ID from Providers settings: + +```sh +t3 shared connect https://t3.example.com --token-file /private/path/laptop-token +t3 shared import codex_personal +t3 shared import claude_personal --token-file /private/path/claude-setup-token +``` + +For Claude, generate the token once with `claude setup-token` while signed into the intended account. The token lasts one year. Other execution hosts only need their own shared-server credential and `t3 shared connect`; the managed profiles appear in their model pickers. Select those profiles for shared accounts. Avoid running the original Codex profile concurrently: it owns an independent copy of the same refresh credential. + +Connected hosts coordinate Codex refreshes with expiring claims. Another host can take over an abandoned claim. During a shared-server outage, already cached access works until it expires; refresh waits for the server to return. A lost provider response can still require signing in again. + +Use `t3 shared status` to list shared accounts, `t3 shared sync` for an immediate refresh, and `t3 shared disconnect` to disable managed profiles on this host. Remove an account everywhere with `t3 shared remove ACCOUNT_ID`. To renew credentials without changing its shared identity, import the signed-in source profile again with `--replace ACCOUNT_ID` (and the new `--token-file` for Claude). + +## Run in a container + +Build from this checkout with `docker build -t the-knife .`. The image includes the web client, server, Codex, and Claude. Mount persistent private storage and your workspaces: + +```sh +docker run --name the-knife -p 127.0.0.1:3773:3773 \ + -e KNIFE_UPDATE_URL=https://files.example.com/public-builds/ \ + -v /private/t3:/data -v /private/provider-home:/home/node \ + -v /private/archives:/archives \ + -v /private/workspaces:/workspaces the-knife +``` + +The process runs as UID 1000; give it access to these directories. Put an HTTPS reverse proxy in front of port 3773, including WebSocket upgrades. Pair clients using the normal T3 pairing flow. Use `docker exec the-knife knife auth session issue …` for server-side account setup. Back up `/data` and `/home/node`, including their private credentials, independently of `/archives`. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c265032c8116a0e45aee7e1fa2cd68e041898e05..8e385387b0ec78716d0cf9d1d6e790fc63addc8e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -69,3 +69,4 @@ export * from "./desktopTerminal.ts"; export * from "./scriptProgress.ts"; export * from "./uiSettings.ts"; +export * from "./sharedAccounts.ts"; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 237160cf07115df154a6b4db719c8909b364d4f9..414314d76eeabdd3f5edf5310e0fd996bbe031b0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -45,6 +45,7 @@ import { ProviderDriverKind, } from "./providerInstance.ts"; import { PullRequestMergeMethod } from "./pullRequest.ts"; +import { SharedAccountId } from "./sharedAccounts.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -617,6 +618,9 @@ export function makeProviderSettingsSchema ({ + driver, + accessToken, + idToken, + accountId, + expiresAt, + })), + SharedClaudeCredentials, +]); +export type SharedAccountAccess = typeof SharedAccountAccess.Type; + +export const SharedAccountCommit = Schema.Struct({ + type: Schema.Literal("commit"), + id: SharedAccountId, + revision: Schema.Int, + leaseToken: Schema.NonEmptyString, + credentials: SharedCodexCredentials, +}); + +export const SharedAccountRequest = Schema.Union([ + Schema.Struct({ type: Schema.Literal("list") }), + Schema.Struct({ + type: Schema.Literal("import"), + label: SharedAccount.fields.label, + replaceId: Schema.optionalKey(SharedAccountId), + credentials: SharedAccountCredentials, + }), + Schema.Struct({ type: Schema.Literal("remove"), id: SharedAccountId }), + Schema.Struct({ + type: Schema.Literal("acquire"), + id: SharedAccountId, + refreshRevision: Schema.optionalKey(Schema.Int), + }), + SharedAccountCommit, +]); +export type SharedAccountRequest = typeof SharedAccountRequest.Type; + +export const SharedAccountResponse = Schema.Union([ + Schema.Struct({ type: Schema.Literal("list"), accounts: Schema.Array(SharedAccount) }), + Schema.Struct({ type: Schema.Literal("imported"), account: SharedAccount }), + Schema.Struct({ type: Schema.Literal("removed") }), + Schema.Struct({ + type: Schema.Literal("access"), + account: SharedAccount, + credentials: SharedAccountAccess, + }), + Schema.Struct({ + type: Schema.Literal("refresh"), + account: SharedAccount, + leaseToken: Schema.NonEmptyString, + credentials: SharedCodexCredentials, + expiresAt: Schema.Finite, + }), + Schema.Struct({ type: Schema.Literal("busy"), retryAt: Schema.Finite }), +]); +export type SharedAccountResponse = typeof SharedAccountResponse.Type; + +export const SharedProfilesRequest = Schema.Union([ + Schema.Struct({ type: Schema.Literal("status") }), + Schema.Struct({ type: Schema.Literal("sync") }), + Schema.Struct({ + type: Schema.Literal("connect"), + url: Schema.NonEmptyString, + tokenFile: Schema.NonEmptyString, + }), + Schema.Struct({ type: Schema.Literal("disconnect") }), + Schema.Struct({ + type: Schema.Literal("import"), + instanceId: ProviderInstanceId, + tokenFile: Schema.optionalKey(Schema.NonEmptyString), + replaceId: Schema.optionalKey(SharedAccountId), + }), + Schema.Struct({ type: Schema.Literal("remove"), id: SharedAccountId }), +]); +export type SharedProfilesRequest = typeof SharedProfilesRequest.Type; + +export const SharedProfilesStatus = Schema.Struct({ + connected: Schema.Boolean, + accounts: Schema.Array(SharedAccount), +}); +export type SharedProfilesStatus = typeof SharedProfilesStatus.Type; + +export class SharedAccountError extends Schema.TaggedError()( + "SharedAccountError", + { + reason: Schema.Literals([ + "storage", + "missing", + "stale", + "identity", + "connection", + "refresh", + "busy", + "setup", + ]), + cause: Schema.optionalKey(Schema.Redacted(Schema.Defect())), + }, +) { + override get message(): string { + switch (this.reason) { + case "setup": + return "This profile could not be shared. Use a signed-in Codex profile or provide a Claude setup-token file."; + case "busy": + return "Another host is refreshing this account. Retry in a moment."; + case "missing": + return "This shared account was removed. Choose another account in Providers."; + case "stale": + return "Another host updated this account. Reconnect to load its latest credentials."; + case "identity": + return "The refreshed account changed. Import the intended account again."; + case "connection": + return "The shared server is unavailable. Reconnect before refreshing this account."; + case "refresh": + return "Codex could not refresh this account. Reconnect the account on the shared server."; + case "storage": + return "Shared accounts could not be saved. Check the shared server’s storage and retry."; + } + } +} -- 2.54.0