authorclover caruso <git@paperclover.net> 2026-10-15 22:09:27-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log58df35b5187753f5f3de7499ef1287785ca6fbcc
tree6431307df1f38df9a19af89a5e8ae1e9fd9e8814
parent44b17c3a0daa9b95d2b15f3d87ff07cf8c3b3d64
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat: share managed provider accounts through a private T3 server

Clover can select any shared Codex or Claude account on an execution host without signing in on every machine. A normal private T3 server coordinates fenced refresh claims and keeps refresh credentials out of native Codex homes. Claude setup tokens and shared session directories live in T3-owned homes; ordinary profiles stay separate and sharing remains opt-in. Run the server and provider CLIs in a container with persistent private state, provider homes, archives, and workspaces. Distribute a loadable Linux image with the canonical Compose configuration and public-feed example so infra can deploy without a source checkout or registry. Disable repository hooks and the unused compiler launcher probe during container builds. The image bootstrap downloads missing verified server/UI/assets archives and prefetches updates while running; code activation waits for a confirmed restart. Issue a separate revocable authority credential to each execution host. Keep account secrets away from the public build share. Scoped tests exercise refresh takeover, lost commit acknowledgements, private storage, permissions, reconnection, and real Claude process environment injection. Assisted-by: gpt-6.1-sol

29 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
18artifacts
19release
20releases
21tmp
22build
23.logs
24release-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 @@
1FROM node:24-bookworm-slim AS build
2RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
3RUN corepack enable
4ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1
5ENV VP_GIT_HOOKS=0
6WORKDIR /src
7COPY . .
8# The unused compiler's launcher probe loops under x64 emulation.
9RUN sed -i 's/^ tsc-rs: true$/ tsc-rs: false/' pnpm-workspace.yaml && pnpm install --frozen-lockfile
10RUN pnpm exec vp run --filter t3 build
11RUN pnpm --filter t3 deploy --prod --legacy /opt/knife/apps/server
12RUN 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
14FROM node:24-bookworm-slim
15ARG CODEX_VERSION=0.163.0
16ARG CLAUDE_VERSION=2.1.301
17RUN 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/*
18RUN npm install --global @openai/codex@${CODEX_VERSION} @anthropic-ai/claude-code@${CLAUDE_VERSION} && npm cache clean --force
19COPY --from=build --chown=node:node /opt/knife /opt/knife
20COPY scripts/container-runtime.ts /opt/knife/scripts/container-runtime.ts
21COPY packages/shared/package.json /opt/knife/packages/shared/package.json
22COPY packages/shared/src/forkUpdateDirectory.ts /opt/knife/packages/shared/src/forkUpdateDirectory.ts
23RUN 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
24COPY --chmod=755 infra/container/knife /usr/local/bin/knife
25RUN mkdir /data /archives /workspaces && chown node:node /data /archives /workspaces
26USER node
27WORKDIR /workspaces
28ENV T3CODE_HOME=/data
29EXPOSE 3773
30VOLUME ["/data", "/home/node", "/archives", "/workspaces"]
31ENTRYPOINT ["/usr/bin/tini", "--", "knife"]
32CMD ["serve", "--host", "0.0.0.0", "--port", "3773"]
apps/server/src/binCli.ts+2
......@@ -26,6 +26,7 @@ import { serviceCommand } from "./cli/service.ts";
2626import { servicePreflightCommand } from "./cli/servicePreflight.ts";
2727import { themeCommand } from "./cli/theme.ts";
2828import { traceCommand } from "./cli/trace.ts";
29import { sharedCommand } from "./cli/shared.ts";
2930import { triageCommand } from "./cli/triage.ts";
3031
3132const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer);
......@@ -84,6 +85,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
8485 servicePreflightCommand,
8586 themeCommand,
8687 traceCommand,
88 sharedCommand,
8789 triageCommand,
8890 cloudEnabled ? connectCommand : connectUnavailableCommand,
8991 ]),
apps/server/src/cli/shared.ts created+121
......@@ -0,0 +1,121 @@
1import {
2 AuthProvidersManageScope,
3 ProviderInstanceId,
4 SharedAccountError,
5 SharedAccountId,
6 SharedProfilesStatus,
7 type SharedProfilesRequest,
8} from "@t3tools/contracts";
9import * as Console from "effect/Console";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as References from "effect/References";
14import * as Schema from "effect/Schema";
15import { Argument, Command, Flag, GlobalFlag } from "effect/cli";
16import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
17import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
18import * as ServerConfig from "../config.ts";
19import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
20import { authLocationFlags, resolveCliAuthConfig, type CliAuthLocationFlags } from "./config.ts";
21
22const encodeStatus = Schema.encodeEffect(Schema.fromJsonString(SharedProfilesStatus));
23
24const 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
63const status = Command.make("status", authLocationFlags).pipe(
64 Command.withDescription("List shared accounts on this host."),
65 Command.withHandler((flags) => run(flags, { type: "status" })),
66);
67const 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);
71const 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);
83const 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);
87const 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);
110const 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
118export 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";
22import {
33 AuthOrchestrationOperateScope,
44 AuthOrchestrationReadScope,
5 AuthProvidersManageScope,
6 SharedAccountRequest,
7 SharedProfilesRequest,
8 SharedAccountError,
9 type AuthEnvironmentScope,
510 EnvironmentHttpApi,
611} from "@t3tools/contracts";
712import { isDevProxiedPath } from "@t3tools/shared/devProxy";
......@@ -9,6 +14,7 @@ import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tool
914import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
1015import * as Data from "effect/Data";
1116import * as Effect from "effect/Effect";
17import * as Redacted from "effect/Redacted";
1218import * as FileSystem from "effect/FileSystem";
1319import * as Layer from "effect/Layer";
1420import * as Option from "effect/Option";
......@@ -39,6 +45,8 @@ import {
3945} from "./assets/AttachmentUpload.ts";
4046import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
4147import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
48import * as SharedAccounts from "./provider/SharedAccounts.ts";
49import * as SharedProfiles from "./provider/SharedProfiles.ts";
4250import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
4351import {
4452 annotateEnvironmentRequest,
......@@ -299,9 +307,7 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
299307 return redirectUrl.toString();
300308}
301309
302const authenticateRawRouteWithScope = (
303 scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
304) =>
310const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) =>
305311 Effect.gen(function* () {
306312 const request = yield* HttpServerRequest.HttpServerRequest;
307313 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
......@@ -321,6 +327,75 @@ const authenticateRawRouteWithScope = (
321327 }
322328 });
323329
330export 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
366export 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
324399export const layerServerEnvironmentHttpApi = HttpApiBuilder.group(
325400 EnvironmentHttpApi,
326401 "metadata",
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+5-1
......@@ -1603,7 +1603,7 @@ export type CodexAdapterV2DriverEnv =
16031603
16041604export const createCodexAdapterV2 = (
16051605 { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput<CodexSettings>,
1606 hooks: Pick<CodexAdapterV2Options, "onUsageLimits" | "resolveRuntime"> = {},
1606 hooks: Pick<CodexAdapterV2Options, "onUsageLimits" | "resolveRuntime" | "authenticate"> = {},
16071607) =>
16081608 Effect.gen(function* () {
16091609 const clientFactory = yield* CodexAppServerClientFactory;
......@@ -1702,6 +1702,9 @@ export interface CodexAdapterV2Options {
17021702 * Codex with a current access token.
17031703 */
17041704 readonly resolveRuntime?: Effect.Effect<CodexEffectiveRuntime, ProviderSetupError, Scope.Scope>;
1705 readonly authenticate?: (
1706 client: CodexClient.CodexAppServerClient["Service"],
1707 ) => Effect.Effect<void, CodexErrors.CodexAppServerError>;
17051708 readonly crypto: Crypto.Crypto;
17061709 readonly fileSystem: FileSystem.FileSystem;
17071710 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
......@@ -1831,6 +1834,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
18311834 }),
18321835 );
18331836 yield* client.notify("initialized", undefined);
1837 if (adapterOptions.authenticate) yield* adapterOptions.authenticate(client);
18341838 yield* Ref.set(initialized, true);
18351839 }),
18361840 );
apps/server/src/provider/Drivers/ClaudeDriver.ts+54-3
......@@ -24,6 +24,7 @@ import { HttpClient } from "effect/http";
2424import { ChildProcessSpawner } from "effect/process";
2525
2626import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
27import * as ManagedProviderHome from "../ManagedProviderHome.ts";
2728import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
2829import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
2930import * as ServerConfig from "../../config.ts";
......@@ -96,6 +97,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
9697});
9798
9899export type ClaudeDriverEnv =
100 | ManagedProviderHome.ManagedProviderHome
99101 | ClaudeAdapterV2DriverEnv
100102 | ProviderHost.ProviderHost
101103 | ChildProcessSpawner.ChildProcessSpawner
......@@ -109,7 +111,11 @@ export type ClaudeDriverEnv =
109111 | ProviderEventLoggers.ProviderEventLoggers
110112 | ServerConfig.ServerConfig;
111113
112export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.Path> = {
114export const ClaudeDriver: ProviderDriver<
115 ClaudeSettings,
116 ClaudeDriverEnv,
117 Path.Path | ServerConfig.ServerConfig
118> = {
113119 driverKind: DRIVER_KIND,
114120 metadata: {
115121 displayName: "Claude",
......@@ -118,8 +124,53 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
118124 configSchema: ClaudeSettings,
119125 defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}),
120126 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 }) =>
122135 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;
123174 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
124175 const fileSystem = yield* FileSystem.FileSystem;
125176 const path = yield* Path.Path;
......@@ -156,7 +207,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
156207 ),
157208 );
158209 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(
159 effectiveConfig,
210 shared ? { ...effectiveConfig, homePath: shared.paths.shared } : effectiveConfig,
160211 processEnv,
161212 );
162213 const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv);
apps/server/src/provider/Drivers/CodexDriver.ts+94-13
......@@ -21,16 +21,20 @@
2121 *
2222 * @module provider/Drivers/CodexDriver
2323 */
24import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts";
24import { CodexSettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
2525import * as Crypto from "effect/Crypto";
2626import * as Effect from "effect/Effect";
2727import * as FileSystem from "effect/FileSystem";
2828import * as Path from "effect/Path";
2929import * as Schema from "effect/Schema";
30import * as Result from "effect/Result";
3031import { HttpClient } from "effect/http";
3132import { ChildProcessSpawner } from "effect/process";
3233
3334import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
35import * as ManagedProviderHome from "../ManagedProviderHome.ts";
36import * as SharedAccountClient from "../SharedAccountClient.ts";
37import { authenticateSharedCodex } from "../SharedCodexAuth.ts";
3438import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
3539import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
3640import * as ServerConfig from "../../config.ts";
......@@ -111,6 +115,8 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) {
111115 * registered driver and the runtime satisfies them once.
112116 */
113117export type CodexDriverEnv =
118 | ManagedProviderHome.ManagedProviderHome
119 | SharedAccountClient.SharedAccountClient
114120 | CodexAdapterV2DriverEnv
115121 | ProviderHost.ProviderHost
116122 | ChildProcessSpawner.ChildProcessSpawner
......@@ -127,7 +133,11 @@ export type CodexDriverEnv =
127133 | ServerEnvironment.ServerEnvironmentIdentity
128134 | CodexInstallation.CodexInstallation;
129135
130export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Path> = {
136export const CodexDriver: ProviderDriver<
137 CodexSettings,
138 CodexDriverEnv,
139 Path.Path | ServerConfig.ServerConfig
140> = {
131141 driverKind: DRIVER_KIND,
132142 metadata: {
133143 displayName: "Codex",
......@@ -136,8 +146,33 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
136146 configSchema: CodexSettings,
137147 defaultConfig: (): CodexSettings => decodeCodexSettings({}),
138148 usage: codexUsageReader,
139 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
149 create: ({ instanceId, displayName, accentColor, environment, enabled, config: configured }) =>
140150 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;
141176 if (config.setupMode === "managed")
142177 return yield* makeManagedCodexProvider({
143178 instanceId,
......@@ -182,6 +217,24 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
182217 binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory),
183218 homePath: homeLayout.effectiveHomePath ?? "",
184219 } 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;
185238 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
186239 resolveProviderMaintenanceCapabilitiesEffect(
187240 makeCodexMaintenanceResolver(homeLayout.sharedHomePath),
......@@ -205,7 +258,18 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
205258 enabled,
206259 config,
207260 },
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 },
209273 ).pipe(
210274 Effect.mapError(
211275 (cause) =>
......@@ -225,15 +289,26 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
225289 // Kick the TTL-gated manifest refresh in the background and classify
226290 // with the in-memory manifest, so a slow or hung fetch never delays the
227291 // 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(
237312 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
238313 Effect.provideService(FileSystem.FileSystem, fileSystem),
239314 Effect.provideService(Path.Path, pathService),
......@@ -277,6 +352,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
277352 effectiveConfig,
278353 processEnv,
279354 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
355 resolveSharedRuntime,
280356 );
281357 const snapshotForCwd = (cwd: string) =>
282358 !effectiveConfig.enabled
......@@ -319,6 +395,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
319395 resetCreditCoordinator
320396 .redeem(accountKey, (idempotencyKey) =>
321397 Effect.gen(function* () {
398 if (resolveSharedRuntime) yield* resolveSharedRuntime;
322399 const { client } = yield* withCodexAppServerClient({
323400 binaryPath: effectiveConfig.binaryPath,
324401 homePath: effectiveConfig.homePath,
......@@ -327,6 +404,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
327404 cwd: process.cwd(),
328405 environment: processEnv,
329406 });
407 if (sharedId && sharedAccounts)
408 yield* authenticateSharedCodex(client, (force) =>
409 sharedAccounts.access(sharedId, force),
410 );
330411 const response = yield* client.request("account/rateLimitResetCredit/consume", {
331412 idempotencyKey,
332413 });
apps/server/src/provider/Drivers/claudeUsage.ts+9-2
......@@ -18,6 +18,8 @@ import {
1818 type UsageRecord,
1919} from "@t3tools/provider-core/server/usage";
2020import * as HostProcess from "@t3tools/shared/HostProcess";
21import * as ServerConfig from "../../config.ts";
22import { sharedProviderHomePaths } from "../ManagedProviderHome.ts";
2123
2224/**
2325 * Parses one line of a Claude Code transcript.
......@@ -103,7 +105,7 @@ export const claudeUsageFormat: TranscriptUsageFormat<void> = {
103105 parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)),
104106};
105107
106export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> = {
108export const claudeUsageReader = {
107109 kind: "transcripts",
108110 provider: "claude",
109111 format: claudeUsageFormat,
......@@ -111,6 +113,11 @@ export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> =
111113 // An undecodable config has no trustworthy home to read.
112114 if (config === undefined) return [];
113115 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 }
114121 const homeDirectory = yield* HostProcess.HomeDirectory;
115122 const configured = config.homePath.trim();
116123 const home = configured
......@@ -118,4 +125,4 @@ export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> =
118125 : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDirectory, ".claude");
119126 return [{ dir: path.resolve(home, "projects") }];
120127 }),
121};
128} satisfies ProviderUsageReader<ClaudeSettings, Path.Path | ServerConfig.ServerConfig>;
apps/server/src/provider/Drivers/codexUsage.ts+9-2
......@@ -21,6 +21,8 @@ import {
2121import type { UsageTokenTotals } from "@t3tools/contracts";
2222
2323import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts";
24import * as ServerConfig from "../../config.ts";
25import { sharedProviderHomePaths } from "../ManagedProviderHome.ts";
2426
2527/**
2628 * Rolling state for a single Codex rollout file.
......@@ -239,7 +241,7 @@ export const codexUsageFormat: TranscriptUsageFormat<CodexScanState> = {
239241 sharedSessionsAcrossFiles: true,
240242};
241243
242export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {
244export const codexUsageReader = {
243245 kind: "transcripts",
244246 provider: "codex",
245247 format: codexUsageFormat,
......@@ -247,6 +249,11 @@ export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {
247249 // An undecodable config has no trustworthy home to read.
248250 if (config === undefined) return [];
249251 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 }
250257 const environmentHome = environment.CODEX_HOME?.trim();
251258 const layout = yield* resolveCodexHomeLayout(
252259 config.setupMode !== "managed" &&
......@@ -258,4 +265,4 @@ export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {
258265 );
259266 return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }];
260267 }),
261};
268} satisfies ProviderUsageReader<CodexSettings, Path.Path | ServerConfig.ServerConfig>;
apps/server/src/provider/ManagedProviderHome.ts created+139
......@@ -0,0 +1,139 @@
1import { SharedAccountError, SharedAccountId, type SharedAccountAccess } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as KeyedLock from "@t3tools/shared/KeyedLock";
12import * as ServerConfig from "../config.ts";
13import * as SharedAccountClient from "./SharedAccountClient.ts";
14
15const decodeId = Schema.decodeEffect(SharedAccountId);
16const encodeAuth = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
17const isSharedAccountError = Schema.is(SharedAccountError);
18
19const 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
31export 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
41export 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
61const 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
139export const layer = Layer.effect(ManagedProviderHome, make);
apps/server/src/provider/ProviderInstanceRegistry.test.ts+94-3
......@@ -1,4 +1,7 @@
11import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as ManagedProviderHome from "./ManagedProviderHome.ts";
3import * as SharedAccountClient from "./SharedAccountClient.ts";
4import * as SharedAccounts from "./SharedAccounts.ts";
25import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
36import * as CodexInstallation from "./CodexInstallation.ts";
47import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
......@@ -68,6 +71,12 @@ import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts";
6871import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts";
6972import * as ProviderHostLive from "./ProviderHostLive.ts";
7073
74const layerSharedAccounts = ManagedProviderHome.layer.pipe(
75 Layer.provideMerge(SharedAccountClient.layer),
76 Layer.provideMerge(SharedAccounts.layer),
77 Layer.provideMerge(ServerSecretStore.layer),
78);
79
7180const layerTestHttpClient = Layer.succeed(
7281 HttpClient.HttpClient,
7382 HttpClient.make((request) =>
......@@ -215,7 +224,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
215224 managedDirectory: "unused-managed-installation",
216225 }),
217226 ),
218 Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})),
219227 Layer.provideMerge(
220228 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
221229 getEnvironmentId: Effect.succeed(
......@@ -238,7 +246,10 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
238246 Layer.provideMerge(ModelManifest.layerTest),
239247 Layer.provideMerge(ResetCreditCoordinator.layerTest),
240248 );
241 const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps));
249 const layerBase = ProviderHostLive.layer.pipe(
250 Layer.provideMerge(layerSharedAccounts),
251 Layer.provideMerge(layerBaseDeps),
252 );
242253 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(
243254 Layer.provideMerge(layerBase),
244255 );
......@@ -472,6 +483,83 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
472483 }).pipe(Effect.provide(layerTest)),
473484 );
474485
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
475563 const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) =>
476564 Effect.gen(function* () {
477565 const fs = yield* FileSystem.FileSystem;
......@@ -657,7 +745,10 @@ describe("ProviderInstanceRegistry — all drivers slice", () => {
657745 Layer.provideMerge(ModelManifest.layerTest),
658746 Layer.provideMerge(ResetCreditCoordinator.layerTest),
659747 );
660 const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps));
748 const layerBase = ProviderHostLive.layer.pipe(
749 Layer.provideMerge(layerSharedAccounts),
750 Layer.provideMerge(layerBaseDeps),
751 );
661752 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(
662753 Layer.provideMerge(layerBase),
663754 );
apps/server/src/provider/SharedAccountClient.test.ts created+192
......@@ -0,0 +1,192 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { assert, it } from "@effect/vitest";
3import { SharedAccountRequest, SharedAccountError } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Cause from "effect/Cause";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";
10import { HttpClient, HttpClientResponse } from "effect/http";
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as SharedAccounts from "./SharedAccounts.ts";
13import * as SharedAccountClient from "./SharedAccountClient.ts";
14
15const decodeRequest = Schema.decodeEffect(Schema.fromJsonString(SharedAccountRequest));
16const isSharedAccountError = Schema.is(SharedAccountError);
17
18const jwt = (expiresAt: number) =>
19 `eyJhbGciOiJub25lIn0.${Buffer.from(JSON.stringify({ exp: expiresAt / 1000 })).toString("base64url")}.signature`;
20const 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
132it.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
147it.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
164it.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
173it.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
185it.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 @@
1import {
2 SharedAccountAccess,
3 SharedAccountId,
4 SharedAccountError,
5 SharedAccountResponse,
6 SharedAccountCommit,
7 type SharedAccountRequest,
8} from "@t3tools/contracts";
9import * as KeyedLock from "@t3tools/shared/KeyedLock";
10import { decodeJwt } from "jose";
11import * as Clock from "effect/Clock";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Redacted from "effect/Redacted";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Schema from "effect/Schema";
19import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
20import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
21import * as SharedAccounts from "./SharedAccounts.ts";
22
23const Connection = Schema.Struct({ url: Schema.String, token: Schema.NonEmptyString });
24const CachedAccess = Schema.Struct({ revision: Schema.Int, credentials: SharedAccountAccess });
25const Tokens = Schema.Struct({
26 access_token: Schema.NonEmptyString,
27 refresh_token: Schema.optionalKey(Schema.NonEmptyString),
28 id_token: Schema.optionalKey(Schema.NonEmptyString),
29});
30
31const decodeConnection = Schema.decodeEffect(Schema.fromJsonString(Connection));
32const encodeConnection = Schema.encodeEffect(Schema.fromJsonString(Connection));
33const decodeAccess = Schema.decodeEffect(Schema.fromJsonString(CachedAccess));
34const encodeAccess = Schema.encodeEffect(Schema.fromJsonString(CachedAccess));
35const decodeCommit = Schema.decodeEffect(Schema.fromJsonString(SharedAccountCommit));
36const encodeCommit = Schema.encodeEffect(Schema.fromJsonString(SharedAccountCommit));
37const decodeId = Schema.decodeEffect(SharedAccountId);
38const isSharedAccountError = Schema.is(SharedAccountError);
39
40export 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
56const 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
322export const layer = Layer.effect(SharedAccountClient, make);
apps/server/src/provider/SharedAccounts.test.ts created+207
......@@ -0,0 +1,207 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as TestClock from "effect/testing/TestClock";
7import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
8import * as SharedAccounts from "./SharedAccounts.ts";
9
10const 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
19it.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
56function 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
87it.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
108it.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
141it.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
172it.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
190it.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 @@
1import {
2 SharedAccount,
3 SharedAccountCredentials,
4 SharedAccountError,
5 type SharedAccountRequest,
6 type SharedAccountResponse,
7} from "@t3tools/contracts";
8import * as Clock from "effect/Clock";
9import * as Context from "effect/Context";
10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";
12import * as Redacted from "effect/Redacted";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
18
19const StoredAccount = Schema.Struct({
20 ...SharedAccount.fields,
21 credentials: SharedAccountCredentials,
22 lease: Schema.NullOr(Schema.Struct({ token: Schema.String, expiresAt: Schema.Finite })),
23});
24const StoredAccounts = Schema.fromJsonString(Schema.Array(StoredAccount));
25const decodeAccounts = Schema.decodeEffect(StoredAccounts);
26const encodeAccounts = Schema.encodeEffect(StoredAccounts);
27const sameCredentials = Schema.toEquivalence(SharedAccountCredentials);
28
29export 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
38const 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
223export const layer = Layer.effect(SharedAccounts, make);
apps/server/src/provider/SharedAccountsHttp.test.ts created+70
......@@ -0,0 +1,70 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { AuthProvidersManageScope, AuthOrchestrationReadScope } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Context from "effect/Context";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8import { HttpRouter } from "effect/http";
9import * as ServerConfig from "../config.ts";
10import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
11import { layerSharedAccountRoute } from "../http.ts";
12import * as SharedAccounts from "./SharedAccounts.ts";
13
14const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
15const 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
21it.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 @@
1import type { SharedAccountAccess, SharedAccountError } from "@t3tools/contracts";
2import type * as CodexClient from "effect-codex-app-server/client";
3import * as CodexErrors from "effect-codex-app-server/errors";
4import * as Effect from "effect/Effect";
5
6export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 CodexSettings,
5 ClaudeSettings,
6 ProviderDriverKind,
7 ProviderInstanceId,
8 type SharedAccount,
9 SharedAccountError,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
16import { HttpClient, HttpClientResponse } from "effect/http";
17import * as ServerConfig from "../config.ts";
18import * as ServerSettings from "../serverSettings.ts";
19import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
20import * as ManagedProviderHome from "./ManagedProviderHome.ts";
21import * as SharedAccounts from "./SharedAccounts.ts";
22import * as SharedAccountClient from "./SharedAccountClient.ts";
23import * as SharedProfiles from "./SharedProfiles.ts";
24import { codexUsageReader } from "./Drivers/codexUsage.ts";
25import { claudeUsageReader } from "./Drivers/claudeUsage.ts";
26
27const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings);
28const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings);
29const isSharedAccountError = Schema.is(SharedAccountError);
30
31const 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
97it.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 @@
1import {
2 ClaudeSettings,
3 CodexSettings,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 SharedAccountError,
7 SharedAccountId,
8 type SharedProfilesRequest,
9 type SharedProfilesStatus,
10} from "@t3tools/contracts";
11import { decodeJwt } from "jose";
12import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";
14import * as Redacted from "effect/Redacted";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Path from "effect/Path";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20import * as Semaphore from "effect/Semaphore";
21import * as ServerSettings from "../serverSettings.ts";
22import { resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts";
23import * as SharedAccountClient from "./SharedAccountClient.ts";
24
25const SharedConfig = Schema.Struct({
26 sharedAccountId: SharedAccountId,
27 sharedAccountRevision: Schema.optionalKey(Schema.Int),
28});
29const 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
40const isSharedConfig = Schema.is(SharedConfig);
41const decodeSharedConfig = Schema.decodeUnknownOption(SharedConfig);
42const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings);
43const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings);
44const decodeCodexAuth = Schema.decodeEffect(CodexAuthFile);
45const isSharedAccountError = Schema.is(SharedAccountError);
46const readConfig = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown));
47
48export 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
57const 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
237export const layer = Layer.effect(SharedProfiles, make);
apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs+10
......@@ -1,6 +1,16 @@
11#!/usr/bin/env node
22import * as NodeFS from "node:fs";
33import * as NodeReadline from "node:readline";
4const requiredToken = process.env.T3_CLAUDE_REQUIRED_TOKEN;
5if (
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}
414// Opt-in: act like a `claude` wrapper that only runs inside one directory.
515const requiredCwd = process.env.T3_CLAUDE_REQUIRED_CWD;
616if (requiredCwd && process.cwd() !== NodeFS.realpathSync(requiredCwd)) {
apps/server/src/server.ts+28
......@@ -1,3 +1,7 @@
1import * as SharedAccounts from "./provider/SharedAccounts.ts";
2import * as SharedAccountClient from "./provider/SharedAccountClient.ts";
3import * as ManagedProviderHome from "./provider/ManagedProviderHome.ts";
4import * as SharedProfiles from "./provider/SharedProfiles.ts";
15import * as UiSettings from "./uiSettings.ts";
26import * as ScriptProgress from "./progress/ScriptProgress.ts";
37import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
......@@ -549,6 +553,24 @@ const layerProviderInstallationRefresh = Layer.effectDiscard(
549553);
550554
551555const 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 ),
552574 AgentAwarenessRelay.layer,
553575 // Asks T3 Connect to deliver webhooks it held while this environment was offline.
554576 HeldHooksWaker.layer,
......@@ -626,6 +648,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
626648);
627649
628650const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
651 Layer.provideMerge(SharedProfiles.layer),
652 Layer.provideMerge(ManagedProviderHome.layer),
653 Layer.provideMerge(SharedAccountClient.layer),
654 Layer.provideMerge(SharedAccounts.layer),
629655 Layer.provideMerge(layerPtyAdapter),
630656 // Search, prepare, status inspection, and turn launch share one registry
631657 // cache so every client and provider instance sees the same prepared agents.
......@@ -719,6 +745,8 @@ const layerMakeRoutes = Layer.mergeAll(
719745 ServerHttp.layerOtlpTracesProxyRoute,
720746 ServerHttp.layerAssetRoute,
721747 ServerHttp.layerAttachmentUploadRoute,
748 ServerHttp.layerSharedAccountRoute,
749 ServerHttp.layerSharedProfilesRoute,
722750 DeviceHubProxy.layer,
723751 ServerBrowserStream.routeLayer,
724752 ServerHttp.layerStaticAndDevRoute,
apps/web/src/components/settings/ProviderSettingsForm.test.ts+8
......@@ -8,6 +8,14 @@ import {
88} from "./ProviderSettingsForm";
99
1010describe("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 });
1119 it("derives visible provider config fields from the client definition schema", () => {
1220 const codex = providerClients.get(ProviderDriverKind.make("codex"));
1321
apps/web/src/components/settings/ProviderSettingsForm.tsx+2
......@@ -92,6 +92,7 @@ export function deriveProviderSettingsFields(
9292 const isLocalAcp =
9393 definition.driverKind === "acpRegistry" &&
9494 readProviderConfigString(value, "source") === "local";
95 const sharedAccount = readProviderConfigString(value, "sharedAccountId");
9596 const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition);
9697 const orderedKeys = new Map(
9798 (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const),
......@@ -110,6 +111,7 @@ export function deriveProviderSettingsFields(
110111 const fieldSchema = definition.settingsSchema.fields[key]!;
111112 const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema);
112113 if (formAnnotation.hidden) return [];
114 if (sharedAccount && (key === "homePath" || key === "shadowHomePath")) return [];
113115 if (isLocalAcp && key !== "source" && key !== "commandPath") return [];
114116
115117 const annotatedTitle = readFieldAnnotationString(fieldSchema, "title");
compose.yaml created+22
......@@ -0,0 +1,22 @@
1services:
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
20volumes:
21 state:
22 provider-home:
docs/user/shared-server.md created+38
......@@ -0,0 +1,38 @@
1# Shared accounts
2
3A 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
5Start the shared server, then issue a separate revocable credential for each execution host. Keep the token in a private file:
6
7```sh
8umask 077
9t3 auth session issue --label laptop --ttl 3650d --scope providers:manage --scope orchestration:read --scope orchestration:operate --token-only > laptop-token
10```
11
12On 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
15t3 shared connect https://t3.example.com --token-file /private/path/laptop-token
16t3 shared import codex_personal
17t3 shared import claude_personal --token-file /private/path/claude-setup-token
18```
19
20For 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
22Connected 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
24Use `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
28Build 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
31docker 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
38The 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";
6969export * from "./scriptProgress.ts";
7070
7171export * from "./uiSettings.ts";
72export * from "./sharedAccounts.ts";
packages/contracts/src/settings.ts+11
......@@ -45,6 +45,7 @@ import {
4545 ProviderDriverKind,
4646} from "./providerInstance.ts";
4747import { PullRequestMergeMethod } from "./pullRequest.ts";
48import { SharedAccountId } from "./sharedAccounts.ts";
4849
4950// ── Client Settings (local-only) ───────────────────────────────
5051
......@@ -617,6 +618,9 @@ export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fi
617618
618619export const CodexSettings = makeProviderSettingsSchema(
619620 {
621 sharedAccountId: Schema.optionalKey(SharedAccountId).pipe(
622 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
623 ),
620624 setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe(
621625 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
622626 ),
......@@ -679,6 +683,13 @@ const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/;
679683
680684export const ClaudeSettings = makeProviderSettingsSchema(
681685 {
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 ),
682693 enabled: Schema.Boolean.pipe(
683694 Schema.withDecodingDefault(Effect.succeed(true)),
684695 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
packages/contracts/src/sharedAccounts.ts created+153
......@@ -0,0 +1,153 @@
1import * as Schema from "effect/Schema";
2import { ProviderInstanceId } from "./providerInstance.ts";
3
4export const SharedAccountId = Schema.String.check(Schema.isPattern(/^[a-zA-Z0-9_-]{1,48}$/u));
5
6export 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
15export const SharedClaudeCredentials = Schema.Struct({
16 driver: Schema.Literal("claudeAgent"),
17 accessToken: Schema.NonEmptyString,
18});
19
20export const SharedAccountCredentials = Schema.Union([
21 SharedCodexCredentials,
22 SharedClaudeCredentials,
23]);
24export type SharedAccountCredentials = typeof SharedAccountCredentials.Type;
25
26export 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});
32export type SharedAccount = typeof SharedAccount.Type;
33
34export 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]);
44export type SharedAccountAccess = typeof SharedAccountAccess.Type;
45
46export 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
54export 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]);
70export type SharedAccountRequest = typeof SharedAccountRequest.Type;
71
72export 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]);
90export type SharedAccountResponse = typeof SharedAccountResponse.Type;
91
92export 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]);
109export type SharedProfilesRequest = typeof SharedProfilesRequest.Type;
110
111export const SharedProfilesStatus = Schema.Struct({
112 connected: Schema.Boolean,
113 accounts: Schema.Array(SharedAccount),
114});
115export type SharedProfilesStatus = typeof SharedProfilesStatus.Type;
116
117export 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}