| author | |
| committer | |
| log | 58df35b5187753f5f3de7499ef1287785ca6fbcc |
| tree | 6431307df1f38df9a19af89a5e8ae1e9fd9e8814 |
| parent | 44b17c3a0daa9b95d2b15f3d87ff07cf8c3b3d64 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol29 files changed, 2535 insertions(+), 27 deletions(-)
.dockerignore created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | **/node_modules | |
| 2 | **/dist | |
| 3 | **/dist-* | |
| 4 | **/.vite-plus | |
| 5 | **/.t3 | |
| 6 | .jj | |
| 7 | .git | |
| 8 | .repos | |
| 9 | .plans | |
| 10 | .env | |
| 11 | .env.* | |
| 12 | **/.env | |
| 13 | **/.env.* | |
| 14 | **/*.log | |
| 15 | **/*.sqlite | |
| 16 | **/*.sqlite-* | |
| 17 | **/.DS_Store | |
| 18 | artifacts | |
| 19 | release | |
| 20 | releases | |
| 21 | tmp | |
| 22 | build | |
| 23 | .logs | |
| 24 | release-mock | |
| 25 | .pnpm-store | |
| 26 | .electron-runtime | |
| 27 | .showcase | |
| 28 | **/target | |
| 29 | **/.generated | |
| 30 | **/.showcase | |
| 31 | **/.playwright | |
| 32 | **/playwright-report | |
| 33 | .audits |
Dockerfile created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | FROM node:24-bookworm-slim AS build | |
| 2 | 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/* | |
| 3 | RUN corepack enable | |
| 4 | ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 | |
| 5 | ENV VP_GIT_HOOKS=0 | |
| 6 | WORKDIR /src | |
| 7 | COPY . . | |
| 8 | # The unused compiler's launcher probe loops under x64 emulation. | |
| 9 | RUN sed -i 's/^ tsc-rs: true$/ tsc-rs: false/' pnpm-workspace.yaml && pnpm install --frozen-lockfile | |
| 10 | RUN pnpm exec vp run --filter t3 build | |
| 11 | RUN pnpm --filter t3 deploy --prod --legacy /opt/knife/apps/server | |
| 12 | 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()));' | |
| 13 | ||
| 14 | FROM node:24-bookworm-slim | |
| 15 | ARG CODEX_VERSION=0.163.0 | |
| 16 | ARG CLAUDE_VERSION=2.1.301 | |
| 17 | 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/* | |
| 18 | RUN npm install --global @openai/codex@${CODEX_VERSION} @anthropic-ai/claude-code@${CLAUDE_VERSION} && npm cache clean --force | |
| 19 | COPY --from=build --chown=node:node /opt/knife /opt/knife | |
| 20 | COPY scripts/container-runtime.ts /opt/knife/scripts/container-runtime.ts | |
| 21 | COPY packages/shared/package.json /opt/knife/packages/shared/package.json | |
| 22 | COPY packages/shared/src/forkUpdateDirectory.ts /opt/knife/packages/shared/src/forkUpdateDirectory.ts | |
| 23 | 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 | |
| 24 | COPY --chmod=755 infra/container/knife /usr/local/bin/knife | |
| 25 | RUN mkdir /data /archives /workspaces && chown node:node /data /archives /workspaces | |
| 26 | USER node | |
| 27 | WORKDIR /workspaces | |
| 28 | ENV T3CODE_HOME=/data | |
| 29 | EXPOSE 3773 | |
| 30 | VOLUME ["/data", "/home/node", "/archives", "/workspaces"] | |
| 31 | ENTRYPOINT ["/usr/bin/tini", "--", "knife"] | |
| 32 | CMD ["serve", "--host", "0.0.0.0", "--port", "3773"] |
apps/server/src/binCli.ts+2| ... | ... | @@ -26,6 +26,7 @@ import { serviceCommand } from "./cli/service.ts"; |
| 26 | 26 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; |
| 27 | 27 | import { themeCommand } from "./cli/theme.ts"; |
| 28 | 28 | import { traceCommand } from "./cli/trace.ts"; |
| 29 | import { sharedCommand } from "./cli/shared.ts"; | |
| 29 | 30 | import { triageCommand } from "./cli/triage.ts"; |
| 30 | 31 | |
| 31 | 32 | const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| ... | ... | @@ -84,6 +85,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 84 | 85 | servicePreflightCommand, |
| 85 | 86 | themeCommand, |
| 86 | 87 | traceCommand, |
| 88 | sharedCommand, | |
| 87 | 89 | triageCommand, |
| 88 | 90 | cloudEnabled ? connectCommand : connectUnavailableCommand, |
| 89 | 91 | ]), |
apps/server/src/cli/shared.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import { | |
| 2 | AuthProvidersManageScope, | |
| 3 | ProviderInstanceId, | |
| 4 | SharedAccountError, | |
| 5 | SharedAccountId, | |
| 6 | SharedProfilesStatus, | |
| 7 | type SharedProfilesRequest, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Console from "effect/Console"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import * as References from "effect/References"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; | |
| 16 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 17 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | |
| 18 | import * as ServerConfig from "../config.ts"; | |
| 19 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; | |
| 20 | import { authLocationFlags, resolveCliAuthConfig, type CliAuthLocationFlags } from "./config.ts"; | |
| 21 | ||
| 22 | const encodeStatus = Schema.encodeEffect(Schema.fromJsonString(SharedProfilesStatus)); | |
| 23 | ||
| 24 | const run = (flags: CliAuthLocationFlags, request: SharedProfilesRequest) => | |
| 25 | Effect.gen(function* () { | |
| 26 | const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel); | |
| 27 | const state = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); | |
| 28 | if (Option.isNone(state)) return yield* new SharedAccountError({ reason: "connection" }); | |
| 29 | return yield* Effect.gen(function* () { | |
| 30 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 31 | const http = yield* HttpClient.HttpClient; | |
| 32 | const result = yield* Effect.acquireUseRelease( | |
| 33 | auth.issueSession({ scopes: [AuthProvidersManageScope], label: "t3 shared cli" }), | |
| 34 | (session) => | |
| 35 | HttpClientRequest.post(`${state.value.origin}/api/shared/profiles`).pipe( | |
| 36 | HttpClientRequest.setHeader("authorization", `Bearer ${session.token}`), | |
| 37 | HttpClientRequest.bodyJson(request), | |
| 38 | Effect.flatMap(http.execute), | |
| 39 | Effect.flatMap((response) => | |
| 40 | response.status === 200 | |
| 41 | ? HttpClientResponse.schemaBodyJson(SharedProfilesStatus)(response) | |
| 42 | : HttpClientResponse.schemaBodyJson(SharedAccountError)(response).pipe( | |
| 43 | Effect.flatMap(Effect.fail), | |
| 44 | ), | |
| 45 | ), | |
| 46 | Effect.timeout("30 seconds"), | |
| 47 | ), | |
| 48 | (session) => auth.revokeSession(session.sessionId).pipe(Effect.ignore({ log: true })), | |
| 49 | ); | |
| 50 | yield* Console.log(yield* encodeStatus(result)); | |
| 51 | }).pipe( | |
| 52 | Effect.scoped, | |
| 53 | Effect.provide( | |
| 54 | EnvironmentAuth.layerRuntime.pipe( | |
| 55 | Layer.provide(ServerConfig.layer(config)), | |
| 56 | Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), | |
| 57 | ), | |
| 58 | ), | |
| 59 | Effect.provide(FetchHttpClient.layer), | |
| 60 | ); | |
| 61 | }); | |
| 62 | ||
| 63 | const status = Command.make("status", authLocationFlags).pipe( | |
| 64 | Command.withDescription("List shared accounts on this host."), | |
| 65 | Command.withHandler((flags) => run(flags, { type: "status" })), | |
| 66 | ); | |
| 67 | const sync = Command.make("sync", authLocationFlags).pipe( | |
| 68 | Command.withDescription("Refresh shared profiles from the shared server."), | |
| 69 | Command.withHandler((flags) => run(flags, { type: "sync" })), | |
| 70 | ); | |
| 71 | const connect = Command.make("connect", { | |
| 72 | ...authLocationFlags, | |
| 73 | url: Argument.String("url"), | |
| 74 | tokenFile: Flag.String("token-file").pipe( | |
| 75 | Flag.withDescription("Private file containing this host’s shared-server bearer token."), | |
| 76 | ), | |
| 77 | }).pipe( | |
| 78 | Command.withDescription("Connect this host to a private T3 server."), | |
| 79 | Command.withHandler((flags) => | |
| 80 | run(flags, { type: "connect", url: flags.url, tokenFile: flags.tokenFile }), | |
| 81 | ), | |
| 82 | ); | |
| 83 | const disconnect = Command.make("disconnect", authLocationFlags).pipe( | |
| 84 | Command.withDescription("Disconnect and disable this host’s shared profiles."), | |
| 85 | Command.withHandler((flags) => run(flags, { type: "disconnect" })), | |
| 86 | ); | |
| 87 | const importAccount = Command.make("import", { | |
| 88 | ...authLocationFlags, | |
| 89 | instanceId: Argument.String("instance-id").pipe(Argument.withSchema(ProviderInstanceId)), | |
| 90 | tokenFile: Flag.String("token-file").pipe( | |
| 91 | Flag.withDescription("Private file containing a Claude setup token."), | |
| 92 | Flag.optional, | |
| 93 | ), | |
| 94 | replaceId: Flag.String("replace").pipe( | |
| 95 | Flag.withSchema(SharedAccountId), | |
| 96 | Flag.withDescription("Replace the credentials of an existing shared account."), | |
| 97 | Flag.optional, | |
| 98 | ), | |
| 99 | }).pipe( | |
| 100 | Command.withDescription("Copy a signed-in profile into managed shared accounts."), | |
| 101 | Command.withHandler((flags) => | |
| 102 | run(flags, { | |
| 103 | type: "import", | |
| 104 | instanceId: flags.instanceId, | |
| 105 | ...(Option.isSome(flags.tokenFile) ? { tokenFile: flags.tokenFile.value } : {}), | |
| 106 | ...(Option.isSome(flags.replaceId) ? { replaceId: flags.replaceId.value } : {}), | |
| 107 | }), | |
| 108 | ), | |
| 109 | ); | |
| 110 | const remove = Command.make("remove", { | |
| 111 | ...authLocationFlags, | |
| 112 | id: Argument.String("account-id").pipe(Argument.withSchema(SharedAccountId)), | |
| 113 | }).pipe( | |
| 114 | Command.withDescription("Remove a shared account from every connected host."), | |
| 115 | Command.withHandler((flags) => run(flags, { type: "remove", id: flags.id })), | |
| 116 | ); | |
| 117 | ||
| 118 | export const sharedCommand = Command.make("shared").pipe( | |
| 119 | Command.withDescription("Manage shared provider accounts."), | |
| 120 | Command.withSubcommands([status, connect, disconnect, importAccount, sync, remove]), | |
| 121 | ); |
apps/server/src/http.ts+78-3| ... | ... | @@ -2,6 +2,11 @@ import * as Mime from "effect/http/Mime"; |
| 2 | 2 | import { |
| 3 | 3 | AuthOrchestrationOperateScope, |
| 4 | 4 | AuthOrchestrationReadScope, |
| 5 | AuthProvidersManageScope, | |
| 6 | SharedAccountRequest, | |
| 7 | SharedProfilesRequest, | |
| 8 | SharedAccountError, | |
| 9 | type AuthEnvironmentScope, | |
| 5 | 10 | EnvironmentHttpApi, |
| 6 | 11 | } from "@t3tools/contracts"; |
| 7 | 12 | import { isDevProxiedPath } from "@t3tools/shared/devProxy"; |
| ... | ... | @@ -9,6 +14,7 @@ import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tool |
| 9 | 14 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 10 | 15 | import * as Data from "effect/Data"; |
| 11 | 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Redacted from "effect/Redacted"; | |
| 12 | 18 | import * as FileSystem from "effect/FileSystem"; |
| 13 | 19 | import * as Layer from "effect/Layer"; |
| 14 | 20 | import * as Option from "effect/Option"; |
| ... | ... | @@ -39,6 +45,8 @@ import { |
| 39 | 45 | } from "./assets/AttachmentUpload.ts"; |
| 40 | 46 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| 41 | 47 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 48 | import * as SharedAccounts from "./provider/SharedAccounts.ts"; | |
| 49 | import * as SharedProfiles from "./provider/SharedProfiles.ts"; | |
| 42 | 50 | import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; |
| 43 | 51 | import { |
| 44 | 52 | annotateEnvironmentRequest, |
| ... | ... | @@ -299,9 +307,7 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { |
| 299 | 307 | return redirectUrl.toString(); |
| 300 | 308 | } |
| 301 | 309 | |
| 302 | const authenticateRawRouteWithScope = ( | |
| 303 | scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, | |
| 304 | ) => | |
| 310 | const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) => | |
| 305 | 311 | Effect.gen(function* () { |
| 306 | 312 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 307 | 313 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| ... | ... | @@ -321,6 +327,75 @@ const authenticateRawRouteWithScope = ( |
| 321 | 327 | } |
| 322 | 328 | }); |
| 323 | 329 | |
| 330 | export const layerSharedAccountRoute = HttpRouter.add( | |
| 331 | "POST", | |
| 332 | "/api/shared/accounts", | |
| 333 | Effect.gen(function* () { | |
| 334 | yield* authenticateRawRouteWithScope(AuthProvidersManageScope); | |
| 335 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 336 | const accounts = yield* SharedAccounts.SharedAccounts; | |
| 337 | const input = yield* request.json.pipe( | |
| 338 | Effect.flatMap(Schema.decodeUnknownEffect(SharedAccountRequest)), | |
| 339 | Effect.mapError( | |
| 340 | (cause) => new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }), | |
| 341 | ), | |
| 342 | ); | |
| 343 | return HttpServerResponse.jsonUnsafe(yield* accounts.execute(input), { | |
| 344 | headers: { "cache-control": "no-store" }, | |
| 345 | }); | |
| 346 | }).pipe( | |
| 347 | Effect.catchTags({ | |
| 348 | SharedAccountError: (error) => | |
| 349 | Effect.succeed( | |
| 350 | HttpServerResponse.jsonUnsafe( | |
| 351 | { _tag: error._tag, reason: error.reason }, | |
| 352 | { | |
| 353 | status: error.reason === "missing" ? 404 : error.reason === "stale" ? 409 : 503, | |
| 354 | headers: { "cache-control": "no-store" }, | |
| 355 | }, | |
| 356 | ), | |
| 357 | ), | |
| 358 | EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, | |
| 359 | EnvironmentInternalError: HttpServerRespondable.toResponse, | |
| 360 | EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, | |
| 361 | }), | |
| 362 | Effect.withTracerEnabled(false), | |
| 363 | ), | |
| 364 | ); | |
| 365 | ||
| 366 | export const layerSharedProfilesRoute = HttpRouter.add( | |
| 367 | "POST", | |
| 368 | "/api/shared/profiles", | |
| 369 | Effect.gen(function* () { | |
| 370 | yield* authenticateRawRouteWithScope(AuthProvidersManageScope); | |
| 371 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 372 | const profiles = yield* SharedProfiles.SharedProfiles; | |
| 373 | const input = yield* request.json.pipe( | |
| 374 | Effect.flatMap(Schema.decodeUnknownEffect(SharedProfilesRequest)), | |
| 375 | Effect.mapError( | |
| 376 | (cause) => new SharedAccountError({ reason: "setup", cause: Redacted.make(cause) }), | |
| 377 | ), | |
| 378 | ); | |
| 379 | return HttpServerResponse.jsonUnsafe(yield* profiles.execute(input), { | |
| 380 | headers: { "cache-control": "no-store" }, | |
| 381 | }); | |
| 382 | }).pipe( | |
| 383 | Effect.catchTags({ | |
| 384 | SharedAccountError: (error) => | |
| 385 | Effect.succeed( | |
| 386 | HttpServerResponse.jsonUnsafe( | |
| 387 | { _tag: error._tag, reason: error.reason }, | |
| 388 | { status: 503, headers: { "cache-control": "no-store" } }, | |
| 389 | ), | |
| 390 | ), | |
| 391 | EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, | |
| 392 | EnvironmentInternalError: HttpServerRespondable.toResponse, | |
| 393 | EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, | |
| 394 | }), | |
| 395 | Effect.withTracerEnabled(false), | |
| 396 | ), | |
| 397 | ); | |
| 398 | ||
| 324 | 399 | export const layerServerEnvironmentHttpApi = HttpApiBuilder.group( |
| 325 | 400 | EnvironmentHttpApi, |
| 326 | 401 | "metadata", |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+5-1| ... | ... | @@ -1603,7 +1603,7 @@ export type CodexAdapterV2DriverEnv = |
| 1603 | 1603 | |
| 1604 | 1604 | export const createCodexAdapterV2 = ( |
| 1605 | 1605 | { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput<CodexSettings>, |
| 1606 | hooks: Pick<CodexAdapterV2Options, "onUsageLimits" | "resolveRuntime"> = {}, | |
| 1606 | hooks: Pick<CodexAdapterV2Options, "onUsageLimits" | "resolveRuntime" | "authenticate"> = {}, | |
| 1607 | 1607 | ) => |
| 1608 | 1608 | Effect.gen(function* () { |
| 1609 | 1609 | const clientFactory = yield* CodexAppServerClientFactory; |
| ... | ... | @@ -1702,6 +1702,9 @@ export interface CodexAdapterV2Options { |
| 1702 | 1702 | * Codex with a current access token. |
| 1703 | 1703 | */ |
| 1704 | 1704 | readonly resolveRuntime?: Effect.Effect<CodexEffectiveRuntime, ProviderSetupError, Scope.Scope>; |
| 1705 | readonly authenticate?: ( | |
| 1706 | client: CodexClient.CodexAppServerClient["Service"], | |
| 1707 | ) => Effect.Effect<void, CodexErrors.CodexAppServerError>; | |
| 1705 | 1708 | readonly crypto: Crypto.Crypto; |
| 1706 | 1709 | readonly fileSystem: FileSystem.FileSystem; |
| 1707 | 1710 | readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; |
| ... | ... | @@ -1831,6 +1834,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 1831 | 1834 | }), |
| 1832 | 1835 | ); |
| 1833 | 1836 | yield* client.notify("initialized", undefined); |
| 1837 | if (adapterOptions.authenticate) yield* adapterOptions.authenticate(client); | |
| 1834 | 1838 | yield* Ref.set(initialized, true); |
| 1835 | 1839 | }), |
| 1836 | 1840 | ); |
apps/server/src/provider/Drivers/ClaudeDriver.ts+54-3| ... | ... | @@ -24,6 +24,7 @@ import { HttpClient } from "effect/http"; |
| 24 | 24 | import { ChildProcessSpawner } from "effect/process"; |
| 25 | 25 | |
| 26 | 26 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; |
| 27 | import * as ManagedProviderHome from "../ManagedProviderHome.ts"; | |
| 27 | 28 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 28 | 29 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 29 | 30 | import * as ServerConfig from "../../config.ts"; |
| ... | ... | @@ -96,6 +97,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ |
| 96 | 97 | }); |
| 97 | 98 | |
| 98 | 99 | export type ClaudeDriverEnv = |
| 100 | | ManagedProviderHome.ManagedProviderHome | |
| 99 | 101 | | ClaudeAdapterV2DriverEnv |
| 100 | 102 | | ProviderHost.ProviderHost |
| 101 | 103 | | ChildProcessSpawner.ChildProcessSpawner |
| ... | ... | @@ -109,7 +111,11 @@ export type ClaudeDriverEnv = |
| 109 | 111 | | ProviderEventLoggers.ProviderEventLoggers |
| 110 | 112 | | ServerConfig.ServerConfig; |
| 111 | 113 | |
| 112 | export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.Path> = { | |
| 114 | export const ClaudeDriver: ProviderDriver< | |
| 115 | ClaudeSettings, | |
| 116 | ClaudeDriverEnv, | |
| 117 | Path.Path | ServerConfig.ServerConfig | |
| 118 | > = { | |
| 113 | 119 | driverKind: DRIVER_KIND, |
| 114 | 120 | metadata: { |
| 115 | 121 | displayName: "Claude", |
| ... | ... | @@ -118,8 +124,53 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path. |
| 118 | 124 | configSchema: ClaudeSettings, |
| 119 | 125 | defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}), |
| 120 | 126 | usage: claudeUsageReader, |
| 121 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 127 | create: ({ | |
| 128 | instanceId, | |
| 129 | displayName, | |
| 130 | accentColor, | |
| 131 | environment: configuredEnvironment, | |
| 132 | enabled, | |
| 133 | config: configured, | |
| 134 | }) => | |
| 122 | 135 | Effect.gen(function* () { |
| 136 | const sharedId = configured.sharedAccountId; | |
| 137 | const managedHomes = sharedId ? yield* ManagedProviderHome.ManagedProviderHome : undefined; | |
| 138 | const shared = | |
| 139 | sharedId && managedHomes | |
| 140 | ? yield* Effect.gen(function* () { | |
| 141 | const credentials = yield* managedHomes.prepare("claudeAgent", sharedId); | |
| 142 | const paths = yield* managedHomes.paths("claudeAgent", sharedId); | |
| 143 | return { credentials, paths }; | |
| 144 | }).pipe( | |
| 145 | Effect.mapError( | |
| 146 | (cause) => | |
| 147 | new ProviderDriverError({ | |
| 148 | driver: DRIVER_KIND, | |
| 149 | instanceId, | |
| 150 | detail: cause.message, | |
| 151 | cause, | |
| 152 | }), | |
| 153 | ), | |
| 154 | ) | |
| 155 | : undefined; | |
| 156 | const config = shared ? { ...configured, homePath: shared.paths.account } : configured; | |
| 157 | const environment = shared | |
| 158 | ? [ | |
| 159 | ...configuredEnvironment.filter( | |
| 160 | ({ name }) => | |
| 161 | !["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"].includes( | |
| 162 | name, | |
| 163 | ), | |
| 164 | ), | |
| 165 | { | |
| 166 | name: "CLAUDE_CODE_OAUTH_TOKEN", | |
| 167 | value: shared.credentials.accessToken, | |
| 168 | sensitive: true, | |
| 169 | }, | |
| 170 | { name: "ANTHROPIC_API_KEY", value: "", sensitive: true }, | |
| 171 | { name: "ANTHROPIC_AUTH_TOKEN", value: "", sensitive: true }, | |
| 172 | ] | |
| 173 | : configuredEnvironment; | |
| 123 | 174 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 124 | 175 | const fileSystem = yield* FileSystem.FileSystem; |
| 125 | 176 | const path = yield* Path.Path; |
| ... | ... | @@ -156,7 +207,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path. |
| 156 | 207 | ), |
| 157 | 208 | ); |
| 158 | 209 | const continuationGroupKey = yield* makeClaudeContinuationGroupKey( |
| 159 | effectiveConfig, | |
| 210 | shared ? { ...effectiveConfig, homePath: shared.paths.shared } : effectiveConfig, | |
| 160 | 211 | processEnv, |
| 161 | 212 | ); |
| 162 | 213 | const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv); |
apps/server/src/provider/Drivers/CodexDriver.ts+94-13| ... | ... | @@ -21,16 +21,20 @@ |
| 21 | 21 | * |
| 22 | 22 | * @module provider/Drivers/CodexDriver |
| 23 | 23 | */ |
| 24 | import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; | |
| 24 | import { CodexSettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; | |
| 25 | 25 | import * as Crypto from "effect/Crypto"; |
| 26 | 26 | import * as Effect from "effect/Effect"; |
| 27 | 27 | import * as FileSystem from "effect/FileSystem"; |
| 28 | 28 | import * as Path from "effect/Path"; |
| 29 | 29 | import * as Schema from "effect/Schema"; |
| 30 | import * as Result from "effect/Result"; | |
| 30 | 31 | import { HttpClient } from "effect/http"; |
| 31 | 32 | import { ChildProcessSpawner } from "effect/process"; |
| 32 | 33 | |
| 33 | 34 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; |
| 35 | import * as ManagedProviderHome from "../ManagedProviderHome.ts"; | |
| 36 | import * as SharedAccountClient from "../SharedAccountClient.ts"; | |
| 37 | import { authenticateSharedCodex } from "../SharedCodexAuth.ts"; | |
| 34 | 38 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 35 | 39 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 36 | 40 | import * as ServerConfig from "../../config.ts"; |
| ... | ... | @@ -111,6 +115,8 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { |
| 111 | 115 | * registered driver and the runtime satisfies them once. |
| 112 | 116 | */ |
| 113 | 117 | export type CodexDriverEnv = |
| 118 | | ManagedProviderHome.ManagedProviderHome | |
| 119 | | SharedAccountClient.SharedAccountClient | |
| 114 | 120 | | CodexAdapterV2DriverEnv |
| 115 | 121 | | ProviderHost.ProviderHost |
| 116 | 122 | | ChildProcessSpawner.ChildProcessSpawner |
| ... | ... | @@ -127,7 +133,11 @@ export type CodexDriverEnv = |
| 127 | 133 | | ServerEnvironment.ServerEnvironmentIdentity |
| 128 | 134 | | CodexInstallation.CodexInstallation; |
| 129 | 135 | |
| 130 | export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Path> = { | |
| 136 | export const CodexDriver: ProviderDriver< | |
| 137 | CodexSettings, | |
| 138 | CodexDriverEnv, | |
| 139 | Path.Path | ServerConfig.ServerConfig | |
| 140 | > = { | |
| 131 | 141 | driverKind: DRIVER_KIND, |
| 132 | 142 | metadata: { |
| 133 | 143 | displayName: "Codex", |
| ... | ... | @@ -136,8 +146,33 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat |
| 136 | 146 | configSchema: CodexSettings, |
| 137 | 147 | defaultConfig: (): CodexSettings => decodeCodexSettings({}), |
| 138 | 148 | usage: codexUsageReader, |
| 139 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 149 | create: ({ instanceId, displayName, accentColor, environment, enabled, config: configured }) => | |
| 140 | 150 | Effect.gen(function* () { |
| 151 | const sharedId = configured.sharedAccountId; | |
| 152 | const managedHomes = sharedId ? yield* ManagedProviderHome.ManagedProviderHome : undefined; | |
| 153 | const sharedAccounts = sharedId ? yield* SharedAccountClient.SharedAccountClient : undefined; | |
| 154 | const managedPaths = | |
| 155 | sharedId && managedHomes | |
| 156 | ? yield* managedHomes.paths("codex", sharedId).pipe( | |
| 157 | Effect.mapError( | |
| 158 | (cause) => | |
| 159 | new ProviderDriverError({ | |
| 160 | driver: DRIVER_KIND, | |
| 161 | instanceId, | |
| 162 | detail: cause.message, | |
| 163 | cause, | |
| 164 | }), | |
| 165 | ), | |
| 166 | ) | |
| 167 | : undefined; | |
| 168 | const config = managedPaths | |
| 169 | ? { | |
| 170 | ...configured, | |
| 171 | setupMode: "existing" as const, | |
| 172 | homePath: managedPaths.shared, | |
| 173 | shadowHomePath: managedPaths.account, | |
| 174 | } | |
| 175 | : configured; | |
| 141 | 176 | if (config.setupMode === "managed") |
| 142 | 177 | return yield* makeManagedCodexProvider({ |
| 143 | 178 | instanceId, |
| ... | ... | @@ -182,6 +217,24 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat |
| 182 | 217 | binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), |
| 183 | 218 | homePath: homeLayout.effectiveHomePath ?? "", |
| 184 | 219 | } satisfies CodexSettings; |
| 220 | const resolveSharedRuntime = | |
| 221 | sharedId && managedHomes | |
| 222 | ? managedHomes.prepare("codex", sharedId).pipe( | |
| 223 | Effect.map((credentials) => ({ | |
| 224 | config: effectiveConfig, | |
| 225 | environment: processEnv, | |
| 226 | revision: credentials.accessToken, | |
| 227 | })), | |
| 228 | Effect.mapError( | |
| 229 | (cause) => | |
| 230 | new ProviderSetupError({ | |
| 231 | instanceId, | |
| 232 | operation: "shared-account", | |
| 233 | detail: cause.message, | |
| 234 | }), | |
| 235 | ), | |
| 236 | ) | |
| 237 | : undefined; | |
| 185 | 238 | const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( |
| 186 | 239 | resolveProviderMaintenanceCapabilitiesEffect( |
| 187 | 240 | makeCodexMaintenanceResolver(homeLayout.sharedHomePath), |
| ... | ... | @@ -205,7 +258,18 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat |
| 205 | 258 | enabled, |
| 206 | 259 | config, |
| 207 | 260 | }, |
| 208 | { onUsageLimits: (update) => snapshot.applyUsageLimits(update) }, | |
| 261 | { | |
| 262 | onUsageLimits: (update) => snapshot.applyUsageLimits(update), | |
| 263 | ...(resolveSharedRuntime ? { resolveRuntime: resolveSharedRuntime } : {}), | |
| 264 | ...(sharedId && sharedAccounts | |
| 265 | ? { | |
| 266 | authenticate: (client) => | |
| 267 | authenticateSharedCodex(client, (force) => | |
| 268 | sharedAccounts.access(sharedId, force), | |
| 269 | ), | |
| 270 | } | |
| 271 | : {}), | |
| 272 | }, | |
| 209 | 273 | ).pipe( |
| 210 | 274 | Effect.mapError( |
| 211 | 275 | (cause) => |
| ... | ... | @@ -225,15 +289,26 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat |
| 225 | 289 | // Kick the TTL-gated manifest refresh in the background and classify |
| 226 | 290 | // with the in-memory manifest, so a slow or hung fetch never delays the |
| 227 | 291 | // provider check. A refresh that lands mid-probe applies on the next one. |
| 228 | const checkProvider = modelCatalog.refreshInBackground.pipe( | |
| 229 | Effect.andThen( | |
| 230 | Effect.zipWith( | |
| 231 | checkCodexProviderStatus(effectiveConfig, undefined, processEnv), | |
| 232 | currentCatalog, | |
| 233 | (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), | |
| 234 | { concurrent: true }, | |
| 235 | ), | |
| 236 | ), | |
| 292 | const checkProvider = Effect.gen(function* () { | |
| 293 | const readiness = yield* (resolveSharedRuntime ?? Effect.void).pipe(Effect.result); | |
| 294 | yield* modelCatalog.refreshInBackground; | |
| 295 | return yield* Effect.zipWith( | |
| 296 | checkCodexProviderStatus(effectiveConfig, undefined, processEnv), | |
| 297 | currentCatalog, | |
| 298 | (draft, catalog) => | |
| 299 | stampIdentity({ | |
| 300 | ...applyCodexModelCatalog(draft, catalog), | |
| 301 | ...(Result.isFailure(readiness) | |
| 302 | ? { | |
| 303 | status: "error" as const, | |
| 304 | auth: { status: "unknown" as const }, | |
| 305 | message: readiness.failure.message, | |
| 306 | } | |
| 307 | : {}), | |
| 308 | }), | |
| 309 | { concurrent: true }, | |
| 310 | ); | |
| 311 | }).pipe( | |
| 237 | 312 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 238 | 313 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 239 | 314 | Effect.provideService(Path.Path, pathService), |
| ... | ... | @@ -277,6 +352,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat |
| 277 | 352 | effectiveConfig, |
| 278 | 353 | processEnv, |
| 279 | 354 | snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), |
| 355 | resolveSharedRuntime, | |
| 280 | 356 | ); |
| 281 | 357 | const snapshotForCwd = (cwd: string) => |
| 282 | 358 | !effectiveConfig.enabled |
| ... | ... | @@ -319,6 +395,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat |
| 319 | 395 | resetCreditCoordinator |
| 320 | 396 | .redeem(accountKey, (idempotencyKey) => |
| 321 | 397 | Effect.gen(function* () { |
| 398 | if (resolveSharedRuntime) yield* resolveSharedRuntime; | |
| 322 | 399 | const { client } = yield* withCodexAppServerClient({ |
| 323 | 400 | binaryPath: effectiveConfig.binaryPath, |
| 324 | 401 | homePath: effectiveConfig.homePath, |
| ... | ... | @@ -327,6 +404,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat |
| 327 | 404 | cwd: process.cwd(), |
| 328 | 405 | environment: processEnv, |
| 329 | 406 | }); |
| 407 | if (sharedId && sharedAccounts) | |
| 408 | yield* authenticateSharedCodex(client, (force) => | |
| 409 | sharedAccounts.access(sharedId, force), | |
| 410 | ); | |
| 330 | 411 | const response = yield* client.request("account/rateLimitResetCredit/consume", { |
| 331 | 412 | idempotencyKey, |
| 332 | 413 | }); |
apps/server/src/provider/Drivers/claudeUsage.ts+9-2| ... | ... | @@ -18,6 +18,8 @@ import { |
| 18 | 18 | type UsageRecord, |
| 19 | 19 | } from "@t3tools/provider-core/server/usage"; |
| 20 | 20 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 21 | import * as ServerConfig from "../../config.ts"; | |
| 22 | import { sharedProviderHomePaths } from "../ManagedProviderHome.ts"; | |
| 21 | 23 | |
| 22 | 24 | /** |
| 23 | 25 | * Parses one line of a Claude Code transcript. |
| ... | ... | @@ -103,7 +105,7 @@ export const claudeUsageFormat: TranscriptUsageFormat<void> = { |
| 103 | 105 | parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)), |
| 104 | 106 | }; |
| 105 | 107 | |
| 106 | export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> = { | |
| 108 | export const claudeUsageReader = { | |
| 107 | 109 | kind: "transcripts", |
| 108 | 110 | provider: "claude", |
| 109 | 111 | format: claudeUsageFormat, |
| ... | ... | @@ -111,6 +113,11 @@ export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> = |
| 111 | 113 | // An undecodable config has no trustworthy home to read. |
| 112 | 114 | if (config === undefined) return []; |
| 113 | 115 | const path = yield* Path.Path; |
| 116 | if (config.sharedAccountId) { | |
| 117 | const { stateDir } = yield* ServerConfig.ServerConfig; | |
| 118 | const home = sharedProviderHomePaths(path, stateDir, "claudeAgent", config.sharedAccountId); | |
| 119 | return [{ dir: path.join(home.shared, "projects") }]; | |
| 120 | } | |
| 114 | 121 | const homeDirectory = yield* HostProcess.HomeDirectory; |
| 115 | 122 | const configured = config.homePath.trim(); |
| 116 | 123 | const home = configured |
| ... | ... | @@ -118,4 +125,4 @@ export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> = |
| 118 | 125 | : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDirectory, ".claude"); |
| 119 | 126 | return [{ dir: path.resolve(home, "projects") }]; |
| 120 | 127 | }), |
| 121 | }; | |
| 128 | } satisfies ProviderUsageReader<ClaudeSettings, Path.Path | ServerConfig.ServerConfig>; |
apps/server/src/provider/Drivers/codexUsage.ts+9-2| ... | ... | @@ -21,6 +21,8 @@ import { |
| 21 | 21 | import type { UsageTokenTotals } from "@t3tools/contracts"; |
| 22 | 22 | |
| 23 | 23 | import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts"; |
| 24 | import * as ServerConfig from "../../config.ts"; | |
| 25 | import { sharedProviderHomePaths } from "../ManagedProviderHome.ts"; | |
| 24 | 26 | |
| 25 | 27 | /** |
| 26 | 28 | * Rolling state for a single Codex rollout file. |
| ... | ... | @@ -239,7 +241,7 @@ export const codexUsageFormat: TranscriptUsageFormat<CodexScanState> = { |
| 239 | 241 | sharedSessionsAcrossFiles: true, |
| 240 | 242 | }; |
| 241 | 243 | |
| 242 | export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = { | |
| 244 | export const codexUsageReader = { | |
| 243 | 245 | kind: "transcripts", |
| 244 | 246 | provider: "codex", |
| 245 | 247 | format: codexUsageFormat, |
| ... | ... | @@ -247,6 +249,11 @@ export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = { |
| 247 | 249 | // An undecodable config has no trustworthy home to read. |
| 248 | 250 | if (config === undefined) return []; |
| 249 | 251 | const path = yield* Path.Path; |
| 252 | if (config.sharedAccountId) { | |
| 253 | const { stateDir } = yield* ServerConfig.ServerConfig; | |
| 254 | const home = sharedProviderHomePaths(path, stateDir, "codex", config.sharedAccountId); | |
| 255 | return [{ dir: path.join(home.shared, "sessions") }]; | |
| 256 | } | |
| 250 | 257 | const environmentHome = environment.CODEX_HOME?.trim(); |
| 251 | 258 | const layout = yield* resolveCodexHomeLayout( |
| 252 | 259 | config.setupMode !== "managed" && |
| ... | ... | @@ -258,4 +265,4 @@ export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = { |
| 258 | 265 | ); |
| 259 | 266 | return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }]; |
| 260 | 267 | }), |
| 261 | }; | |
| 268 | } satisfies ProviderUsageReader<CodexSettings, Path.Path | ServerConfig.ServerConfig>; |
apps/server/src/provider/ManagedProviderHome.ts created+139| ... | ... | @@ -0,0 +1,139 @@ |
| 1 | import { SharedAccountError, SharedAccountId, type SharedAccountAccess } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as DateTime from "effect/DateTime"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Redacted from "effect/Redacted"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 12 | import * as ServerConfig from "../config.ts"; | |
| 13 | import * as SharedAccountClient from "./SharedAccountClient.ts"; | |
| 14 | ||
| 15 | const decodeId = Schema.decodeEffect(SharedAccountId); | |
| 16 | const encodeAuth = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 17 | const isSharedAccountError = Schema.is(SharedAccountError); | |
| 18 | ||
| 19 | const CLAUDE_SHARED_DIRECTORIES = [ | |
| 20 | "projects", | |
| 21 | "tasks", | |
| 22 | "todos", | |
| 23 | "plans", | |
| 24 | "skills", | |
| 25 | "plugins", | |
| 26 | "cache", | |
| 27 | "shell-snapshots", | |
| 28 | "session-env", | |
| 29 | ]; | |
| 30 | ||
| 31 | export function sharedProviderHomePaths( | |
| 32 | path: Path.Path, | |
| 33 | stateDir: string, | |
| 34 | driver: SharedAccountAccess["driver"], | |
| 35 | id: string, | |
| 36 | ) { | |
| 37 | const root = path.join(stateDir, "providers", driver); | |
| 38 | return { shared: path.join(root, "shared"), account: path.join(root, "accounts", id) }; | |
| 39 | } | |
| 40 | ||
| 41 | export class ManagedProviderHome extends Context.Service< | |
| 42 | ManagedProviderHome, | |
| 43 | { | |
| 44 | readonly paths: ( | |
| 45 | driver: SharedAccountAccess["driver"], | |
| 46 | id: string, | |
| 47 | ) => Effect.Effect< | |
| 48 | { | |
| 49 | readonly shared: string; | |
| 50 | readonly account: string; | |
| 51 | }, | |
| 52 | SharedAccountError | |
| 53 | >; | |
| 54 | readonly prepare: ( | |
| 55 | driver: SharedAccountAccess["driver"], | |
| 56 | id: string, | |
| 57 | ) => Effect.Effect<SharedAccountAccess, SharedAccountError>; | |
| 58 | } | |
| 59 | >()("t3/provider/ManagedProviderHome") {} | |
| 60 | ||
| 61 | const make = Effect.gen(function* () { | |
| 62 | const fs = yield* FileSystem.FileSystem; | |
| 63 | const path = yield* Path.Path; | |
| 64 | const crypto = yield* Crypto.Crypto; | |
| 65 | const { stateDir } = yield* ServerConfig.ServerConfig; | |
| 66 | const accounts = yield* SharedAccountClient.SharedAccountClient; | |
| 67 | const locks = yield* KeyedLock.make<string>(); | |
| 68 | ||
| 69 | const paths = Effect.fn("ManagedProviderHome.paths")(function* ( | |
| 70 | driver: SharedAccountAccess["driver"], | |
| 71 | inputId: string, | |
| 72 | ) { | |
| 73 | const id = yield* decodeId(inputId).pipe( | |
| 74 | Effect.mapError( | |
| 75 | (cause) => new SharedAccountError({ reason: "missing", cause: Redacted.make(cause) }), | |
| 76 | ), | |
| 77 | ); | |
| 78 | return sharedProviderHomePaths(path, stateDir, driver, id); | |
| 79 | }); | |
| 80 | ||
| 81 | const prepare = Effect.fn("ManagedProviderHome.prepare")(function* ( | |
| 82 | driver: SharedAccountAccess["driver"], | |
| 83 | id: string, | |
| 84 | ) { | |
| 85 | const credentials = yield* accounts.access(id); | |
| 86 | if (credentials.driver !== driver) return yield* new SharedAccountError({ reason: "identity" }); | |
| 87 | const home = yield* paths(driver, id); | |
| 88 | ||
| 89 | yield* Effect.gen(function* () { | |
| 90 | yield* fs.makeDirectory(home.shared, { recursive: true }); | |
| 91 | yield* fs.makeDirectory(home.account, { recursive: true }); | |
| 92 | yield* fs.chmod(home.account, 0o700); | |
| 93 | if (driver === "claudeAgent") { | |
| 94 | for (const entry of CLAUDE_SHARED_DIRECTORIES) { | |
| 95 | const target = path.join(home.shared, entry); | |
| 96 | const link = path.join(home.account, entry); | |
| 97 | yield* fs.makeDirectory(target, { recursive: true }); | |
| 98 | if (!(yield* fs.exists(link))) yield* fs.symlink(target, link); | |
| 99 | else { | |
| 100 | const existing = yield* fs.readLink(link); | |
| 101 | if (path.resolve(path.dirname(link), existing) !== target) | |
| 102 | return yield* new SharedAccountError({ reason: "storage" }); | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | if (credentials.driver === "codex") { | |
| 107 | const json = yield* encodeAuth({ | |
| 108 | auth_mode: "chatgptAuthTokens", | |
| 109 | tokens: { | |
| 110 | access_token: credentials.accessToken, | |
| 111 | id_token: credentials.idToken, | |
| 112 | account_id: credentials.accountId, | |
| 113 | refresh_token: "", | |
| 114 | }, | |
| 115 | last_refresh: DateTime.formatIso(yield* DateTime.now), | |
| 116 | }); | |
| 117 | const temporary = path.join(home.account, `${yield* crypto.randomUUIDv4()}.tmp`); | |
| 118 | yield* fs.writeFileString(temporary, json, { mode: 0o600 }); | |
| 119 | yield* fs.chmod(temporary, 0o600); | |
| 120 | yield* fs.rename(temporary, path.join(home.account, "auth.json")); | |
| 121 | } | |
| 122 | }).pipe( | |
| 123 | Effect.mapError((cause) => | |
| 124 | isSharedAccountError(cause) | |
| 125 | ? cause | |
| 126 | : new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 127 | ), | |
| 128 | ); | |
| 129 | ||
| 130 | return credentials; | |
| 131 | }); | |
| 132 | ||
| 133 | return ManagedProviderHome.of({ | |
| 134 | paths, | |
| 135 | prepare: (driver, id) => locks.withLock(`${driver}:${id}`, prepare(driver, id)), | |
| 136 | }); | |
| 137 | }); | |
| 138 | ||
| 139 | export const layer = Layer.effect(ManagedProviderHome, make); |
apps/server/src/provider/ProviderInstanceRegistry.test.ts+94-3| ... | ... | @@ -1,4 +1,7 @@ |
| 1 | 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import * as ManagedProviderHome from "./ManagedProviderHome.ts"; | |
| 3 | import * as SharedAccountClient from "./SharedAccountClient.ts"; | |
| 4 | import * as SharedAccounts from "./SharedAccounts.ts"; | |
| 2 | 5 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 3 | 6 | import * as CodexInstallation from "./CodexInstallation.ts"; |
| 4 | 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| ... | ... | @@ -68,6 +71,12 @@ import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; |
| 68 | 71 | import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; |
| 69 | 72 | import * as ProviderHostLive from "./ProviderHostLive.ts"; |
| 70 | 73 | |
| 74 | const layerSharedAccounts = ManagedProviderHome.layer.pipe( | |
| 75 | Layer.provideMerge(SharedAccountClient.layer), | |
| 76 | Layer.provideMerge(SharedAccounts.layer), | |
| 77 | Layer.provideMerge(ServerSecretStore.layer), | |
| 78 | ); | |
| 79 | ||
| 71 | 80 | const layerTestHttpClient = Layer.succeed( |
| 72 | 81 | HttpClient.HttpClient, |
| 73 | 82 | HttpClient.make((request) => |
| ... | ... | @@ -215,7 +224,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { |
| 215 | 224 | managedDirectory: "unused-managed-installation", |
| 216 | 225 | }), |
| 217 | 226 | ), |
| 218 | Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), | |
| 219 | 227 | Layer.provideMerge( |
| 220 | 228 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { |
| 221 | 229 | getEnvironmentId: Effect.succeed( |
| ... | ... | @@ -238,7 +246,10 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { |
| 238 | 246 | Layer.provideMerge(ModelManifest.layerTest), |
| 239 | 247 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 240 | 248 | ); |
| 241 | const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); | |
| 249 | const layerBase = ProviderHostLive.layer.pipe( | |
| 250 | Layer.provideMerge(layerSharedAccounts), | |
| 251 | Layer.provideMerge(layerBaseDeps), | |
| 252 | ); | |
| 242 | 253 | const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( |
| 243 | 254 | Layer.provideMerge(layerBase), |
| 244 | 255 | ); |
| ... | ... | @@ -472,6 +483,83 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { |
| 472 | 483 | }).pipe(Effect.provide(layerTest)), |
| 473 | 484 | ); |
| 474 | 485 | |
| 486 | it.live( | |
| 487 | "launches shared Claude accounts with their managed token and a common continuation home", | |
| 488 | () => | |
| 489 | Effect.gen(function* () { | |
| 490 | if (yield* HostProcess.isWindows) return; | |
| 491 | const fixtures = yield* makeTildeProviderFixtures(); | |
| 492 | const accounts = yield* SharedAccounts.SharedAccounts; | |
| 493 | const a = yield* accounts.execute({ | |
| 494 | type: "import", | |
| 495 | label: "First", | |
| 496 | credentials: { driver: "claudeAgent", accessToken: "first-managed-token" }, | |
| 497 | }); | |
| 498 | const b = yield* accounts.execute({ | |
| 499 | type: "import", | |
| 500 | label: "Second", | |
| 501 | credentials: { driver: "claudeAgent", accessToken: "second-managed-token" }, | |
| 502 | }); | |
| 503 | if (a.type !== "imported" || b.type !== "imported") | |
| 504 | return yield* Effect.die("Expected two shared accounts"); | |
| 505 | const firstId = ProviderInstanceId.make("first_shared_claude"); | |
| 506 | const secondId = ProviderInstanceId.make("second_shared_claude"); | |
| 507 | const { registry } = yield* makeProviderInstanceRegistry<ClaudeDriverEnv>({ | |
| 508 | drivers: [ClaudeDriver], | |
| 509 | configMap: { | |
| 510 | [firstId]: { | |
| 511 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 512 | enabled: true, | |
| 513 | config: makeClaudeConfig({ | |
| 514 | enabled: true, | |
| 515 | sharedAccountId: a.account.id, | |
| 516 | binaryPath: fixtures.claudeBinaryPath, | |
| 517 | }), | |
| 518 | environment: [ | |
| 519 | { | |
| 520 | name: "T3_CLAUDE_REQUIRED_TOKEN", | |
| 521 | value: "first-managed-token", | |
| 522 | sensitive: false, | |
| 523 | }, | |
| 524 | { name: "CLAUDE_CODE_OAUTH_TOKEN", value: "wrong-token", sensitive: true }, | |
| 525 | { name: "ANTHROPIC_API_KEY", value: "wrong-key", sensitive: true }, | |
| 526 | ], | |
| 527 | }, | |
| 528 | [secondId]: { | |
| 529 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 530 | enabled: true, | |
| 531 | config: makeClaudeConfig({ | |
| 532 | enabled: true, | |
| 533 | sharedAccountId: b.account.id, | |
| 534 | binaryPath: fixtures.claudeBinaryPath, | |
| 535 | }), | |
| 536 | environment: [ | |
| 537 | { | |
| 538 | name: "T3_CLAUDE_REQUIRED_TOKEN", | |
| 539 | value: "second-managed-token", | |
| 540 | sensitive: false, | |
| 541 | }, | |
| 542 | { name: "ANTHROPIC_AUTH_TOKEN", value: "wrong-key", sensitive: true }, | |
| 543 | ], | |
| 544 | }, | |
| 545 | }, | |
| 546 | }); | |
| 547 | const first = yield* registry.getInstance(firstId); | |
| 548 | const second = yield* registry.getInstance(secondId); | |
| 549 | expect(first).toBeDefined(); | |
| 550 | expect(second).toBeDefined(); | |
| 551 | expect(yield* first!.snapshot.refresh).toMatchObject({ | |
| 552 | status: "ready", | |
| 553 | auth: { status: "authenticated" }, | |
| 554 | }); | |
| 555 | expect(yield* second!.snapshot.refresh).toMatchObject({ | |
| 556 | status: "ready", | |
| 557 | auth: { status: "authenticated" }, | |
| 558 | }); | |
| 559 | expect(first!.continuationIdentity).toEqual(second!.continuationIdentity); | |
| 560 | }).pipe(Effect.provide(layerTest)), | |
| 561 | ); | |
| 562 | ||
| 475 | 563 | const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => |
| 476 | 564 | Effect.gen(function* () { |
| 477 | 565 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -657,7 +745,10 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { |
| 657 | 745 | Layer.provideMerge(ModelManifest.layerTest), |
| 658 | 746 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 659 | 747 | ); |
| 660 | const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); | |
| 748 | const layerBase = ProviderHostLive.layer.pipe( | |
| 749 | Layer.provideMerge(layerSharedAccounts), | |
| 750 | Layer.provideMerge(layerBaseDeps), | |
| 751 | ); | |
| 661 | 752 | const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( |
| 662 | 753 | Layer.provideMerge(layerBase), |
| 663 | 754 | ); |
apps/server/src/provider/SharedAccountClient.test.ts created+192| ... | ... | @@ -0,0 +1,192 @@ |
| 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { SharedAccountRequest, SharedAccountError } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Cause from "effect/Cause"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import * as TestClock from "effect/testing/TestClock"; | |
| 10 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 12 | import * as SharedAccounts from "./SharedAccounts.ts"; | |
| 13 | import * as SharedAccountClient from "./SharedAccountClient.ts"; | |
| 14 | ||
| 15 | const decodeRequest = Schema.decodeEffect(Schema.fromJsonString(SharedAccountRequest)); | |
| 16 | const isSharedAccountError = Schema.is(SharedAccountError); | |
| 17 | ||
| 18 | const jwt = (expiresAt: number) => | |
| 19 | `eyJhbGciOiJub25lIn0.${Buffer.from(JSON.stringify({ exp: expiresAt / 1000 })).toString("base64url")}.signature`; | |
| 20 | const fixture = Effect.gen(function* () { | |
| 21 | const bytes = new Map<string, Uint8Array>(); | |
| 22 | const store = ServerSecretStore.ServerSecretStore.of({ | |
| 23 | get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))), | |
| 24 | set: (key, value) => | |
| 25 | Effect.sync(() => { | |
| 26 | bytes.set(key, value); | |
| 27 | }), | |
| 28 | remove: (key) => | |
| 29 | Effect.sync(() => { | |
| 30 | bytes.delete(key); | |
| 31 | }), | |
| 32 | create: () => Effect.die("unused"), | |
| 33 | getOrCreateRandom: () => Effect.die("unused"), | |
| 34 | }); | |
| 35 | const hub = yield* SharedAccounts.SharedAccounts.pipe( | |
| 36 | Effect.provide(SharedAccounts.layer), | |
| 37 | Effect.provideService(ServerSecretStore.ServerSecretStore, store), | |
| 38 | ); | |
| 39 | const imported = yield* hub.execute({ | |
| 40 | type: "import", | |
| 41 | label: "Personal", | |
| 42 | credentials: { | |
| 43 | driver: "codex", | |
| 44 | accountId: "account-a", | |
| 45 | accessToken: jwt(1), | |
| 46 | refreshToken: "refresh-a", | |
| 47 | idToken: "id-a", | |
| 48 | expiresAt: 1, | |
| 49 | }, | |
| 50 | }); | |
| 51 | if (imported.type !== "imported") return yield* Effect.die("Expected an account"); | |
| 52 | let refreshes = 0; | |
| 53 | let offline = false; | |
| 54 | let loseCommitResponse = false; | |
| 55 | let corruptResponse = false; | |
| 56 | let expireClaim = false; | |
| 57 | const http = HttpClient.make((request) => | |
| 58 | Effect.gen(function* () { | |
| 59 | if (offline) return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); | |
| 60 | if (corruptResponse) | |
| 61 | return HttpClientResponse.fromWeb(request, new Response("review-access-secret-sentinel")); | |
| 62 | if (request.url === "https://auth.openai.com/oauth/token") { | |
| 63 | refreshes++; | |
| 64 | assert.strictEqual(request.body._tag, "Uint8Array"); | |
| 65 | if (request.body._tag !== "Uint8Array") return yield* Effect.die("Expected JSON"); | |
| 66 | assert.strictEqual( | |
| 67 | JSON.parse(new TextDecoder().decode(request.body.body)).refresh_token, | |
| 68 | "refresh-a", | |
| 69 | ); | |
| 70 | return HttpClientResponse.fromWeb( | |
| 71 | request, | |
| 72 | Response.json({ | |
| 73 | access_token: jwt(600_000), | |
| 74 | refresh_token: "refresh-b", | |
| 75 | id_token: "id-b", | |
| 76 | }), | |
| 77 | ); | |
| 78 | } | |
| 79 | assert.strictEqual(request.headers.authorization, "Bearer machine-token"); | |
| 80 | assert.strictEqual(request.url, "https://shared.example.test/api/shared/accounts"); | |
| 81 | if (request.body._tag !== "Uint8Array") return yield* Effect.die("Expected JSON"); | |
| 82 | const input = yield* decodeRequest(new TextDecoder().decode(request.body.body)).pipe( | |
| 83 | Effect.orDie, | |
| 84 | ); | |
| 85 | const response = yield* hub | |
| 86 | .execute(input) | |
| 87 | .pipe(Effect.catchTags({ SharedAccountError: (error) => Effect.succeed(error) })); | |
| 88 | if (!isSharedAccountError(response) && response.type === "refresh" && expireClaim) { | |
| 89 | expireClaim = false; | |
| 90 | return HttpClientResponse.fromWeb(request, Response.json({ ...response, expiresAt: 0 })); | |
| 91 | } | |
| 92 | if (input.type === "commit" && loseCommitResponse) { | |
| 93 | loseCommitResponse = false; | |
| 94 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })); | |
| 95 | } | |
| 96 | return HttpClientResponse.fromWeb( | |
| 97 | request, | |
| 98 | Response.json(response, { status: isSharedAccountError(response) ? 404 : 200 }), | |
| 99 | ); | |
| 100 | }), | |
| 101 | ); | |
| 102 | const layer = SharedAccountClient.layer.pipe( | |
| 103 | Layer.provide(Layer.succeed(SharedAccounts.SharedAccounts, hub)), | |
| 104 | Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, store)), | |
| 105 | Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), | |
| 106 | ); | |
| 107 | const restart = SharedAccountClient.SharedAccountClient.pipe(Effect.provide(layer)); | |
| 108 | const client = yield* restart; | |
| 109 | yield* client.configure({ url: "https://shared.example.test/", token: "machine-token" }); | |
| 110 | return { | |
| 111 | id: imported.account.id, | |
| 112 | client, | |
| 113 | restart, | |
| 114 | hub, | |
| 115 | refreshes: () => refreshes, | |
| 116 | goOffline: () => { | |
| 117 | offline = true; | |
| 118 | }, | |
| 119 | loseResponse: () => { | |
| 120 | loseCommitResponse = true; | |
| 121 | }, | |
| 122 | corruptResponse: () => { | |
| 123 | corruptResponse = true; | |
| 124 | }, | |
| 125 | expireClaim: () => { | |
| 126 | expireClaim = true; | |
| 127 | }, | |
| 128 | hasJournal: () => [...bytes.keys()].some((key) => key.startsWith("shared-account-refresh-")), | |
| 129 | }; | |
| 130 | }).pipe(Effect.provide(NodeCrypto.layer)); | |
| 131 | ||
| 132 | it.effect( | |
| 133 | "refreshes once and recovers a committed rotation after losing the response and restarting", | |
| 134 | () => | |
| 135 | Effect.gen(function* () { | |
| 136 | const f = yield* fixture; | |
| 137 | f.loseResponse(); | |
| 138 | assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "connection"); | |
| 139 | assert.isTrue(f.hasJournal()); | |
| 140 | const restarted = yield* f.restart; | |
| 141 | assert.strictEqual((yield* restarted.access(f.id)).accessToken, jwt(600_000)); | |
| 142 | assert.strictEqual(f.refreshes(), 1); | |
| 143 | assert.isFalse(f.hasJournal()); | |
| 144 | }).pipe(Effect.provide(NodeCrypto.layer)), | |
| 145 | ); | |
| 146 | ||
| 147 | it.effect( | |
| 148 | "uses cached access while disconnected, but never expires or force-refreshes from the cache", | |
| 149 | () => | |
| 150 | Effect.gen(function* () { | |
| 151 | const f = yield* fixture; | |
| 152 | const access = yield* f.client.access(f.id); | |
| 153 | f.goOffline(); | |
| 154 | assert.deepStrictEqual(yield* f.client.access(f.id), access); | |
| 155 | assert.strictEqual( | |
| 156 | (yield* f.client.access(f.id, true).pipe(Effect.flip)).reason, | |
| 157 | "connection", | |
| 158 | ); | |
| 159 | yield* TestClock.adjust("11 minutes"); | |
| 160 | assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "connection"); | |
| 161 | }).pipe(Effect.provide(NodeCrypto.layer)), | |
| 162 | ); | |
| 163 | ||
| 164 | it.effect("does not revive an account removed on the shared server from its cached token", () => | |
| 165 | Effect.gen(function* () { | |
| 166 | const f = yield* fixture; | |
| 167 | yield* f.client.access(f.id); | |
| 168 | yield* f.hub.execute({ type: "remove", id: f.id }); | |
| 169 | assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "missing"); | |
| 170 | }).pipe(Effect.provide(NodeCrypto.layer)), | |
| 171 | ); | |
| 172 | ||
| 173 | it.effect("redacts a malformed credential response from the failure trace", () => | |
| 174 | Effect.gen(function* () { | |
| 175 | const f = yield* fixture; | |
| 176 | f.corruptResponse(); | |
| 177 | const result = yield* f.client.execute({ type: "list" }).pipe(Effect.exit); | |
| 178 | if (result._tag !== "Failure") return yield* Effect.die("Expected a malformed response error"); | |
| 179 | const trace = Cause.pretty(result.cause); | |
| 180 | assert.isFalse(trace.includes("review-access")); | |
| 181 | assert.isFalse(trace.includes("machine-token")); | |
| 182 | }).pipe(Effect.provide(NodeCrypto.layer)), | |
| 183 | ); | |
| 184 | ||
| 185 | it.effect("rejects an expired refresh claim before contacting the token endpoint", () => | |
| 186 | Effect.gen(function* () { | |
| 187 | const f = yield* fixture; | |
| 188 | f.expireClaim(); | |
| 189 | assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "stale"); | |
| 190 | assert.strictEqual(f.refreshes(), 0); | |
| 191 | }).pipe(Effect.provide(NodeCrypto.layer)), | |
| 192 | ); |
apps/server/src/provider/SharedAccountClient.ts created+322| ... | ... | @@ -0,0 +1,322 @@ |
| 1 | import { | |
| 2 | SharedAccountAccess, | |
| 3 | SharedAccountId, | |
| 4 | SharedAccountError, | |
| 5 | SharedAccountResponse, | |
| 6 | SharedAccountCommit, | |
| 7 | type SharedAccountRequest, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | |
| 10 | import { decodeJwt } from "jose"; | |
| 11 | import * as Clock from "effect/Clock"; | |
| 12 | import * as Context from "effect/Context"; | |
| 13 | import * as Crypto from "effect/Crypto"; | |
| 14 | import * as Effect from "effect/Effect"; | |
| 15 | import * as Redacted from "effect/Redacted"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Option from "effect/Option"; | |
| 18 | import * as Schema from "effect/Schema"; | |
| 19 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 20 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 21 | import * as SharedAccounts from "./SharedAccounts.ts"; | |
| 22 | ||
| 23 | const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString }); | |
| 24 | const CachedAccess = Schema.Struct({ revision: Schema.Int, credentials: SharedAccountAccess }); | |
| 25 | const Tokens = Schema.Struct({ | |
| 26 | access_token: Schema.NonEmptyString, | |
| 27 | refresh_token: Schema.optionalKey(Schema.NonEmptyString), | |
| 28 | id_token: Schema.optionalKey(Schema.NonEmptyString), | |
| 29 | }); | |
| 30 | ||
| 31 | const decodeConnection = Schema.decodeEffect(Schema.fromJsonString(Connection)); | |
| 32 | const encodeConnection = Schema.encodeEffect(Schema.fromJsonString(Connection)); | |
| 33 | const decodeAccess = Schema.decodeEffect(Schema.fromJsonString(CachedAccess)); | |
| 34 | const encodeAccess = Schema.encodeEffect(Schema.fromJsonString(CachedAccess)); | |
| 35 | const decodeCommit = Schema.decodeEffect(Schema.fromJsonString(SharedAccountCommit)); | |
| 36 | const encodeCommit = Schema.encodeEffect(Schema.fromJsonString(SharedAccountCommit)); | |
| 37 | const decodeId = Schema.decodeEffect(SharedAccountId); | |
| 38 | const isSharedAccountError = Schema.is(SharedAccountError); | |
| 39 | ||
| 40 | export class SharedAccountClient extends Context.Service< | |
| 41 | SharedAccountClient, | |
| 42 | { | |
| 43 | readonly configure: (input: typeof Connection.Type) => Effect.Effect<void, SharedAccountError>; | |
| 44 | readonly disconnect: Effect.Effect<void, SharedAccountError>; | |
| 45 | readonly configured: Effect.Effect<boolean, SharedAccountError>; | |
| 46 | readonly execute: ( | |
| 47 | request: SharedAccountRequest, | |
| 48 | ) => Effect.Effect<SharedAccountResponse, SharedAccountError>; | |
| 49 | readonly access: ( | |
| 50 | id: string, | |
| 51 | forceRefresh?: boolean, | |
| 52 | ) => Effect.Effect<SharedAccountAccess, SharedAccountError>; | |
| 53 | } | |
| 54 | >()("t3/provider/SharedAccountClient") {} | |
| 55 | ||
| 56 | const make = Effect.gen(function* () { | |
| 57 | const http = yield* HttpClient.HttpClient; | |
| 58 | const crypto = yield* Crypto.Crypto; | |
| 59 | const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 60 | const local = yield* SharedAccounts.SharedAccounts; | |
| 61 | const locks = yield* KeyedLock.make<string>(); | |
| 62 | ||
| 63 | const connection = secrets.get("shared-server").pipe( | |
| 64 | Effect.flatMap((bytes) => | |
| 65 | Option.isNone(bytes) | |
| 66 | ? Effect.succeedNone | |
| 67 | : decodeConnection(new TextDecoder().decode(bytes.value)).pipe(Effect.map(Option.some)), | |
| 68 | ), | |
| 69 | Effect.mapError( | |
| 70 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 71 | ), | |
| 72 | ); | |
| 73 | ||
| 74 | const requestRemote = (target: typeof Connection.Type, input: SharedAccountRequest) => | |
| 75 | HttpClientRequest.post(`${target.url}/api/shared/accounts`).pipe( | |
| 76 | HttpClientRequest.setHeader("authorization", `Bearer ${target.token}`), | |
| 77 | HttpClientRequest.bodyJson(input), | |
| 78 | Effect.flatMap(http.execute), | |
| 79 | Effect.flatMap((response) => | |
| 80 | response.status >= 200 && response.status < 300 | |
| 81 | ? HttpClientResponse.schemaBodyJson(SharedAccountResponse)(response) | |
| 82 | : HttpClientResponse.schemaBodyJson(SharedAccountError)(response).pipe( | |
| 83 | Effect.flatMap(Effect.fail), | |
| 84 | ), | |
| 85 | ), | |
| 86 | Effect.mapError((cause) => | |
| 87 | isSharedAccountError(cause) | |
| 88 | ? cause | |
| 89 | : new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }), | |
| 90 | ), | |
| 91 | Effect.timeout("10 seconds"), | |
| 92 | Effect.catchTags({ | |
| 93 | TimeoutError: (cause) => | |
| 94 | new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }), | |
| 95 | }), | |
| 96 | ); | |
| 97 | ||
| 98 | const execute = Effect.fn("SharedAccountClient.execute")(function* (input: SharedAccountRequest) { | |
| 99 | const target = yield* connection; | |
| 100 | return yield* Option.isNone(target) ? local.execute(input) : requestRemote(target.value, input); | |
| 101 | }); | |
| 102 | ||
| 103 | const configure = Effect.fn("SharedAccountClient.configure")(function* ( | |
| 104 | input: typeof Connection.Type, | |
| 105 | ) { | |
| 106 | const url = yield* Effect.try({ | |
| 107 | try: () => { | |
| 108 | const value = new URL(input.url); | |
| 109 | if ( | |
| 110 | !["https:", "http:"].includes(value.protocol) || | |
| 111 | value.username || | |
| 112 | value.password || | |
| 113 | value.search || | |
| 114 | value.hash | |
| 115 | ) | |
| 116 | throw new Error("Expected an HTTP server URL"); | |
| 117 | return value.toString().replace(/\/$/u, ""); | |
| 118 | }, | |
| 119 | catch: (cause) => | |
| 120 | new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }), | |
| 121 | }); | |
| 122 | const target = { ...input, url }; | |
| 123 | yield* requestRemote(target, { type: "list" }); | |
| 124 | const json = yield* encodeConnection(target).pipe( | |
| 125 | Effect.mapError( | |
| 126 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 127 | ), | |
| 128 | ); | |
| 129 | yield* secrets | |
| 130 | .set("shared-server", new TextEncoder().encode(json)) | |
| 131 | .pipe( | |
| 132 | Effect.mapError( | |
| 133 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 134 | ), | |
| 135 | ); | |
| 136 | }); | |
| 137 | ||
| 138 | const access = Effect.fn("SharedAccountClient.access")(function* ( | |
| 139 | accountId: string, | |
| 140 | forceRefresh = false, | |
| 141 | ) { | |
| 142 | const id = yield* decodeId(accountId).pipe( | |
| 143 | Effect.mapError( | |
| 144 | (cause) => new SharedAccountError({ reason: "missing", cause: Redacted.make(cause) }), | |
| 145 | ), | |
| 146 | ); | |
| 147 | const target = yield* connection; | |
| 148 | const origin = Option.isSome(target) ? target.value.url : "local"; | |
| 149 | const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(origin)).pipe( | |
| 150 | Effect.map((bytes) => Buffer.from(bytes).toString("hex")), | |
| 151 | Effect.mapError( | |
| 152 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 153 | ), | |
| 154 | ); | |
| 155 | const cacheKey = `shared-account-access-${digest}-${id}`; | |
| 156 | const pendingKey = `shared-account-refresh-${digest}-${id}`; | |
| 157 | const pending = yield* secrets.get(pendingKey).pipe( | |
| 158 | Effect.flatMap((bytes) => | |
| 159 | Option.isNone(bytes) | |
| 160 | ? Effect.succeedNone | |
| 161 | : decodeCommit(new TextDecoder().decode(bytes.value)).pipe(Effect.map(Option.some)), | |
| 162 | ), | |
| 163 | Effect.mapError( | |
| 164 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 165 | ), | |
| 166 | ); | |
| 167 | if (Option.isSome(pending)) { | |
| 168 | yield* execute(pending.value).pipe( | |
| 169 | Effect.catchTags({ | |
| 170 | SharedAccountError: (error) => | |
| 171 | error.reason === "stale" ? Effect.void : Effect.fail(error), | |
| 172 | }), | |
| 173 | ); | |
| 174 | yield* secrets | |
| 175 | .remove(pendingKey) | |
| 176 | .pipe( | |
| 177 | Effect.mapError( | |
| 178 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 179 | ), | |
| 180 | ); | |
| 181 | } | |
| 182 | const cached = yield* secrets.get(cacheKey).pipe( | |
| 183 | Effect.flatMap((bytes) => | |
| 184 | Option.isNone(bytes) | |
| 185 | ? Effect.succeedNone | |
| 186 | : decodeAccess(new TextDecoder().decode(bytes.value)).pipe(Effect.map(Option.some)), | |
| 187 | ), | |
| 188 | Effect.mapError( | |
| 189 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 190 | ), | |
| 191 | ); | |
| 192 | const request: SharedAccountRequest = { | |
| 193 | type: "acquire", | |
| 194 | id, | |
| 195 | ...(forceRefresh && Option.isSome(cached) ? { refreshRevision: cached.value.revision } : {}), | |
| 196 | }; | |
| 197 | const now = yield* Clock.currentTimeMillis; | |
| 198 | let response = yield* execute(request).pipe( | |
| 199 | Effect.catchTags({ | |
| 200 | SharedAccountError: (error) => { | |
| 201 | if (error.reason !== "connection" || forceRefresh || Option.isNone(cached)) | |
| 202 | return Effect.fail(error); | |
| 203 | const value = cached.value; | |
| 204 | if (value.credentials.driver === "codex" && value.credentials.expiresAt <= now) | |
| 205 | return Effect.fail(error); | |
| 206 | return Effect.succeed<SharedAccountResponse>({ | |
| 207 | type: "access", | |
| 208 | account: { id, driver: value.credentials.driver, label: id, revision: value.revision }, | |
| 209 | credentials: value.credentials, | |
| 210 | }); | |
| 211 | }, | |
| 212 | }), | |
| 213 | ); | |
| 214 | while (response.type === "busy") { | |
| 215 | if ((yield* Clock.currentTimeMillis) - now >= 8_000) | |
| 216 | return yield* new SharedAccountError({ reason: "busy" }); | |
| 217 | yield* Effect.sleep("200 millis"); | |
| 218 | response = yield* execute(request); | |
| 219 | } | |
| 220 | if (response.type === "refresh") { | |
| 221 | const claim = response; | |
| 222 | response = yield* Effect.gen(function* () { | |
| 223 | if ((yield* Clock.currentTimeMillis) + 8_000 >= claim.expiresAt) | |
| 224 | return yield* new SharedAccountError({ reason: "stale" }); | |
| 225 | const tokens = yield* HttpClientRequest.post("https://auth.openai.com/oauth/token").pipe( | |
| 226 | HttpClientRequest.bodyJson({ | |
| 227 | client_id: "app_EMoamEEZ73f0CkXaXp7hrann", | |
| 228 | grant_type: "refresh_token", | |
| 229 | refresh_token: claim.credentials.refreshToken, | |
| 230 | }), | |
| 231 | Effect.flatMap(http.execute), | |
| 232 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 233 | Effect.flatMap(HttpClientResponse.schemaBodyJson(Tokens)), | |
| 234 | Effect.mapError( | |
| 235 | (cause) => new SharedAccountError({ reason: "refresh", cause: Redacted.make(cause) }), | |
| 236 | ), | |
| 237 | Effect.timeout("8 seconds"), | |
| 238 | Effect.catchTags({ | |
| 239 | TimeoutError: (cause) => | |
| 240 | new SharedAccountError({ reason: "refresh", cause: Redacted.make(cause) }), | |
| 241 | }), | |
| 242 | ); | |
| 243 | const expiresAt = yield* Effect.try({ | |
| 244 | try: () => { | |
| 245 | const exp = decodeJwt(tokens.access_token).exp; | |
| 246 | if (typeof exp !== "number") throw new Error("Missing token expiry"); | |
| 247 | return exp * 1000; | |
| 248 | }, | |
| 249 | catch: (cause) => | |
| 250 | new SharedAccountError({ reason: "refresh", cause: Redacted.make(cause) }), | |
| 251 | }); | |
| 252 | const commit: typeof SharedAccountCommit.Type = { | |
| 253 | type: "commit", | |
| 254 | id, | |
| 255 | revision: claim.account.revision, | |
| 256 | leaseToken: claim.leaseToken, | |
| 257 | credentials: { | |
| 258 | ...claim.credentials, | |
| 259 | accessToken: tokens.access_token, | |
| 260 | refreshToken: tokens.refresh_token ?? claim.credentials.refreshToken, | |
| 261 | idToken: tokens.id_token ?? claim.credentials.idToken, | |
| 262 | expiresAt, | |
| 263 | }, | |
| 264 | }; | |
| 265 | const journal = yield* encodeCommit(commit).pipe( | |
| 266 | Effect.mapError( | |
| 267 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 268 | ), | |
| 269 | ); | |
| 270 | yield* secrets | |
| 271 | .set(pendingKey, new TextEncoder().encode(journal)) | |
| 272 | .pipe( | |
| 273 | Effect.mapError( | |
| 274 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 275 | ), | |
| 276 | ); | |
| 277 | const committed = yield* execute(commit); | |
| 278 | yield* secrets | |
| 279 | .remove(pendingKey) | |
| 280 | .pipe( | |
| 281 | Effect.mapError( | |
| 282 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 283 | ), | |
| 284 | ); | |
| 285 | return committed; | |
| 286 | }).pipe(Effect.uninterruptible); | |
| 287 | } | |
| 288 | if (response.type !== "access") return yield* new SharedAccountError({ reason: "connection" }); | |
| 289 | const json = yield* encodeAccess({ | |
| 290 | revision: response.account.revision, | |
| 291 | credentials: response.credentials, | |
| 292 | }).pipe( | |
| 293 | Effect.mapError( | |
| 294 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 295 | ), | |
| 296 | ); | |
| 297 | yield* secrets | |
| 298 | .set(cacheKey, new TextEncoder().encode(json)) | |
| 299 | .pipe( | |
| 300 | Effect.mapError( | |
| 301 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 302 | ), | |
| 303 | ); | |
| 304 | return response.credentials; | |
| 305 | }); | |
| 306 | ||
| 307 | return SharedAccountClient.of({ | |
| 308 | execute, | |
| 309 | configure, | |
| 310 | access: (id, force) => locks.withLock(id, access(id, force)), | |
| 311 | configured: connection.pipe(Effect.map(Option.isSome)), | |
| 312 | disconnect: secrets | |
| 313 | .remove("shared-server") | |
| 314 | .pipe( | |
| 315 | Effect.mapError( | |
| 316 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 317 | ), | |
| 318 | ), | |
| 319 | }); | |
| 320 | }); | |
| 321 | ||
| 322 | export const layer = Layer.effect(SharedAccountClient, make); |
apps/server/src/provider/SharedAccounts.test.ts created+207| ... | ... | @@ -0,0 +1,207 @@ |
| 1 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 8 | import * as SharedAccounts from "./SharedAccounts.ts"; | |
| 9 | ||
| 10 | const credentials = { | |
| 11 | driver: "codex" as const, | |
| 12 | accountId: "account-a", | |
| 13 | accessToken: "access-a", | |
| 14 | refreshToken: "refresh-a", | |
| 15 | idToken: "id-a", | |
| 16 | expiresAt: 600_000, | |
| 17 | }; | |
| 18 | ||
| 19 | it.effect( | |
| 20 | "replacing credentials fences an earlier refresher and retries do not create extra accounts", | |
| 21 | () => | |
| 22 | Effect.gen(function* () { | |
| 23 | const { service, id } = yield* fixture(); | |
| 24 | const claim = yield* service.execute({ type: "acquire", id, refreshRevision: 1 }); | |
| 25 | if (claim.type !== "refresh") return yield* Effect.die("Expected a refresh claim"); | |
| 26 | const replaced = yield* service.execute({ | |
| 27 | type: "import", | |
| 28 | replaceId: id, | |
| 29 | label: "Personal", | |
| 30 | credentials: { ...credentials, refreshToken: "replacement" }, | |
| 31 | }); | |
| 32 | if (replaced.type !== "imported") return yield* Effect.die("Expected replacement"); | |
| 33 | assert.strictEqual(replaced.account.revision, 2); | |
| 34 | assert.strictEqual( | |
| 35 | (yield* service | |
| 36 | .execute({ type: "commit", id, revision: 1, leaseToken: claim.leaseToken, credentials }) | |
| 37 | .pipe(Effect.flip)).reason, | |
| 38 | "stale", | |
| 39 | ); | |
| 40 | assert.deepStrictEqual( | |
| 41 | yield* service.execute({ | |
| 42 | type: "import", | |
| 43 | replaceId: id, | |
| 44 | label: "Personal", | |
| 45 | credentials: { ...credentials, refreshToken: "replacement" }, | |
| 46 | }), | |
| 47 | replaced, | |
| 48 | ); | |
| 49 | assert.deepStrictEqual( | |
| 50 | yield* service.execute({ type: "import", label: "Personal", credentials }), | |
| 51 | replaced, | |
| 52 | ); | |
| 53 | }), | |
| 54 | ); | |
| 55 | ||
| 56 | function fixture() { | |
| 57 | const data = new Map<string, Uint8Array>(); | |
| 58 | const store = ServerSecretStore.ServerSecretStore.of({ | |
| 59 | get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | |
| 60 | set: (key, value) => | |
| 61 | Effect.sync(() => { | |
| 62 | data.set(key, value); | |
| 63 | }), | |
| 64 | remove: (key) => | |
| 65 | Effect.sync(() => { | |
| 66 | data.delete(key); | |
| 67 | }), | |
| 68 | create: () => Effect.die("unused"), | |
| 69 | getOrCreateRandom: () => Effect.die("unused"), | |
| 70 | }); | |
| 71 | const layer = SharedAccounts.layer.pipe( | |
| 72 | Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, store)), | |
| 73 | Layer.provide(NodeCrypto.layer), | |
| 74 | ); | |
| 75 | return Effect.gen(function* () { | |
| 76 | const service = yield* SharedAccounts.SharedAccounts.pipe(Effect.provide(layer)); | |
| 77 | const imported = yield* service.execute({ type: "import", label: "Personal", credentials }); | |
| 78 | if (imported.type !== "imported") return yield* Effect.die("Expected an imported account"); | |
| 79 | return { | |
| 80 | service, | |
| 81 | id: imported.account.id, | |
| 82 | restart: SharedAccounts.SharedAccounts.pipe(Effect.provide(layer)), | |
| 83 | }; | |
| 84 | }); | |
| 85 | } | |
| 86 | ||
| 87 | it.effect("lists account labels without credential or refresh-claim material", () => | |
| 88 | Effect.gen(function* () { | |
| 89 | const { service, id } = yield* fixture(); | |
| 90 | assert.deepStrictEqual(yield* service.execute({ type: "list" }), { | |
| 91 | type: "list", | |
| 92 | accounts: [{ id, label: "Personal", driver: "codex", revision: 1 }], | |
| 93 | }); | |
| 94 | assert.deepStrictEqual(yield* service.execute({ type: "acquire", id }), { | |
| 95 | type: "access", | |
| 96 | account: { id, label: "Personal", driver: "codex", revision: 1 }, | |
| 97 | credentials: { | |
| 98 | driver: "codex", | |
| 99 | accountId: "account-a", | |
| 100 | accessToken: "access-a", | |
| 101 | idToken: "id-a", | |
| 102 | expiresAt: 600_000, | |
| 103 | }, | |
| 104 | }); | |
| 105 | }), | |
| 106 | ); | |
| 107 | ||
| 108 | it.effect("only one host acquires a refresh claim and its replacement survives restart", () => | |
| 109 | Effect.gen(function* () { | |
| 110 | const { service, id, restart } = yield* fixture(); | |
| 111 | const results = yield* Effect.all( | |
| 112 | [ | |
| 113 | service.execute({ type: "acquire", id, refreshRevision: 1 }), | |
| 114 | service.execute({ type: "acquire", id, refreshRevision: 1 }), | |
| 115 | ], | |
| 116 | { concurrency: "unbounded" }, | |
| 117 | ); | |
| 118 | const claim = results.find((result) => result.type === "refresh"); | |
| 119 | if (claim?.type !== "refresh") return yield* Effect.die("Expected a refresh claim"); | |
| 120 | assert.strictEqual(results.filter((result) => result.type === "refresh").length, 1); | |
| 121 | assert.strictEqual(results.filter((result) => result.type === "busy").length, 1); | |
| 122 | ||
| 123 | const result = yield* service.execute({ | |
| 124 | type: "commit", | |
| 125 | id, | |
| 126 | revision: 1, | |
| 127 | leaseToken: claim.leaseToken, | |
| 128 | credentials: { ...credentials, accessToken: "access-b", refreshToken: "refresh-b" }, | |
| 129 | }); | |
| 130 | if (result.type !== "access") return yield* Effect.die("Expected current access"); | |
| 131 | assert.strictEqual(result.account.revision, 2); | |
| 132 | assert.strictEqual(result.credentials.accessToken, "access-b"); | |
| 133 | const next = yield* restart; | |
| 134 | assert.deepStrictEqual( | |
| 135 | yield* next.execute({ type: "acquire", id, refreshRevision: 1 }), | |
| 136 | result, | |
| 137 | ); | |
| 138 | }), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("a new host takes over an expired claim and fences out the previous host", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const { service, id, restart } = yield* fixture(); | |
| 144 | const first = yield* service.execute({ type: "acquire", id, refreshRevision: 1 }); | |
| 145 | if (first.type !== "refresh") return yield* Effect.die("Expected a refresh claim"); | |
| 146 | const next = yield* restart; | |
| 147 | assert.strictEqual( | |
| 148 | (yield* next.execute({ type: "acquire", id, refreshRevision: 1 })).type, | |
| 149 | "busy", | |
| 150 | ); | |
| 151 | yield* TestClock.adjust("31 seconds"); | |
| 152 | const second = yield* next.execute({ type: "acquire", id, refreshRevision: 1 }); | |
| 153 | if (second.type !== "refresh") return yield* Effect.die("Expected takeover"); | |
| 154 | assert.notStrictEqual(second.leaseToken, first.leaseToken); | |
| 155 | const rejected = yield* next | |
| 156 | .execute({ type: "commit", id, revision: 1, leaseToken: first.leaseToken, credentials }) | |
| 157 | .pipe(Effect.flip); | |
| 158 | assert.strictEqual(rejected.reason, "stale"); | |
| 159 | assert.strictEqual( | |
| 160 | (yield* next.execute({ | |
| 161 | type: "commit", | |
| 162 | id, | |
| 163 | revision: 1, | |
| 164 | leaseToken: second.leaseToken, | |
| 165 | credentials, | |
| 166 | })).type, | |
| 167 | "access", | |
| 168 | ); | |
| 169 | }), | |
| 170 | ); | |
| 171 | ||
| 172 | it.effect("does not change account identity when committing a refresh", () => | |
| 173 | Effect.gen(function* () { | |
| 174 | const { service, id } = yield* fixture(); | |
| 175 | const claim = yield* service.execute({ type: "acquire", id, refreshRevision: 1 }); | |
| 176 | if (claim.type !== "refresh") return yield* Effect.die("Expected a refresh claim"); | |
| 177 | const rejected = yield* service | |
| 178 | .execute({ | |
| 179 | type: "commit", | |
| 180 | id, | |
| 181 | revision: 1, | |
| 182 | leaseToken: claim.leaseToken, | |
| 183 | credentials: { ...credentials, accountId: "wrong-account" }, | |
| 184 | }) | |
| 185 | .pipe(Effect.flip); | |
| 186 | assert.strictEqual(rejected.reason, "identity"); | |
| 187 | }), | |
| 188 | ); | |
| 189 | ||
| 190 | it.effect("Claude setup tokens need no refresh claim and removed accounts stay removed", () => | |
| 191 | Effect.gen(function* () { | |
| 192 | const { service, restart } = yield* fixture(); | |
| 193 | const imported = yield* service.execute({ | |
| 194 | type: "import", | |
| 195 | label: "Claude", | |
| 196 | credentials: { driver: "claudeAgent", accessToken: "setup-token" }, | |
| 197 | }); | |
| 198 | if (imported.type !== "imported") return yield* Effect.die("Expected an account"); | |
| 199 | const id = imported.account.id; | |
| 200 | const access = yield* service.execute({ type: "acquire", id }); | |
| 201 | assert.strictEqual(access.type, "access"); | |
| 202 | yield* service.execute({ type: "remove", id }); | |
| 203 | const next = yield* restart; | |
| 204 | const missing = yield* next.execute({ type: "acquire", id }).pipe(Effect.flip); | |
| 205 | assert.strictEqual(missing.reason, "missing"); | |
| 206 | }), | |
| 207 | ); |
apps/server/src/provider/SharedAccounts.ts created+223| ... | ... | @@ -0,0 +1,223 @@ |
| 1 | import { | |
| 2 | SharedAccount, | |
| 3 | SharedAccountCredentials, | |
| 4 | SharedAccountError, | |
| 5 | type SharedAccountRequest, | |
| 6 | type SharedAccountResponse, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Clock from "effect/Clock"; | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import * as Crypto from "effect/Crypto"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Redacted from "effect/Redacted"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as Option from "effect/Option"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import * as Semaphore from "effect/Semaphore"; | |
| 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 18 | ||
| 19 | const StoredAccount = Schema.Struct({ | |
| 20 | ...SharedAccount.fields, | |
| 21 | credentials: SharedAccountCredentials, | |
| 22 | lease: Schema.NullOr(Schema.Struct({ token: Schema.String, expiresAt: Schema.Finite })), | |
| 23 | }); | |
| 24 | const StoredAccounts = Schema.fromJsonString(Schema.Array(StoredAccount)); | |
| 25 | const decodeAccounts = Schema.decodeEffect(StoredAccounts); | |
| 26 | const encodeAccounts = Schema.encodeEffect(StoredAccounts); | |
| 27 | const sameCredentials = Schema.toEquivalence(SharedAccountCredentials); | |
| 28 | ||
| 29 | export class SharedAccounts extends Context.Service< | |
| 30 | SharedAccounts, | |
| 31 | { | |
| 32 | readonly execute: ( | |
| 33 | request: SharedAccountRequest, | |
| 34 | ) => Effect.Effect<SharedAccountResponse, SharedAccountError>; | |
| 35 | } | |
| 36 | >()("t3/provider/SharedAccounts") {} | |
| 37 | ||
| 38 | const make = Effect.gen(function* () { | |
| 39 | const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 40 | const crypto = yield* Crypto.Crypto; | |
| 41 | const lock = yield* Semaphore.make(1); | |
| 42 | ||
| 43 | const read = secrets.get("shared-provider-accounts").pipe( | |
| 44 | Effect.flatMap((bytes) => | |
| 45 | Option.isNone(bytes) | |
| 46 | ? Effect.succeed<ReadonlyArray<typeof StoredAccount.Type>>([]) | |
| 47 | : decodeAccounts(new TextDecoder().decode(bytes.value)), | |
| 48 | ), | |
| 49 | Effect.mapError( | |
| 50 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 51 | ), | |
| 52 | ); | |
| 53 | ||
| 54 | const write = (accounts: ReadonlyArray<typeof StoredAccount.Type>) => | |
| 55 | encodeAccounts(accounts).pipe( | |
| 56 | Effect.flatMap((json) => | |
| 57 | secrets.set("shared-provider-accounts", new TextEncoder().encode(json)), | |
| 58 | ), | |
| 59 | Effect.mapError( | |
| 60 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 61 | ), | |
| 62 | ); | |
| 63 | ||
| 64 | const publicAccount = ({ id, label, driver, revision }: typeof StoredAccount.Type) => ({ | |
| 65 | id, | |
| 66 | label, | |
| 67 | driver, | |
| 68 | revision, | |
| 69 | }); | |
| 70 | const access = (account: typeof StoredAccount.Type): SharedAccountResponse => ({ | |
| 71 | type: "access", | |
| 72 | account: publicAccount(account), | |
| 73 | credentials: | |
| 74 | account.credentials.driver === "codex" | |
| 75 | ? { | |
| 76 | driver: "codex", | |
| 77 | accessToken: account.credentials.accessToken, | |
| 78 | idToken: account.credentials.idToken, | |
| 79 | accountId: account.credentials.accountId, | |
| 80 | expiresAt: account.credentials.expiresAt, | |
| 81 | } | |
| 82 | : account.credentials, | |
| 83 | }); | |
| 84 | ||
| 85 | const execute = Effect.fn("SharedAccounts.execute")(function* (request: SharedAccountRequest) { | |
| 86 | return yield* lock | |
| 87 | .withPermits(1)( | |
| 88 | Effect.gen(function* (): Effect.fn.Return<SharedAccountResponse, SharedAccountError> { | |
| 89 | const accounts = [...(yield* read)]; | |
| 90 | if (request.type === "list") { | |
| 91 | return { type: "list", accounts: accounts.map(publicAccount) }; | |
| 92 | } | |
| 93 | if (request.type === "import") { | |
| 94 | if (request.replaceId) { | |
| 95 | const index = accounts.findIndex((account) => account.id === request.replaceId); | |
| 96 | const previous = accounts[index]; | |
| 97 | if (!previous) return yield* new SharedAccountError({ reason: "missing" }); | |
| 98 | if ( | |
| 99 | previous.driver !== request.credentials.driver || | |
| 100 | (previous.credentials.driver === "codex" && | |
| 101 | request.credentials.driver === "codex" && | |
| 102 | previous.credentials.accountId !== request.credentials.accountId) | |
| 103 | ) | |
| 104 | return yield* new SharedAccountError({ reason: "identity" }); | |
| 105 | if (sameCredentials(previous.credentials, request.credentials)) | |
| 106 | return { type: "imported", account: publicAccount(previous) }; | |
| 107 | const updated = { | |
| 108 | ...previous, | |
| 109 | credentials: request.credentials, | |
| 110 | revision: previous.revision + 1, | |
| 111 | lease: null, | |
| 112 | }; | |
| 113 | accounts[index] = updated; | |
| 114 | yield* write(accounts); | |
| 115 | return { type: "imported", account: publicAccount(updated) }; | |
| 116 | } | |
| 117 | const existing = accounts.find(({ credentials }) => | |
| 118 | credentials.driver === "codex" && request.credentials.driver === "codex" | |
| 119 | ? credentials.accountId === request.credentials.accountId | |
| 120 | : credentials.driver === "claudeAgent" && | |
| 121 | request.credentials.driver === "claudeAgent" && | |
| 122 | credentials.accessToken === request.credentials.accessToken, | |
| 123 | ); | |
| 124 | if (existing) return { type: "imported", account: publicAccount(existing) }; | |
| 125 | ||
| 126 | const id = yield* crypto | |
| 127 | .randomUUIDv4() | |
| 128 | .pipe( | |
| 129 | Effect.mapError( | |
| 130 | (cause) => | |
| 131 | new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 132 | ), | |
| 133 | ); | |
| 134 | const account: typeof StoredAccount.Type = { | |
| 135 | id, | |
| 136 | label: request.label, | |
| 137 | driver: request.credentials.driver, | |
| 138 | revision: 1, | |
| 139 | credentials: request.credentials, | |
| 140 | lease: null, | |
| 141 | }; | |
| 142 | accounts.push(account); | |
| 143 | yield* write(accounts); | |
| 144 | return { type: "imported", account: publicAccount(account) }; | |
| 145 | } | |
| 146 | ||
| 147 | const index = accounts.findIndex((entry) => entry.id === request.id); | |
| 148 | const account = accounts[index]; | |
| 149 | if (!account) return yield* new SharedAccountError({ reason: "missing" }); | |
| 150 | ||
| 151 | if (request.type === "remove") { | |
| 152 | accounts.splice(index, 1); | |
| 153 | yield* write(accounts); | |
| 154 | return { type: "removed" }; | |
| 155 | } | |
| 156 | if (request.type === "commit") { | |
| 157 | if ( | |
| 158 | account.revision === request.revision + 1 && | |
| 159 | sameCredentials(account.credentials, request.credentials) | |
| 160 | ) | |
| 161 | return access(account); | |
| 162 | ||
| 163 | if ( | |
| 164 | account.revision !== request.revision || | |
| 165 | account.lease?.token !== request.leaseToken | |
| 166 | ) { | |
| 167 | return yield* new SharedAccountError({ reason: "stale" }); | |
| 168 | } | |
| 169 | if ( | |
| 170 | account.credentials.driver !== "codex" || | |
| 171 | account.credentials.accountId !== request.credentials.accountId | |
| 172 | ) { | |
| 173 | return yield* new SharedAccountError({ reason: "identity" }); | |
| 174 | } | |
| 175 | const updated = { | |
| 176 | ...account, | |
| 177 | credentials: request.credentials, | |
| 178 | revision: account.revision + 1, | |
| 179 | lease: null, | |
| 180 | }; | |
| 181 | accounts[index] = updated; | |
| 182 | yield* write(accounts); | |
| 183 | return access(updated); | |
| 184 | } | |
| 185 | ||
| 186 | const now = yield* Clock.currentTimeMillis; | |
| 187 | if ( | |
| 188 | account.credentials.driver === "claudeAgent" || | |
| 189 | (account.credentials.expiresAt > now + 300_000 && | |
| 190 | request.refreshRevision !== account.revision) | |
| 191 | ) { | |
| 192 | return access(account); | |
| 193 | } | |
| 194 | if (account.lease && account.lease.expiresAt > now) { | |
| 195 | return { type: "busy", retryAt: account.lease.expiresAt }; | |
| 196 | } | |
| 197 | const token = yield* crypto | |
| 198 | .randomUUIDv4() | |
| 199 | .pipe( | |
| 200 | Effect.mapError( | |
| 201 | (cause) => | |
| 202 | new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 203 | ), | |
| 204 | ); | |
| 205 | const lease = { token, expiresAt: now + 30_000 }; | |
| 206 | accounts[index] = { ...account, lease }; | |
| 207 | yield* write(accounts); | |
| 208 | return { | |
| 209 | type: "refresh", | |
| 210 | account: publicAccount(account), | |
| 211 | leaseToken: token, | |
| 212 | credentials: account.credentials, | |
| 213 | expiresAt: lease.expiresAt, | |
| 214 | }; | |
| 215 | }), | |
| 216 | ) | |
| 217 | .pipe(Effect.uninterruptible); | |
| 218 | }); | |
| 219 | ||
| 220 | return SharedAccounts.of({ execute }); | |
| 221 | }); | |
| 222 | ||
| 223 | export const layer = Layer.effect(SharedAccounts, make); |
apps/server/src/provider/SharedAccountsHttp.test.ts created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { AuthProvidersManageScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Context from "effect/Context"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | import { HttpRouter } from "effect/http"; | |
| 9 | import * as ServerConfig from "../config.ts"; | |
| 10 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | |
| 11 | import { layerSharedAccountRoute } from "../http.ts"; | |
| 12 | import * as SharedAccounts from "./SharedAccounts.ts"; | |
| 13 | ||
| 14 | const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 15 | const layer = SharedAccounts.layer.pipe( | |
| 16 | Layer.provideMerge(EnvironmentAuth.layerRuntime), | |
| 17 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-shared-http-" })), | |
| 18 | Layer.provide(NodeServices.layer), | |
| 19 | ); | |
| 20 | ||
| 21 | it.live( | |
| 22 | "requires provider management authority and never returns credential causes in HTTP errors", | |
| 23 | () => | |
| 24 | Effect.gen(function* () { | |
| 25 | const auth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 26 | const accounts = yield* SharedAccounts.SharedAccounts; | |
| 27 | const manager = yield* auth.issueSession({ scopes: [AuthProvidersManageScope] }); | |
| 28 | const reader = yield* auth.issueSession({ scopes: [AuthOrchestrationReadScope] }); | |
| 29 | const requestContext = Context.make(EnvironmentAuth.EnvironmentAuth, auth).pipe( | |
| 30 | Context.add(SharedAccounts.SharedAccounts, accounts), | |
| 31 | ); | |
| 32 | const app = yield* Effect.acquireRelease( | |
| 33 | Effect.sync(() => | |
| 34 | HttpRouter.toWebHandler(layerSharedAccountRoute, { disableLogger: true }), | |
| 35 | ), | |
| 36 | (app) => Effect.promise(() => app.dispose()), | |
| 37 | ); | |
| 38 | const post = (body: unknown, token?: string) => | |
| 39 | Effect.promise(() => | |
| 40 | app.handler( | |
| 41 | new Request("http://127.0.0.1/api/shared/accounts", { | |
| 42 | method: "POST", | |
| 43 | headers: { | |
| 44 | "content-type": "application/json", | |
| 45 | ...(token ? { authorization: `Bearer ${token}` } : {}), | |
| 46 | }, | |
| 47 | body: encode(body), | |
| 48 | }), | |
| 49 | requestContext, | |
| 50 | ), | |
| 51 | ); | |
| 52 | assert.strictEqual((yield* post({ type: "list" })).status, 401); | |
| 53 | assert.strictEqual((yield* post({ type: "list" }, reader.token)).status, 403); | |
| 54 | const response = yield* post({ type: "list" }, manager.token); | |
| 55 | assert.strictEqual(response.status, 200); | |
| 56 | assert.strictEqual(response.headers.get("cache-control"), "no-store"); | |
| 57 | const invalid = yield* post( | |
| 58 | { | |
| 59 | type: "import", | |
| 60 | label: "Personal", | |
| 61 | credentials: { driver: "codex", accessToken: "private-value-in-malformed-input" }, | |
| 62 | }, | |
| 63 | manager.token, | |
| 64 | ); | |
| 65 | assert.strictEqual(invalid.status, 503); | |
| 66 | const error = yield* Effect.promise(() => invalid.text()); | |
| 67 | assert.isFalse(error.includes("private-value")); | |
| 68 | assert.isFalse(error.includes("cause")); | |
| 69 | }).pipe(Effect.provide(layer)), | |
| 70 | ); |
apps/server/src/provider/SharedCodexAuth.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import type { SharedAccountAccess, SharedAccountError } from "@t3tools/contracts"; | |
| 2 | import type * as CodexClient from "effect-codex-app-server/client"; | |
| 3 | import * as CodexErrors from "effect-codex-app-server/errors"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | ||
| 6 | export const authenticateSharedCodex = Effect.fn("authenticateSharedCodex")(function* ( | |
| 7 | client: CodexClient.CodexAppServerClient["Service"], | |
| 8 | access: (force: boolean) => Effect.Effect<SharedAccountAccess, SharedAccountError>, | |
| 9 | ) { | |
| 10 | const read = (force: boolean) => | |
| 11 | access(force).pipe( | |
| 12 | Effect.mapError((cause) => | |
| 13 | CodexErrors.CodexAppServerRequestError.internalError(cause.message, undefined, { cause }), | |
| 14 | ), | |
| 15 | Effect.flatMap((credentials) => | |
| 16 | credentials.driver === "codex" | |
| 17 | ? Effect.succeed({ | |
| 18 | accessToken: credentials.accessToken, | |
| 19 | chatgptAccountId: credentials.accountId, | |
| 20 | chatgptPlanType: null, | |
| 21 | }) | |
| 22 | : CodexErrors.CodexAppServerRequestError.internalError( | |
| 23 | "This shared account is not a Codex account.", | |
| 24 | ), | |
| 25 | ), | |
| 26 | ); | |
| 27 | ||
| 28 | yield* client.handleServerRequest("account/chatgptAuthTokens/refresh", () => read(true)); | |
| 29 | yield* client.request("account/login/start", { | |
| 30 | type: "chatgptAuthTokens", | |
| 31 | ...(yield* read(false)), | |
| 32 | }); | |
| 33 | }); |
apps/server/src/provider/SharedProfiles.test.ts created+308| ... | ... | @@ -0,0 +1,308 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | CodexSettings, | |
| 5 | ClaudeSettings, | |
| 6 | ProviderDriverKind, | |
| 7 | ProviderInstanceId, | |
| 8 | type SharedAccount, | |
| 9 | SharedAccountError, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as FileSystem from "effect/FileSystem"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as Path from "effect/Path"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 17 | import * as ServerConfig from "../config.ts"; | |
| 18 | import * as ServerSettings from "../serverSettings.ts"; | |
| 19 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 20 | import * as ManagedProviderHome from "./ManagedProviderHome.ts"; | |
| 21 | import * as SharedAccounts from "./SharedAccounts.ts"; | |
| 22 | import * as SharedAccountClient from "./SharedAccountClient.ts"; | |
| 23 | import * as SharedProfiles from "./SharedProfiles.ts"; | |
| 24 | import { codexUsageReader } from "./Drivers/codexUsage.ts"; | |
| 25 | import { claudeUsageReader } from "./Drivers/claudeUsage.ts"; | |
| 26 | ||
| 27 | const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); | |
| 28 | const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); | |
| 29 | const isSharedAccountError = Schema.is(SharedAccountError); | |
| 30 | ||
| 31 | const fixture = Effect.gen(function* () { | |
| 32 | const fs = yield* FileSystem.FileSystem; | |
| 33 | const path = yield* Path.Path; | |
| 34 | const source = yield* fs.makeTempDirectoryScoped(); | |
| 35 | const tokenFile = path.join(source, "claude-token"); | |
| 36 | yield* fs.writeFileString(tokenFile, "sk-ant-oat01-test-setup-token\n", { mode: 0o600 }); | |
| 37 | const accessToken = `eyJhbGciOiJub25lIn0.${Buffer.from(JSON.stringify({ exp: 3_000_000_000 })).toString("base64url")}.signature`; | |
| 38 | yield* fs.writeFileString( | |
| 39 | path.join(source, "auth.json"), | |
| 40 | JSON.stringify({ | |
| 41 | tokens: { | |
| 42 | access_token: accessToken, | |
| 43 | refresh_token: "original-refresh", | |
| 44 | id_token: "original-id", | |
| 45 | account_id: "account-a", | |
| 46 | }, | |
| 47 | }), | |
| 48 | ); | |
| 49 | const settings = ServerSettings.layerTest({ | |
| 50 | providerInstances: { | |
| 51 | [ProviderInstanceId.make("personal")]: { | |
| 52 | driver: ProviderDriverKind.make("codex"), | |
| 53 | displayName: "Personal", | |
| 54 | config: { homePath: source }, | |
| 55 | }, | |
| 56 | [ProviderInstanceId.make("claude")]: { | |
| 57 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 58 | displayName: "Claude", | |
| 59 | config: {}, | |
| 60 | }, | |
| 61 | }, | |
| 62 | }); | |
| 63 | const config = ServerConfig.layerTest(process.cwd(), { prefix: "t3-shared-profiles-" }); | |
| 64 | let remoteAccounts: ReadonlyArray<SharedAccount> = []; | |
| 65 | const layer = SharedProfiles.layer.pipe( | |
| 66 | Layer.provideMerge(ManagedProviderHome.layer), | |
| 67 | Layer.provideMerge(SharedAccountClient.layer), | |
| 68 | Layer.provideMerge(SharedAccounts.layer), | |
| 69 | Layer.provideMerge(settings), | |
| 70 | Layer.provideMerge(ServerSecretStore.layer), | |
| 71 | Layer.provideMerge(config), | |
| 72 | Layer.provide( | |
| 73 | Layer.succeed( | |
| 74 | HttpClient.HttpClient, | |
| 75 | HttpClient.make((request) => | |
| 76 | Effect.succeed( | |
| 77 | HttpClientResponse.fromWeb( | |
| 78 | request, | |
| 79 | Response.json({ type: "list", accounts: remoteAccounts }), | |
| 80 | ), | |
| 81 | ), | |
| 82 | ), | |
| 83 | ), | |
| 84 | ), | |
| 85 | ); | |
| 86 | return { | |
| 87 | layer, | |
| 88 | source, | |
| 89 | tokenFile, | |
| 90 | accessToken, | |
| 91 | remoteAccounts: (value: ReadonlyArray<SharedAccount>) => { | |
| 92 | remoteAccounts = value; | |
| 93 | }, | |
| 94 | }; | |
| 95 | }); | |
| 96 | ||
| 97 | it.layer(NodeServices.layer)("managed shared profiles", (it) => { | |
| 98 | it.effect( | |
| 99 | "imports into private T3-owned homes without copying refresh credentials into native CLI storage", | |
| 100 | () => | |
| 101 | Effect.gen(function* () { | |
| 102 | const f = yield* fixture; | |
| 103 | yield* Effect.gen(function* () { | |
| 104 | const profiles = yield* SharedProfiles.SharedProfiles; | |
| 105 | const homes = yield* ManagedProviderHome.ManagedProviderHome; | |
| 106 | const settings = yield* ServerSettings.ServerSettingsService; | |
| 107 | const fs = yield* FileSystem.FileSystem; | |
| 108 | const path = yield* Path.Path; | |
| 109 | const result = yield* profiles.execute({ | |
| 110 | type: "import", | |
| 111 | instanceId: ProviderInstanceId.make("personal"), | |
| 112 | }); | |
| 113 | const account = result.accounts[0]; | |
| 114 | assert.isDefined(account); | |
| 115 | if (!account) return yield* Effect.die("Expected an account"); | |
| 116 | const instance = (yield* settings.getSettings).providerInstances[ | |
| 117 | ProviderInstanceId.make(`shared-${account.id}`) | |
| 118 | ]; | |
| 119 | assert.strictEqual(instance?.displayName, "Personal"); | |
| 120 | yield* homes.prepare("codex", account.id); | |
| 121 | const paths = yield* homes.paths("codex", account.id); | |
| 122 | const directories = yield* codexUsageReader.directories({ | |
| 123 | instanceId: ProviderInstanceId.make("managed"), | |
| 124 | config: yield* decodeCodexSettings({ | |
| 125 | sharedAccountId: account.id, | |
| 126 | homePath: f.source, | |
| 127 | }), | |
| 128 | environment: {}, | |
| 129 | configured: true, | |
| 130 | }); | |
| 131 | assert.deepStrictEqual(directories, [{ dir: path.join(paths.shared, "sessions") }]); | |
| 132 | const config = yield* ServerConfig.ServerConfig; | |
| 133 | assert.isTrue(paths.account.startsWith(config.stateDir)); | |
| 134 | const json = yield* fs.readFileString(path.join(paths.account, "auth.json")); | |
| 135 | assert.isFalse(json.includes("original-refresh")); | |
| 136 | assert.deepInclude(JSON.parse(json), { auth_mode: "chatgptAuthTokens" }); | |
| 137 | assert.isTrue( | |
| 138 | (yield* fs.readFileString(path.join(f.source, "auth.json"))).includes( | |
| 139 | "original-refresh", | |
| 140 | ), | |
| 141 | ); | |
| 142 | assert.strictEqual( | |
| 143 | Number((yield* fs.stat(path.join(paths.account, "auth.json"))).mode) & 0o777, | |
| 144 | 0o600, | |
| 145 | ); | |
| 146 | yield* profiles.execute({ type: "remove", id: account.id }); | |
| 147 | assert.strictEqual( | |
| 148 | (yield* settings.getSettings).providerInstances[ | |
| 149 | ProviderInstanceId.make(`shared-${account.id}`) | |
| 150 | ]?.enabled, | |
| 151 | false, | |
| 152 | ); | |
| 153 | assert.strictEqual( | |
| 154 | (yield* homes.prepare("codex", account.id).pipe(Effect.flip)).reason, | |
| 155 | "missing", | |
| 156 | ); | |
| 157 | }).pipe(Effect.provide(f.layer)); | |
| 158 | }), | |
| 159 | ); | |
| 160 | ||
| 161 | it.effect( | |
| 162 | "shares Claude session directories between accounts while keeping account configuration separate", | |
| 163 | () => | |
| 164 | Effect.gen(function* () { | |
| 165 | const f = yield* fixture; | |
| 166 | yield* Effect.gen(function* () { | |
| 167 | const profiles = yield* SharedProfiles.SharedProfiles; | |
| 168 | const homes = yield* ManagedProviderHome.ManagedProviderHome; | |
| 169 | const hub = yield* SharedAccounts.SharedAccounts; | |
| 170 | const settings = yield* ServerSettings.ServerSettingsService; | |
| 171 | const fs = yield* FileSystem.FileSystem; | |
| 172 | const path = yield* Path.Path; | |
| 173 | const result = yield* profiles.execute({ | |
| 174 | type: "import", | |
| 175 | instanceId: ProviderInstanceId.make("claude"), | |
| 176 | tokenFile: f.tokenFile, | |
| 177 | }); | |
| 178 | const first = result.accounts[0]; | |
| 179 | const second = yield* hub.execute({ | |
| 180 | type: "import", | |
| 181 | label: "Second", | |
| 182 | credentials: { driver: "claudeAgent", accessToken: "second-setup-token" }, | |
| 183 | }); | |
| 184 | if (!first || second.type !== "imported") | |
| 185 | return yield* Effect.die("Expected two accounts"); | |
| 186 | yield* Effect.all( | |
| 187 | [ | |
| 188 | homes.prepare("claudeAgent", first.id), | |
| 189 | homes.prepare("claudeAgent", first.id), | |
| 190 | homes.prepare("claudeAgent", second.account.id), | |
| 191 | ], | |
| 192 | { concurrency: "unbounded" }, | |
| 193 | ); | |
| 194 | const a = yield* homes.paths("claudeAgent", first.id); | |
| 195 | const b = yield* homes.paths("claudeAgent", second.account.id); | |
| 196 | yield* fs.writeFileString( | |
| 197 | path.join(a.account, "projects", "session.jsonl"), | |
| 198 | "shared-session", | |
| 199 | ); | |
| 200 | assert.strictEqual( | |
| 201 | yield* fs.readFileString(path.join(b.account, "projects", "session.jsonl")), | |
| 202 | "shared-session", | |
| 203 | ); | |
| 204 | yield* fs.writeFileString( | |
| 205 | path.join(a.account, ".claude.json"), | |
| 206 | "private-account-settings", | |
| 207 | ); | |
| 208 | assert.isFalse(yield* fs.exists(path.join(b.account, ".claude.json"))); | |
| 209 | assert.isFalse( | |
| 210 | JSON.stringify(yield* settings.getSettings).includes("sk-ant-oat01-test-setup-token"), | |
| 211 | ); | |
| 212 | const directories = yield* claudeUsageReader.directories({ | |
| 213 | instanceId: ProviderInstanceId.make("managed"), | |
| 214 | config: yield* decodeClaudeSettings({ | |
| 215 | sharedAccountId: first.id, | |
| 216 | homePath: f.source, | |
| 217 | }), | |
| 218 | environment: {}, | |
| 219 | configured: true, | |
| 220 | }); | |
| 221 | assert.strictEqual( | |
| 222 | yield* fs.readFileString(path.join(directories[0]!.dir, "session.jsonl")), | |
| 223 | "shared-session", | |
| 224 | ); | |
| 225 | yield* fs.writeFileString(f.tokenFile, "sk-ant-oat01-renewed-token"); | |
| 226 | const renewed = yield* profiles.execute({ | |
| 227 | type: "import", | |
| 228 | instanceId: ProviderInstanceId.make("claude"), | |
| 229 | tokenFile: f.tokenFile, | |
| 230 | replaceId: first.id, | |
| 231 | }); | |
| 232 | assert.strictEqual( | |
| 233 | renewed.accounts.find((account) => account.id === first.id)?.revision, | |
| 234 | 2, | |
| 235 | ); | |
| 236 | const instance = (yield* settings.getSettings).providerInstances[ | |
| 237 | ProviderInstanceId.make(`shared-${first.id}`) | |
| 238 | ]; | |
| 239 | assert.strictEqual( | |
| 240 | (yield* decodeClaudeSettings(instance?.config)).sharedAccountRevision, | |
| 241 | 2, | |
| 242 | ); | |
| 243 | assert.strictEqual( | |
| 244 | (yield* homes.prepare("claudeAgent", first.id)).accessToken, | |
| 245 | "sk-ant-oat01-renewed-token", | |
| 246 | ); | |
| 247 | }).pipe(Effect.provide(f.layer)); | |
| 248 | }), | |
| 249 | ); | |
| 250 | ||
| 251 | it.effect( | |
| 252 | "disconnect disables managed instances and explicit reconnect restores them without overriding later manual disables", | |
| 253 | () => | |
| 254 | Effect.gen(function* () { | |
| 255 | const f = yield* fixture; | |
| 256 | yield* Effect.gen(function* () { | |
| 257 | const profiles = yield* SharedProfiles.SharedProfiles; | |
| 258 | const settings = yield* ServerSettings.ServerSettingsService; | |
| 259 | const result = yield* profiles.execute({ | |
| 260 | type: "import", | |
| 261 | instanceId: ProviderInstanceId.make("personal"), | |
| 262 | }); | |
| 263 | const account = result.accounts[0]; | |
| 264 | if (!account) return yield* Effect.die("Expected an account"); | |
| 265 | f.remoteAccounts(result.accounts); | |
| 266 | yield* profiles.execute({ type: "disconnect" }); | |
| 267 | const current = yield* settings.getSettings; | |
| 268 | assert.strictEqual( | |
| 269 | current.providerInstances[ProviderInstanceId.make(`shared-${account.id}`)]?.enabled, | |
| 270 | false, | |
| 271 | ); | |
| 272 | assert.notStrictEqual( | |
| 273 | current.providerInstances[ProviderInstanceId.make("personal")]?.enabled, | |
| 274 | false, | |
| 275 | ); | |
| 276 | const id = ProviderInstanceId.make(`shared-${account.id}`); | |
| 277 | yield* profiles.execute({ | |
| 278 | type: "connect", | |
| 279 | url: "https://shared.example.test", | |
| 280 | tokenFile: f.tokenFile, | |
| 281 | }); | |
| 282 | assert.strictEqual((yield* settings.getSettings).providerInstances[id]?.enabled, true); | |
| 283 | const instance = (yield* settings.getSettings).providerInstances[id]; | |
| 284 | if (!instance) return yield* Effect.die("Expected a managed instance"); | |
| 285 | yield* settings.updateProviderInstance({ | |
| 286 | operation: "upsert", | |
| 287 | instanceId: id, | |
| 288 | instance: { ...instance, enabled: false }, | |
| 289 | }); | |
| 290 | yield* profiles.execute({ type: "sync" }); | |
| 291 | assert.strictEqual((yield* settings.getSettings).providerInstances[id]?.enabled, false); | |
| 292 | assert.strictEqual( | |
| 293 | (yield* profiles | |
| 294 | .execute({ type: "import", instanceId: ProviderInstanceId.make("missing") }) | |
| 295 | .pipe(Effect.flip)).reason, | |
| 296 | "setup", | |
| 297 | ); | |
| 298 | assert.isTrue( | |
| 299 | isSharedAccountError( | |
| 300 | yield* (yield* ManagedProviderHome.ManagedProviderHome) | |
| 301 | .prepare("codex", "../outside") | |
| 302 | .pipe(Effect.flip), | |
| 303 | ), | |
| 304 | ); | |
| 305 | }).pipe(Effect.provide(f.layer)); | |
| 306 | }), | |
| 307 | ); | |
| 308 | }); |
apps/server/src/provider/SharedProfiles.ts created+237| ... | ... | @@ -0,0 +1,237 @@ |
| 1 | import { | |
| 2 | ClaudeSettings, | |
| 3 | CodexSettings, | |
| 4 | ProviderDriverKind, | |
| 5 | ProviderInstanceId, | |
| 6 | SharedAccountError, | |
| 7 | SharedAccountId, | |
| 8 | type SharedProfilesRequest, | |
| 9 | type SharedProfilesStatus, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { decodeJwt } from "jose"; | |
| 12 | import * as Context from "effect/Context"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Redacted from "effect/Redacted"; | |
| 15 | import * as FileSystem from "effect/FileSystem"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Path from "effect/Path"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as Schema from "effect/Schema"; | |
| 20 | import * as Semaphore from "effect/Semaphore"; | |
| 21 | import * as ServerSettings from "../serverSettings.ts"; | |
| 22 | import { resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; | |
| 23 | import * as SharedAccountClient from "./SharedAccountClient.ts"; | |
| 24 | ||
| 25 | const SharedConfig = Schema.Struct({ | |
| 26 | sharedAccountId: SharedAccountId, | |
| 27 | sharedAccountRevision: Schema.optionalKey(Schema.Int), | |
| 28 | }); | |
| 29 | const CodexAuthFile = Schema.fromJsonString( | |
| 30 | Schema.Struct({ | |
| 31 | tokens: Schema.Struct({ | |
| 32 | access_token: Schema.NonEmptyString, | |
| 33 | refresh_token: Schema.NonEmptyString, | |
| 34 | id_token: Schema.NonEmptyString, | |
| 35 | account_id: Schema.NonEmptyString, | |
| 36 | }), | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | const isSharedConfig = Schema.is(SharedConfig); | |
| 41 | const decodeSharedConfig = Schema.decodeUnknownOption(SharedConfig); | |
| 42 | const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); | |
| 43 | const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings); | |
| 44 | const decodeCodexAuth = Schema.decodeEffect(CodexAuthFile); | |
| 45 | const isSharedAccountError = Schema.is(SharedAccountError); | |
| 46 | const readConfig = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)); | |
| 47 | ||
| 48 | export class SharedProfiles extends Context.Service< | |
| 49 | SharedProfiles, | |
| 50 | { | |
| 51 | readonly execute: ( | |
| 52 | request: SharedProfilesRequest, | |
| 53 | ) => Effect.Effect<SharedProfilesStatus, SharedAccountError>; | |
| 54 | } | |
| 55 | >()("t3/provider/SharedProfiles") {} | |
| 56 | ||
| 57 | const make = Effect.gen(function* () { | |
| 58 | const accounts = yield* SharedAccountClient.SharedAccountClient; | |
| 59 | const settings = yield* ServerSettings.ServerSettingsService; | |
| 60 | const fs = yield* FileSystem.FileSystem; | |
| 61 | const path = yield* Path.Path; | |
| 62 | const lock = yield* Semaphore.make(1); | |
| 63 | ||
| 64 | const sync = (enable = false) => | |
| 65 | Effect.gen(function* () { | |
| 66 | const response = yield* accounts.execute({ type: "list" }); | |
| 67 | if (response.type !== "list") return yield* new SharedAccountError({ reason: "connection" }); | |
| 68 | const current = yield* settings.getSettings.pipe( | |
| 69 | Effect.mapError( | |
| 70 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 71 | ), | |
| 72 | ); | |
| 73 | for (const account of response.accounts) { | |
| 74 | const instanceId = ProviderInstanceId.make(`shared-${account.id}`); | |
| 75 | const previous = current.providerInstances[instanceId]; | |
| 76 | const config = | |
| 77 | account.driver === "codex" | |
| 78 | ? { | |
| 79 | sharedAccountId: account.id, | |
| 80 | setupMode: "existing", | |
| 81 | } | |
| 82 | : { sharedAccountId: account.id, sharedAccountRevision: account.revision }; | |
| 83 | const previousShared = decodeSharedConfig(previous?.config); | |
| 84 | if ( | |
| 85 | previous?.displayName === account.label && | |
| 86 | previousShared._tag === "Some" && | |
| 87 | (!enable || previous.enabled !== false) && | |
| 88 | (account.driver !== "claudeAgent" || | |
| 89 | previousShared.value.sharedAccountRevision === account.revision) | |
| 90 | ) | |
| 91 | continue; | |
| 92 | const previousConfig = Option.getOrElse(readConfig(previous?.config), () => ({})); | |
| 93 | yield* settings | |
| 94 | .updateProviderInstance({ | |
| 95 | operation: "upsert", | |
| 96 | instanceId, | |
| 97 | instance: { | |
| 98 | ...previous, | |
| 99 | ...(enable ? { enabled: true } : {}), | |
| 100 | driver: ProviderDriverKind.make(account.driver), | |
| 101 | displayName: account.label, | |
| 102 | config: { | |
| 103 | ...previousConfig, | |
| 104 | ...config, | |
| 105 | }, | |
| 106 | }, | |
| 107 | }) | |
| 108 | .pipe( | |
| 109 | Effect.mapError( | |
| 110 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 111 | ), | |
| 112 | ); | |
| 113 | } | |
| 114 | const ids = new Set(response.accounts.map((account) => account.id)); | |
| 115 | for (const [key, instance] of Object.entries(current.providerInstances)) { | |
| 116 | const shared = decodeSharedConfig(instance.config); | |
| 117 | if ( | |
| 118 | shared._tag === "Some" && | |
| 119 | !ids.has(shared.value.sharedAccountId) && | |
| 120 | instance.enabled !== false | |
| 121 | ) { | |
| 122 | yield* settings | |
| 123 | .updateProviderInstance({ | |
| 124 | operation: "upsert", | |
| 125 | instanceId: ProviderInstanceId.make(key), | |
| 126 | instance: { ...instance, enabled: false }, | |
| 127 | }) | |
| 128 | .pipe( | |
| 129 | Effect.mapError( | |
| 130 | (cause) => | |
| 131 | new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 132 | ), | |
| 133 | ); | |
| 134 | } | |
| 135 | } | |
| 136 | return { connected: yield* accounts.configured, accounts: response.accounts }; | |
| 137 | }); | |
| 138 | ||
| 139 | const execute = Effect.fn("SharedProfiles.execute")(function* (request: SharedProfilesRequest) { | |
| 140 | return yield* lock.withPermit( | |
| 141 | Effect.gen(function* () { | |
| 142 | if (request.type === "disconnect") { | |
| 143 | const current = yield* settings.getSettings.pipe( | |
| 144 | Effect.mapError( | |
| 145 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 146 | ), | |
| 147 | ); | |
| 148 | for (const [id, instance] of Object.entries(current.providerInstances)) { | |
| 149 | if (!isSharedConfig(instance.config) || instance.enabled === false) continue; | |
| 150 | yield* settings | |
| 151 | .updateProviderInstance({ | |
| 152 | operation: "upsert", | |
| 153 | instanceId: ProviderInstanceId.make(id), | |
| 154 | instance: { ...instance, enabled: false }, | |
| 155 | }) | |
| 156 | .pipe( | |
| 157 | Effect.mapError( | |
| 158 | (cause) => | |
| 159 | new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 160 | ), | |
| 161 | ); | |
| 162 | } | |
| 163 | yield* accounts.disconnect; | |
| 164 | return { connected: false, accounts: [] }; | |
| 165 | } | |
| 166 | if (request.type === "connect") { | |
| 167 | const token = yield* fs.readFileString(request.tokenFile).pipe( | |
| 168 | Effect.map((value) => value.trim()), | |
| 169 | Effect.mapError( | |
| 170 | (cause) => new SharedAccountError({ reason: "setup", cause: Redacted.make(cause) }), | |
| 171 | ), | |
| 172 | ); | |
| 173 | yield* accounts.configure({ url: request.url, token }); | |
| 174 | } | |
| 175 | if (request.type === "remove") yield* accounts.execute(request); | |
| 176 | if (request.type === "import") { | |
| 177 | const current = yield* settings.getSettings.pipe( | |
| 178 | Effect.mapError( | |
| 179 | (cause) => new SharedAccountError({ reason: "storage", cause: Redacted.make(cause) }), | |
| 180 | ), | |
| 181 | ); | |
| 182 | const source = current.providerInstances[request.instanceId]; | |
| 183 | if (!source || isSharedConfig(source.config)) | |
| 184 | return yield* new SharedAccountError({ reason: "setup" }); | |
| 185 | const credentials = yield* Effect.gen(function* () { | |
| 186 | if (source.driver === "claudeAgent") { | |
| 187 | if (!request.tokenFile) return yield* new SharedAccountError({ reason: "setup" }); | |
| 188 | yield* decodeClaudeSettings(source.config ?? {}); | |
| 189 | const accessToken = (yield* fs.readFileString(request.tokenFile)).trim(); | |
| 190 | if (!accessToken.startsWith("sk-ant-oat")) | |
| 191 | return yield* new SharedAccountError({ reason: "setup" }); | |
| 192 | return { driver: "claudeAgent" as const, accessToken }; | |
| 193 | } | |
| 194 | if (source.driver !== "codex") | |
| 195 | return yield* new SharedAccountError({ reason: "setup" }); | |
| 196 | const config = yield* decodeCodexSettings(source.config ?? {}); | |
| 197 | if (config.setupMode === "managed") | |
| 198 | return yield* new SharedAccountError({ reason: "setup" }); | |
| 199 | const home = yield* resolveCodexHomeLayout(config).pipe( | |
| 200 | Effect.provideService(Path.Path, path), | |
| 201 | ); | |
| 202 | const auth = yield* fs | |
| 203 | .readFileString(path.join(home.effectiveHomePath ?? home.sharedHomePath, "auth.json")) | |
| 204 | .pipe(Effect.flatMap(decodeCodexAuth)); | |
| 205 | const exp = yield* Effect.try(() => decodeJwt(auth.tokens.access_token).exp); | |
| 206 | if (typeof exp !== "number") return yield* new SharedAccountError({ reason: "setup" }); | |
| 207 | return { | |
| 208 | driver: "codex" as const, | |
| 209 | accessToken: auth.tokens.access_token, | |
| 210 | refreshToken: auth.tokens.refresh_token, | |
| 211 | idToken: auth.tokens.id_token, | |
| 212 | accountId: auth.tokens.account_id, | |
| 213 | expiresAt: exp * 1000, | |
| 214 | }; | |
| 215 | }).pipe( | |
| 216 | Effect.mapError((cause) => | |
| 217 | isSharedAccountError(cause) | |
| 218 | ? cause | |
| 219 | : new SharedAccountError({ reason: "setup", cause: Redacted.make(cause) }), | |
| 220 | ), | |
| 221 | ); | |
| 222 | yield* accounts.execute({ | |
| 223 | type: "import", | |
| 224 | label: source.displayName ?? request.instanceId, | |
| 225 | credentials, | |
| 226 | ...(request.replaceId ? { replaceId: request.replaceId } : {}), | |
| 227 | }); | |
| 228 | } | |
| 229 | return yield* sync(request.type === "connect"); | |
| 230 | }), | |
| 231 | ); | |
| 232 | }); | |
| 233 | ||
| 234 | return SharedProfiles.of({ execute }); | |
| 235 | }); | |
| 236 | ||
| 237 | export const layer = Layer.effect(SharedProfiles, make); |
apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs+10| ... | ... | @@ -1,6 +1,16 @@ |
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | import * as NodeFS from "node:fs"; |
| 3 | 3 | import * as NodeReadline from "node:readline"; |
| 4 | const requiredToken = process.env.T3_CLAUDE_REQUIRED_TOKEN; | |
| 5 | if ( | |
| 6 | requiredToken && | |
| 7 | (process.env.CLAUDE_CODE_OAUTH_TOKEN !== requiredToken || | |
| 8 | process.env.ANTHROPIC_API_KEY !== "" || | |
| 9 | process.env.ANTHROPIC_AUTH_TOKEN !== "") | |
| 10 | ) { | |
| 11 | process.stderr.write("claude: managed credentials did not reach the subprocess\n"); | |
| 12 | process.exit(1); | |
| 13 | } | |
| 4 | 14 | // Opt-in: act like a `claude` wrapper that only runs inside one directory. |
| 5 | 15 | const requiredCwd = process.env.T3_CLAUDE_REQUIRED_CWD; |
| 6 | 16 | if (requiredCwd && process.cwd() !== NodeFS.realpathSync(requiredCwd)) { |
apps/server/src/server.ts+28| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import * as SharedAccounts from "./provider/SharedAccounts.ts"; | |
| 2 | import * as SharedAccountClient from "./provider/SharedAccountClient.ts"; | |
| 3 | import * as ManagedProviderHome from "./provider/ManagedProviderHome.ts"; | |
| 4 | import * as SharedProfiles from "./provider/SharedProfiles.ts"; | |
| 1 | 5 | import * as UiSettings from "./uiSettings.ts"; |
| 2 | 6 | import * as ScriptProgress from "./progress/ScriptProgress.ts"; |
| 3 | 7 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| ... | ... | @@ -549,6 +553,24 @@ const layerProviderInstallationRefresh = Layer.effectDiscard( |
| 549 | 553 | ); |
| 550 | 554 | |
| 551 | 555 | const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 556 | Layer.effectDiscard( | |
| 557 | Effect.gen(function* () { | |
| 558 | const profiles = yield* SharedProfiles.SharedProfiles; | |
| 559 | yield* ServerActivation.forkParked( | |
| 560 | Effect.forever( | |
| 561 | profiles.execute({ type: "sync" }).pipe( | |
| 562 | Effect.catchTags({ | |
| 563 | SharedAccountError: (error) => | |
| 564 | Effect.logWarning("Shared account sync could not finish", { | |
| 565 | reason: error.reason, | |
| 566 | }), | |
| 567 | }), | |
| 568 | Effect.andThen(Effect.sleep("1 minute")), | |
| 569 | ), | |
| 570 | ), | |
| 571 | ); | |
| 572 | }), | |
| 573 | ), | |
| 552 | 574 | AgentAwarenessRelay.layer, |
| 553 | 575 | // Asks T3 Connect to deliver webhooks it held while this environment was offline. |
| 554 | 576 | HeldHooksWaker.layer, |
| ... | ... | @@ -626,6 +648,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 626 | 648 | ); |
| 627 | 649 | |
| 628 | 650 | const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( |
| 651 | Layer.provideMerge(SharedProfiles.layer), | |
| 652 | Layer.provideMerge(ManagedProviderHome.layer), | |
| 653 | Layer.provideMerge(SharedAccountClient.layer), | |
| 654 | Layer.provideMerge(SharedAccounts.layer), | |
| 629 | 655 | Layer.provideMerge(layerPtyAdapter), |
| 630 | 656 | // Search, prepare, status inspection, and turn launch share one registry |
| 631 | 657 | // cache so every client and provider instance sees the same prepared agents. |
| ... | ... | @@ -719,6 +745,8 @@ const layerMakeRoutes = Layer.mergeAll( |
| 719 | 745 | ServerHttp.layerOtlpTracesProxyRoute, |
| 720 | 746 | ServerHttp.layerAssetRoute, |
| 721 | 747 | ServerHttp.layerAttachmentUploadRoute, |
| 748 | ServerHttp.layerSharedAccountRoute, | |
| 749 | ServerHttp.layerSharedProfilesRoute, | |
| 722 | 750 | DeviceHubProxy.layer, |
| 723 | 751 | ServerBrowserStream.routeLayer, |
| 724 | 752 | ServerHttp.layerStaticAndDevRoute, |
apps/web/src/components/settings/ProviderSettingsForm.test.ts+8| ... | ... | @@ -8,6 +8,14 @@ import { |
| 8 | 8 | } from "./ProviderSettingsForm"; |
| 9 | 9 | |
| 10 | 10 | describe("ProviderSettingsForm helpers", () => { |
| 11 | it("keeps managed account homes out of the editable runtime fields", () => { | |
| 12 | const codex = providerClients.get(ProviderDriverKind.make("codex"))!; | |
| 13 | expect( | |
| 14 | deriveProviderSettingsFields(codex, { sharedAccountId: "managed-account" }).map( | |
| 15 | (field) => field.key, | |
| 16 | ), | |
| 17 | ).toEqual(["binaryPath", "launchArgs"]); | |
| 18 | }); | |
| 11 | 19 | it("derives visible provider config fields from the client definition schema", () => { |
| 12 | 20 | const codex = providerClients.get(ProviderDriverKind.make("codex")); |
| 13 | 21 |
apps/web/src/components/settings/ProviderSettingsForm.tsx+2| ... | ... | @@ -92,6 +92,7 @@ export function deriveProviderSettingsFields( |
| 92 | 92 | const isLocalAcp = |
| 93 | 93 | definition.driverKind === "acpRegistry" && |
| 94 | 94 | readProviderConfigString(value, "source") === "local"; |
| 95 | const sharedAccount = readProviderConfigString(value, "sharedAccountId"); | |
| 95 | 96 | const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); |
| 96 | 97 | const orderedKeys = new Map( |
| 97 | 98 | (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), |
| ... | ... | @@ -110,6 +111,7 @@ export function deriveProviderSettingsFields( |
| 110 | 111 | const fieldSchema = definition.settingsSchema.fields[key]!; |
| 111 | 112 | const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); |
| 112 | 113 | if (formAnnotation.hidden) return []; |
| 114 | if (sharedAccount && (key === "homePath" || key === "shadowHomePath")) return []; | |
| 113 | 115 | if (isLocalAcp && key !== "source" && key !== "commandPath") return []; |
| 114 | 116 | |
| 115 | 117 | const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); |
compose.yaml created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | services: | |
| 2 | knife: | |
| 3 | image: ${KNIFE_IMAGE:-knife:local} | |
| 4 | build: | |
| 5 | context: . | |
| 6 | restart: unless-stopped | |
| 7 | stop_grace_period: 90s | |
| 8 | ports: | |
| 9 | - "${KNIFE_BIND_ADDRESS:-127.0.0.1}:${KNIFE_PORT:-3773}:3773" | |
| 10 | environment: | |
| 11 | KNIFE_UPDATE_URL: ${KNIFE_UPDATE_URL:?Set the public release feed URL} | |
| 12 | T3CODE_HOME: /data | |
| 13 | T3CODE_ARCHIVE_DIR: /archives | |
| 14 | volumes: | |
| 15 | - state:/data | |
| 16 | - provider-home:/home/node | |
| 17 | - ${KNIFE_ARCHIVES_DIRECTORY:?Set the host archive directory}:/archives | |
| 18 | - ${KNIFE_WORKSPACES_DIRECTORY:?Set the host workspace directory}:/workspaces | |
| 19 | ||
| 20 | volumes: | |
| 21 | state: | |
| 22 | provider-home: |
docs/user/shared-server.md created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | # Shared accounts | |
| 2 | ||
| 3 | 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. | |
| 4 | ||
| 5 | Start the shared server, then issue a separate revocable credential for each execution host. Keep the token in a private file: | |
| 6 | ||
| 7 | ```sh | |
| 8 | umask 077 | |
| 9 | t3 auth session issue --label laptop --ttl 3650d --scope providers:manage --scope orchestration:read --scope orchestration:operate --token-only > laptop-token | |
| 10 | ``` | |
| 11 | ||
| 12 | On the host with your signed-in profiles, connect its running T3 server and import each profile by its instance ID from Providers settings: | |
| 13 | ||
| 14 | ```sh | |
| 15 | t3 shared connect https://t3.example.com --token-file /private/path/laptop-token | |
| 16 | t3 shared import codex_personal | |
| 17 | t3 shared import claude_personal --token-file /private/path/claude-setup-token | |
| 18 | ``` | |
| 19 | ||
| 20 | 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. | |
| 21 | ||
| 22 | 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. | |
| 23 | ||
| 24 | 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). | |
| 25 | ||
| 26 | ## Run in a container | |
| 27 | ||
| 28 | 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: | |
| 29 | ||
| 30 | ```sh | |
| 31 | docker run --name the-knife -p 127.0.0.1:3773:3773 \ | |
| 32 | -e KNIFE_UPDATE_URL=https://files.example.com/public-builds/ \ | |
| 33 | -v /private/t3:/data -v /private/provider-home:/home/node \ | |
| 34 | -v /private/archives:/archives \ | |
| 35 | -v /private/workspaces:/workspaces the-knife | |
| 36 | ``` | |
| 37 | ||
| 38 | 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`. |
packages/contracts/src/index.ts+1| ... | ... | @@ -69,3 +69,4 @@ export * from "./desktopTerminal.ts"; |
| 69 | 69 | export * from "./scriptProgress.ts"; |
| 70 | 70 | |
| 71 | 71 | export * from "./uiSettings.ts"; |
| 72 | export * from "./sharedAccounts.ts"; |
packages/contracts/src/settings.ts+11| ... | ... | @@ -45,6 +45,7 @@ import { |
| 45 | 45 | ProviderDriverKind, |
| 46 | 46 | } from "./providerInstance.ts"; |
| 47 | 47 | import { PullRequestMergeMethod } from "./pullRequest.ts"; |
| 48 | import { SharedAccountId } from "./sharedAccounts.ts"; | |
| 48 | 49 | |
| 49 | 50 | // ── Client Settings (local-only) ─────────────────────────────── |
| 50 | 51 | |
| ... | ... | @@ -617,6 +618,9 @@ export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fi |
| 617 | 618 | |
| 618 | 619 | export const CodexSettings = makeProviderSettingsSchema( |
| 619 | 620 | { |
| 621 | sharedAccountId: Schema.optionalKey(SharedAccountId).pipe( | |
| 622 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 623 | ), | |
| 620 | 624 | setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe( |
| 621 | 625 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 622 | 626 | ), |
| ... | ... | @@ -679,6 +683,13 @@ const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/; |
| 679 | 683 | |
| 680 | 684 | export const ClaudeSettings = makeProviderSettingsSchema( |
| 681 | 685 | { |
| 686 | // Claude children read setup tokens at spawn; a revision change rebuilds the instance. | |
| 687 | sharedAccountRevision: Schema.optionalKey(Schema.Int).pipe( | |
| 688 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 689 | ), | |
| 690 | sharedAccountId: Schema.optionalKey(SharedAccountId).pipe( | |
| 691 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 692 | ), | |
| 682 | 693 | enabled: Schema.Boolean.pipe( |
| 683 | 694 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 684 | 695 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
packages/contracts/src/sharedAccounts.ts created+153| ... | ... | @@ -0,0 +1,153 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { ProviderInstanceId } from "./providerInstance.ts"; | |
| 3 | ||
| 4 | export const SharedAccountId = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]{1,48}$/u)); | |
| 5 | ||
| 6 | export const SharedCodexCredentials = Schema.Struct({ | |
| 7 | driver: Schema.Literal("codex"), | |
| 8 | accessToken: Schema.NonEmptyString, | |
| 9 | refreshToken: Schema.NonEmptyString, | |
| 10 | idToken: Schema.NonEmptyString, | |
| 11 | accountId: Schema.NonEmptyString, | |
| 12 | expiresAt: Schema.Finite, | |
| 13 | }); | |
| 14 | ||
| 15 | export const SharedClaudeCredentials = Schema.Struct({ | |
| 16 | driver: Schema.Literal("claudeAgent"), | |
| 17 | accessToken: Schema.NonEmptyString, | |
| 18 | }); | |
| 19 | ||
| 20 | export const SharedAccountCredentials = Schema.Union([ | |
| 21 | SharedCodexCredentials, | |
| 22 | SharedClaudeCredentials, | |
| 23 | ]); | |
| 24 | export type SharedAccountCredentials = typeof SharedAccountCredentials.Type; | |
| 25 | ||
| 26 | export const SharedAccount = Schema.Struct({ | |
| 27 | id: SharedAccountId, | |
| 28 | label: Schema.NonEmptyString.check(Schema.isMaxLength(120)), | |
| 29 | driver: Schema.Literals(["codex", "claudeAgent"]), | |
| 30 | revision: Schema.Int.check(Schema.isGreaterThan(0)), | |
| 31 | }); | |
| 32 | export type SharedAccount = typeof SharedAccount.Type; | |
| 33 | ||
| 34 | export const SharedAccountAccess = Schema.Union([ | |
| 35 | SharedCodexCredentials.mapFields(({ driver, accessToken, idToken, accountId, expiresAt }) => ({ | |
| 36 | driver, | |
| 37 | accessToken, | |
| 38 | idToken, | |
| 39 | accountId, | |
| 40 | expiresAt, | |
| 41 | })), | |
| 42 | SharedClaudeCredentials, | |
| 43 | ]); | |
| 44 | export type SharedAccountAccess = typeof SharedAccountAccess.Type; | |
| 45 | ||
| 46 | export const SharedAccountCommit = Schema.Struct({ | |
| 47 | type: Schema.Literal("commit"), | |
| 48 | id: SharedAccountId, | |
| 49 | revision: Schema.Int, | |
| 50 | leaseToken: Schema.NonEmptyString, | |
| 51 | credentials: SharedCodexCredentials, | |
| 52 | }); | |
| 53 | ||
| 54 | export const SharedAccountRequest = Schema.Union([ | |
| 55 | Schema.Struct({ type: Schema.Literal("list") }), | |
| 56 | Schema.Struct({ | |
| 57 | type: Schema.Literal("import"), | |
| 58 | label: SharedAccount.fields.label, | |
| 59 | replaceId: Schema.optionalKey(SharedAccountId), | |
| 60 | credentials: SharedAccountCredentials, | |
| 61 | }), | |
| 62 | Schema.Struct({ type: Schema.Literal("remove"), id: SharedAccountId }), | |
| 63 | Schema.Struct({ | |
| 64 | type: Schema.Literal("acquire"), | |
| 65 | id: SharedAccountId, | |
| 66 | refreshRevision: Schema.optionalKey(Schema.Int), | |
| 67 | }), | |
| 68 | SharedAccountCommit, | |
| 69 | ]); | |
| 70 | export type SharedAccountRequest = typeof SharedAccountRequest.Type; | |
| 71 | ||
| 72 | export const SharedAccountResponse = Schema.Union([ | |
| 73 | Schema.Struct({ type: Schema.Literal("list"), accounts: Schema.Array(SharedAccount) }), | |
| 74 | Schema.Struct({ type: Schema.Literal("imported"), account: SharedAccount }), | |
| 75 | Schema.Struct({ type: Schema.Literal("removed") }), | |
| 76 | Schema.Struct({ | |
| 77 | type: Schema.Literal("access"), | |
| 78 | account: SharedAccount, | |
| 79 | credentials: SharedAccountAccess, | |
| 80 | }), | |
| 81 | Schema.Struct({ | |
| 82 | type: Schema.Literal("refresh"), | |
| 83 | account: SharedAccount, | |
| 84 | leaseToken: Schema.NonEmptyString, | |
| 85 | credentials: SharedCodexCredentials, | |
| 86 | expiresAt: Schema.Finite, | |
| 87 | }), | |
| 88 | Schema.Struct({ type: Schema.Literal("busy"), retryAt: Schema.Finite }), | |
| 89 | ]); | |
| 90 | export type SharedAccountResponse = typeof SharedAccountResponse.Type; | |
| 91 | ||
| 92 | export const SharedProfilesRequest = Schema.Union([ | |
| 93 | Schema.Struct({ type: Schema.Literal("status") }), | |
| 94 | Schema.Struct({ type: Schema.Literal("sync") }), | |
| 95 | Schema.Struct({ | |
| 96 | type: Schema.Literal("connect"), | |
| 97 | url: Schema.NonEmptyString, | |
| 98 | tokenFile: Schema.NonEmptyString, | |
| 99 | }), | |
| 100 | Schema.Struct({ type: Schema.Literal("disconnect") }), | |
| 101 | Schema.Struct({ | |
| 102 | type: Schema.Literal("import"), | |
| 103 | instanceId: ProviderInstanceId, | |
| 104 | tokenFile: Schema.optionalKey(Schema.NonEmptyString), | |
| 105 | replaceId: Schema.optionalKey(SharedAccountId), | |
| 106 | }), | |
| 107 | Schema.Struct({ type: Schema.Literal("remove"), id: SharedAccountId }), | |
| 108 | ]); | |
| 109 | export type SharedProfilesRequest = typeof SharedProfilesRequest.Type; | |
| 110 | ||
| 111 | export const SharedProfilesStatus = Schema.Struct({ | |
| 112 | connected: Schema.Boolean, | |
| 113 | accounts: Schema.Array(SharedAccount), | |
| 114 | }); | |
| 115 | export type SharedProfilesStatus = typeof SharedProfilesStatus.Type; | |
| 116 | ||
| 117 | export class SharedAccountError extends Schema.TaggedError<SharedAccountError>()( | |
| 118 | "SharedAccountError", | |
| 119 | { | |
| 120 | reason: Schema.Literals([ | |
| 121 | "storage", | |
| 122 | "missing", | |
| 123 | "stale", | |
| 124 | "identity", | |
| 125 | "connection", | |
| 126 | "refresh", | |
| 127 | "busy", | |
| 128 | "setup", | |
| 129 | ]), | |
| 130 | cause: Schema.optionalKey(Schema.Redacted(Schema.Defect())), | |
| 131 | }, | |
| 132 | ) { | |
| 133 | override get message(): string { | |
| 134 | switch (this.reason) { | |
| 135 | case "setup": | |
| 136 | return "This profile could not be shared. Use a signed-in Codex profile or provide a Claude setup-token file."; | |
| 137 | case "busy": | |
| 138 | return "Another host is refreshing this account. Retry in a moment."; | |
| 139 | case "missing": | |
| 140 | return "This shared account was removed. Choose another account in Providers."; | |
| 141 | case "stale": | |
| 142 | return "Another host updated this account. Reconnect to load its latest credentials."; | |
| 143 | case "identity": | |
| 144 | return "The refreshed account changed. Import the intended account again."; | |
| 145 | case "connection": | |
| 146 | return "The shared server is unavailable. Reconnect before refreshing this account."; | |
| 147 | case "refresh": | |
| 148 | return "Codex could not refresh this account. Reconnect the account on the shared server."; | |
| 149 | case "storage": | |
| 150 | return "Shared accounts could not be saved. Check the shared server’s storage and retry."; | |
| 151 | } | |
| 152 | } | |
| 153 | } |