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";...@@ -26,6 +26,7 @@ import { serviceCommand } from "./cli/service.ts";
26import { servicePreflightCommand } from "./cli/servicePreflight.ts";26import { servicePreflightCommand } from "./cli/servicePreflight.ts";
27import { themeCommand } from "./cli/theme.ts";27import { themeCommand } from "./cli/theme.ts";
28import { traceCommand } from "./cli/trace.ts";28import { traceCommand } from "./cli/trace.ts";
29import { sharedCommand } from "./cli/shared.ts";
29import { triageCommand } from "./cli/triage.ts";30import { triageCommand } from "./cli/triage.ts";
3031
31const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer);32const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer);
...@@ -84,6 +85,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>...@@ -84,6 +85,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
84 servicePreflightCommand,85 servicePreflightCommand,
85 themeCommand,86 themeCommand,
86 traceCommand,87 traceCommand,
88 sharedCommand,
87 triageCommand,89 triageCommand,
88 cloudEnabled ? connectCommand : connectUnavailableCommand,90 cloudEnabled ? connectCommand : connectUnavailableCommand,
89 ]),91 ]),
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";...@@ -2,6 +2,11 @@ import * as Mime from "effect/http/Mime";
2import {2import {
3 AuthOrchestrationOperateScope,3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,4 AuthOrchestrationReadScope,
5 AuthProvidersManageScope,
6 SharedAccountRequest,
7 SharedProfilesRequest,
8 SharedAccountError,
9 type AuthEnvironmentScope,
5 EnvironmentHttpApi,10 EnvironmentHttpApi,
6} from "@t3tools/contracts";11} from "@t3tools/contracts";
7import { isDevProxiedPath } from "@t3tools/shared/devProxy";12import { isDevProxiedPath } from "@t3tools/shared/devProxy";
...@@ -9,6 +14,7 @@ import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tool...@@ -9,6 +14,7 @@ import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tool
9import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";14import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
10import * as Data from "effect/Data";15import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
17import * as Redacted from "effect/Redacted";
12import * as FileSystem from "effect/FileSystem";18import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";19import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";20import * as Option from "effect/Option";
...@@ -39,6 +45,8 @@ import {...@@ -39,6 +45,8 @@ import {
39} from "./assets/AttachmentUpload.ts";45} from "./assets/AttachmentUpload.ts";
40import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";46import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
41import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";47import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
48import * as SharedAccounts from "./provider/SharedAccounts.ts";
49import * as SharedProfiles from "./provider/SharedProfiles.ts";
42import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";50import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
43import {51import {
44 annotateEnvironmentRequest,52 annotateEnvironmentRequest,
...@@ -299,9 +307,7 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {...@@ -299,9 +307,7 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
299 return redirectUrl.toString();307 return redirectUrl.toString();
300}308}
301309
302const authenticateRawRouteWithScope = (310const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) =>
303 scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
304) =>
305 Effect.gen(function* () {311 Effect.gen(function* () {
306 const request = yield* HttpServerRequest.HttpServerRequest;312 const request = yield* HttpServerRequest.HttpServerRequest;
307 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;313 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
...@@ -321,6 +327,75 @@ const authenticateRawRouteWithScope = (...@@ -321,6 +327,75 @@ const authenticateRawRouteWithScope = (
321 }327 }
322 });328 });
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
324export const layerServerEnvironmentHttpApi = HttpApiBuilder.group(399export const layerServerEnvironmentHttpApi = HttpApiBuilder.group(
325 EnvironmentHttpApi,400 EnvironmentHttpApi,
326 "metadata",401 "metadata",
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+5-1
...@@ -1603,7 +1603,7 @@ export type CodexAdapterV2DriverEnv =...@@ -1603,7 +1603,7 @@ export type CodexAdapterV2DriverEnv =
16031603
1604export const createCodexAdapterV2 = (1604export const createCodexAdapterV2 = (
1605 { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput<CodexSettings>,1605 { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput<CodexSettings>,
1606 hooks: Pick<CodexAdapterV2Options, "onUsageLimits" | "resolveRuntime"> = {},1606 hooks: Pick<CodexAdapterV2Options, "onUsageLimits" | "resolveRuntime" | "authenticate"> = {},
1607) =>1607) =>
1608 Effect.gen(function* () {1608 Effect.gen(function* () {
1609 const clientFactory = yield* CodexAppServerClientFactory;1609 const clientFactory = yield* CodexAppServerClientFactory;
...@@ -1702,6 +1702,9 @@ export interface CodexAdapterV2Options {...@@ -1702,6 +1702,9 @@ export interface CodexAdapterV2Options {
1702 * Codex with a current access token.1702 * Codex with a current access token.
1703 */1703 */
1704 readonly resolveRuntime?: Effect.Effect<CodexEffectiveRuntime, ProviderSetupError, Scope.Scope>;1704 readonly resolveRuntime?: Effect.Effect<CodexEffectiveRuntime, ProviderSetupError, Scope.Scope>;
1705 readonly authenticate?: (
1706 client: CodexClient.CodexAppServerClient["Service"],
1707 ) => Effect.Effect<void, CodexErrors.CodexAppServerError>;
1705 readonly crypto: Crypto.Crypto;1708 readonly crypto: Crypto.Crypto;
1706 readonly fileSystem: FileSystem.FileSystem;1709 readonly fileSystem: FileSystem.FileSystem;
1707 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];1710 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
...@@ -1831,6 +1834,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -1831,6 +1834,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
1831 }),1834 }),
1832 );1835 );
1833 yield* client.notify("initialized", undefined);1836 yield* client.notify("initialized", undefined);
1837 if (adapterOptions.authenticate) yield* adapterOptions.authenticate(client);
1834 yield* Ref.set(initialized, true);1838 yield* Ref.set(initialized, true);
1835 }),1839 }),
1836 );1840 );
apps/server/src/provider/Drivers/ClaudeDriver.ts+54-3
...@@ -24,6 +24,7 @@ import { HttpClient } from "effect/http";...@@ -24,6 +24,7 @@ import { HttpClient } from "effect/http";
24import { ChildProcessSpawner } from "effect/process";24import { ChildProcessSpawner } from "effect/process";
2525
26import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";26import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
27import * as ManagedProviderHome from "../ManagedProviderHome.ts";
27import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";28import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
28import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";29import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
29import * as ServerConfig from "../../config.ts";30import * as ServerConfig from "../../config.ts";
...@@ -96,6 +97,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({...@@ -96,6 +97,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
96});97});
9798
98export type ClaudeDriverEnv =99export type ClaudeDriverEnv =
100 | ManagedProviderHome.ManagedProviderHome
99 | ClaudeAdapterV2DriverEnv101 | ClaudeAdapterV2DriverEnv
100 | ProviderHost.ProviderHost102 | ProviderHost.ProviderHost
101 | ChildProcessSpawner.ChildProcessSpawner103 | ChildProcessSpawner.ChildProcessSpawner
...@@ -109,7 +111,11 @@ export type ClaudeDriverEnv =...@@ -109,7 +111,11 @@ export type ClaudeDriverEnv =
109 | ProviderEventLoggers.ProviderEventLoggers111 | ProviderEventLoggers.ProviderEventLoggers
110 | ServerConfig.ServerConfig;112 | 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> = {
113 driverKind: DRIVER_KIND,119 driverKind: DRIVER_KIND,
114 metadata: {120 metadata: {
115 displayName: "Claude",121 displayName: "Claude",
...@@ -118,8 +124,53 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path....@@ -118,8 +124,53 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
118 configSchema: ClaudeSettings,124 configSchema: ClaudeSettings,
119 defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}),125 defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}),
120 usage: claudeUsageReader,126 usage: claudeUsageReader,
121 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>127 create: ({
128 instanceId,
129 displayName,
130 accentColor,
131 environment: configuredEnvironment,
132 enabled,
133 config: configured,
134 }) =>
122 Effect.gen(function* () {135 Effect.gen(function* () {
136 const sharedId = configured.sharedAccountId;
137 const managedHomes = sharedId ? yield* ManagedProviderHome.ManagedProviderHome : undefined;
138 const shared =
139 sharedId && managedHomes
140 ? yield* Effect.gen(function* () {
141 const credentials = yield* managedHomes.prepare("claudeAgent", sharedId);
142 const paths = yield* managedHomes.paths("claudeAgent", sharedId);
143 return { credentials, paths };
144 }).pipe(
145 Effect.mapError(
146 (cause) =>
147 new ProviderDriverError({
148 driver: DRIVER_KIND,
149 instanceId,
150 detail: cause.message,
151 cause,
152 }),
153 ),
154 )
155 : undefined;
156 const config = shared ? { ...configured, homePath: shared.paths.account } : configured;
157 const environment = shared
158 ? [
159 ...configuredEnvironment.filter(
160 ({ name }) =>
161 !["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"].includes(
162 name,
163 ),
164 ),
165 {
166 name: "CLAUDE_CODE_OAUTH_TOKEN",
167 value: shared.credentials.accessToken,
168 sensitive: true,
169 },
170 { name: "ANTHROPIC_API_KEY", value: "", sensitive: true },
171 { name: "ANTHROPIC_AUTH_TOKEN", value: "", sensitive: true },
172 ]
173 : configuredEnvironment;
123 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;174 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
124 const fileSystem = yield* FileSystem.FileSystem;175 const fileSystem = yield* FileSystem.FileSystem;
125 const path = yield* Path.Path;176 const path = yield* Path.Path;
...@@ -156,7 +207,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path....@@ -156,7 +207,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
156 ),207 ),
157 );208 );
158 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(209 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(
159 effectiveConfig,210 shared ? { ...effectiveConfig, homePath: shared.paths.shared } : effectiveConfig,
160 processEnv,211 processEnv,
161 );212 );
162 const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv);213 const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv);
apps/server/src/provider/Drivers/CodexDriver.ts+94-13
...@@ -21,16 +21,20 @@...@@ -21,16 +21,20 @@
21 *21 *
22 * @module provider/Drivers/CodexDriver22 * @module provider/Drivers/CodexDriver
23 */23 */
24import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts";24import { CodexSettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
25import * as Crypto from "effect/Crypto";25import * as Crypto from "effect/Crypto";
26import * as Effect from "effect/Effect";26import * as Effect from "effect/Effect";
27import * as FileSystem from "effect/FileSystem";27import * as FileSystem from "effect/FileSystem";
28import * as Path from "effect/Path";28import * as Path from "effect/Path";
29import * as Schema from "effect/Schema";29import * as Schema from "effect/Schema";
30import * as Result from "effect/Result";
30import { HttpClient } from "effect/http";31import { HttpClient } from "effect/http";
31import { ChildProcessSpawner } from "effect/process";32import { ChildProcessSpawner } from "effect/process";
3233
33import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";34import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
35import * as ManagedProviderHome from "../ManagedProviderHome.ts";
36import * as SharedAccountClient from "../SharedAccountClient.ts";
37import { authenticateSharedCodex } from "../SharedCodexAuth.ts";
34import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";38import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
35import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";39import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
36import * as ServerConfig from "../../config.ts";40import * as ServerConfig from "../../config.ts";
...@@ -111,6 +115,8 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) {...@@ -111,6 +115,8 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) {
111 * registered driver and the runtime satisfies them once.115 * registered driver and the runtime satisfies them once.
112 */116 */
113export type CodexDriverEnv =117export type CodexDriverEnv =
118 | ManagedProviderHome.ManagedProviderHome
119 | SharedAccountClient.SharedAccountClient
114 | CodexAdapterV2DriverEnv120 | CodexAdapterV2DriverEnv
115 | ProviderHost.ProviderHost121 | ProviderHost.ProviderHost
116 | ChildProcessSpawner.ChildProcessSpawner122 | ChildProcessSpawner.ChildProcessSpawner
...@@ -127,7 +133,11 @@ export type CodexDriverEnv =...@@ -127,7 +133,11 @@ export type CodexDriverEnv =
127 | ServerEnvironment.ServerEnvironmentIdentity133 | ServerEnvironment.ServerEnvironmentIdentity
128 | CodexInstallation.CodexInstallation;134 | 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> = {
131 driverKind: DRIVER_KIND,141 driverKind: DRIVER_KIND,
132 metadata: {142 metadata: {
133 displayName: "Codex",143 displayName: "Codex",
...@@ -136,8 +146,33 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat...@@ -136,8 +146,33 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
136 configSchema: CodexSettings,146 configSchema: CodexSettings,
137 defaultConfig: (): CodexSettings => decodeCodexSettings({}),147 defaultConfig: (): CodexSettings => decodeCodexSettings({}),
138 usage: codexUsageReader,148 usage: codexUsageReader,
139 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>149 create: ({ instanceId, displayName, accentColor, environment, enabled, config: configured }) =>
140 Effect.gen(function* () {150 Effect.gen(function* () {
151 const sharedId = configured.sharedAccountId;
152 const managedHomes = sharedId ? yield* ManagedProviderHome.ManagedProviderHome : undefined;
153 const sharedAccounts = sharedId ? yield* SharedAccountClient.SharedAccountClient : undefined;
154 const managedPaths =
155 sharedId && managedHomes
156 ? yield* managedHomes.paths("codex", sharedId).pipe(
157 Effect.mapError(
158 (cause) =>
159 new ProviderDriverError({
160 driver: DRIVER_KIND,
161 instanceId,
162 detail: cause.message,
163 cause,
164 }),
165 ),
166 )
167 : undefined;
168 const config = managedPaths
169 ? {
170 ...configured,
171 setupMode: "existing" as const,
172 homePath: managedPaths.shared,
173 shadowHomePath: managedPaths.account,
174 }
175 : configured;
141 if (config.setupMode === "managed")176 if (config.setupMode === "managed")
142 return yield* makeManagedCodexProvider({177 return yield* makeManagedCodexProvider({
143 instanceId,178 instanceId,
...@@ -182,6 +217,24 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat...@@ -182,6 +217,24 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
182 binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory),217 binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory),
183 homePath: homeLayout.effectiveHomePath ?? "",218 homePath: homeLayout.effectiveHomePath ?? "",
184 } satisfies CodexSettings;219 } satisfies CodexSettings;
220 const resolveSharedRuntime =
221 sharedId && managedHomes
222 ? managedHomes.prepare("codex", sharedId).pipe(
223 Effect.map((credentials) => ({
224 config: effectiveConfig,
225 environment: processEnv,
226 revision: credentials.accessToken,
227 })),
228 Effect.mapError(
229 (cause) =>
230 new ProviderSetupError({
231 instanceId,
232 operation: "shared-account",
233 detail: cause.message,
234 }),
235 ),
236 )
237 : undefined;
185 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(238 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
186 resolveProviderMaintenanceCapabilitiesEffect(239 resolveProviderMaintenanceCapabilitiesEffect(
187 makeCodexMaintenanceResolver(homeLayout.sharedHomePath),240 makeCodexMaintenanceResolver(homeLayout.sharedHomePath),
...@@ -205,7 +258,18 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat...@@ -205,7 +258,18 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
205 enabled,258 enabled,
206 config,259 config,
207 },260 },
208 { onUsageLimits: (update) => snapshot.applyUsageLimits(update) },261 {
262 onUsageLimits: (update) => snapshot.applyUsageLimits(update),
263 ...(resolveSharedRuntime ? { resolveRuntime: resolveSharedRuntime } : {}),
264 ...(sharedId && sharedAccounts
265 ? {
266 authenticate: (client) =>
267 authenticateSharedCodex(client, (force) =>
268 sharedAccounts.access(sharedId, force),
269 ),
270 }
271 : {}),
272 },
209 ).pipe(273 ).pipe(
210 Effect.mapError(274 Effect.mapError(
211 (cause) =>275 (cause) =>
...@@ -225,15 +289,26 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat...@@ -225,15 +289,26 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
225 // Kick the TTL-gated manifest refresh in the background and classify289 // Kick the TTL-gated manifest refresh in the background and classify
226 // with the in-memory manifest, so a slow or hung fetch never delays the290 // with the in-memory manifest, so a slow or hung fetch never delays the
227 // provider check. A refresh that lands mid-probe applies on the next one.291 // provider check. A refresh that lands mid-probe applies on the next one.
228 const checkProvider = modelCatalog.refreshInBackground.pipe(292 const checkProvider = Effect.gen(function* () {
229 Effect.andThen(293 const readiness = yield* (resolveSharedRuntime ?? Effect.void).pipe(Effect.result);
230 Effect.zipWith(294 yield* modelCatalog.refreshInBackground;
231 checkCodexProviderStatus(effectiveConfig, undefined, processEnv),295 return yield* Effect.zipWith(
232 currentCatalog,296 checkCodexProviderStatus(effectiveConfig, undefined, processEnv),
233 (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)),297 currentCatalog,
234 { concurrent: true },298 (draft, catalog) =>
235 ),299 stampIdentity({
236 ),300 ...applyCodexModelCatalog(draft, catalog),
301 ...(Result.isFailure(readiness)
302 ? {
303 status: "error" as const,
304 auth: { status: "unknown" as const },
305 message: readiness.failure.message,
306 }
307 : {}),
308 }),
309 { concurrent: true },
310 );
311 }).pipe(
237 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),312 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
238 Effect.provideService(FileSystem.FileSystem, fileSystem),313 Effect.provideService(FileSystem.FileSystem, fileSystem),
239 Effect.provideService(Path.Path, pathService),314 Effect.provideService(Path.Path, pathService),
...@@ -277,6 +352,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat...@@ -277,6 +352,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
277 effectiveConfig,352 effectiveConfig,
278 processEnv,353 processEnv,
279 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),354 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
355 resolveSharedRuntime,
280 );356 );
281 const snapshotForCwd = (cwd: string) =>357 const snapshotForCwd = (cwd: string) =>
282 !effectiveConfig.enabled358 !effectiveConfig.enabled
...@@ -319,6 +395,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat...@@ -319,6 +395,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
319 resetCreditCoordinator395 resetCreditCoordinator
320 .redeem(accountKey, (idempotencyKey) =>396 .redeem(accountKey, (idempotencyKey) =>
321 Effect.gen(function* () {397 Effect.gen(function* () {
398 if (resolveSharedRuntime) yield* resolveSharedRuntime;
322 const { client } = yield* withCodexAppServerClient({399 const { client } = yield* withCodexAppServerClient({
323 binaryPath: effectiveConfig.binaryPath,400 binaryPath: effectiveConfig.binaryPath,
324 homePath: effectiveConfig.homePath,401 homePath: effectiveConfig.homePath,
...@@ -327,6 +404,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat...@@ -327,6 +404,10 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Pat
327 cwd: process.cwd(),404 cwd: process.cwd(),
328 environment: processEnv,405 environment: processEnv,
329 });406 });
407 if (sharedId && sharedAccounts)
408 yield* authenticateSharedCodex(client, (force) =>
409 sharedAccounts.access(sharedId, force),
410 );
330 const response = yield* client.request("account/rateLimitResetCredit/consume", {411 const response = yield* client.request("account/rateLimitResetCredit/consume", {
331 idempotencyKey,412 idempotencyKey,
332 });413 });
apps/server/src/provider/Drivers/claudeUsage.ts+9-2
...@@ -18,6 +18,8 @@ import {...@@ -18,6 +18,8 @@ import {
18 type UsageRecord,18 type UsageRecord,
19} from "@t3tools/provider-core/server/usage";19} from "@t3tools/provider-core/server/usage";
20import * as HostProcess from "@t3tools/shared/HostProcess";20import * as HostProcess from "@t3tools/shared/HostProcess";
21import * as ServerConfig from "../../config.ts";
22import { sharedProviderHomePaths } from "../ManagedProviderHome.ts";
2123
22/**24/**
23 * Parses one line of a Claude Code transcript.25 * Parses one line of a Claude Code transcript.
...@@ -103,7 +105,7 @@ export const claudeUsageFormat: TranscriptUsageFormat<void> = {...@@ -103,7 +105,7 @@ export const claudeUsageFormat: TranscriptUsageFormat<void> = {
103 parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)),105 parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)),
104};106};
105107
106export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> = {108export const claudeUsageReader = {
107 kind: "transcripts",109 kind: "transcripts",
108 provider: "claude",110 provider: "claude",
109 format: claudeUsageFormat,111 format: claudeUsageFormat,
...@@ -111,6 +113,11 @@ export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> =...@@ -111,6 +113,11 @@ export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> =
111 // An undecodable config has no trustworthy home to read.113 // An undecodable config has no trustworthy home to read.
112 if (config === undefined) return [];114 if (config === undefined) return [];
113 const path = yield* Path.Path;115 const path = yield* Path.Path;
116 if (config.sharedAccountId) {
117 const { stateDir } = yield* ServerConfig.ServerConfig;
118 const home = sharedProviderHomePaths(path, stateDir, "claudeAgent", config.sharedAccountId);
119 return [{ dir: path.join(home.shared, "projects") }];
120 }
114 const homeDirectory = yield* HostProcess.HomeDirectory;121 const homeDirectory = yield* HostProcess.HomeDirectory;
115 const configured = config.homePath.trim();122 const configured = config.homePath.trim();
116 const home = configured123 const home = configured
...@@ -118,4 +125,4 @@ export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> =...@@ -118,4 +125,4 @@ export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> =
118 : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDirectory, ".claude");125 : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDirectory, ".claude");
119 return [{ dir: path.resolve(home, "projects") }];126 return [{ dir: path.resolve(home, "projects") }];
120 }),127 }),
121};128} satisfies ProviderUsageReader<ClaudeSettings, Path.Path | ServerConfig.ServerConfig>;
apps/server/src/provider/Drivers/codexUsage.ts+9-2
...@@ -21,6 +21,8 @@ import {...@@ -21,6 +21,8 @@ import {
21import type { UsageTokenTotals } from "@t3tools/contracts";21import type { UsageTokenTotals } from "@t3tools/contracts";
2222
23import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts";23import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts";
24import * as ServerConfig from "../../config.ts";
25import { sharedProviderHomePaths } from "../ManagedProviderHome.ts";
2426
25/**27/**
26 * Rolling state for a single Codex rollout file.28 * Rolling state for a single Codex rollout file.
...@@ -239,7 +241,7 @@ export const codexUsageFormat: TranscriptUsageFormat<CodexScanState> = {...@@ -239,7 +241,7 @@ export const codexUsageFormat: TranscriptUsageFormat<CodexScanState> = {
239 sharedSessionsAcrossFiles: true,241 sharedSessionsAcrossFiles: true,
240};242};
241243
242export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {244export const codexUsageReader = {
243 kind: "transcripts",245 kind: "transcripts",
244 provider: "codex",246 provider: "codex",
245 format: codexUsageFormat,247 format: codexUsageFormat,
...@@ -247,6 +249,11 @@ export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {...@@ -247,6 +249,11 @@ export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {
247 // An undecodable config has no trustworthy home to read.249 // An undecodable config has no trustworthy home to read.
248 if (config === undefined) return [];250 if (config === undefined) return [];
249 const path = yield* Path.Path;251 const path = yield* Path.Path;
252 if (config.sharedAccountId) {
253 const { stateDir } = yield* ServerConfig.ServerConfig;
254 const home = sharedProviderHomePaths(path, stateDir, "codex", config.sharedAccountId);
255 return [{ dir: path.join(home.shared, "sessions") }];
256 }
250 const environmentHome = environment.CODEX_HOME?.trim();257 const environmentHome = environment.CODEX_HOME?.trim();
251 const layout = yield* resolveCodexHomeLayout(258 const layout = yield* resolveCodexHomeLayout(
252 config.setupMode !== "managed" &&259 config.setupMode !== "managed" &&
...@@ -258,4 +265,4 @@ export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {...@@ -258,4 +265,4 @@ export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {
258 );265 );
259 return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }];266 return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }];
260 }),267 }),
261};268} satisfies ProviderUsageReader<CodexSettings, Path.Path | ServerConfig.ServerConfig>;
apps/server/src/provider/ManagedProviderHome.ts created+139
...@@ -0,0 +1,139 @@
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 @@...@@ -1,4 +1,7 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";1import * 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";
2import * as ServerSecretStore from "../auth/ServerSecretStore.ts";5import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
3import * as CodexInstallation from "./CodexInstallation.ts";6import * as CodexInstallation from "./CodexInstallation.ts";
4import * as ServerEnvironment from "../environment/ServerEnvironment.ts";7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
...@@ -68,6 +71,12 @@ import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts";...@@ -68,6 +71,12 @@ import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts";
68import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts";71import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts";
69import * as ProviderHostLive from "./ProviderHostLive.ts";72import * 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
71const layerTestHttpClient = Layer.succeed(80const layerTestHttpClient = Layer.succeed(
72 HttpClient.HttpClient,81 HttpClient.HttpClient,
73 HttpClient.make((request) =>82 HttpClient.make((request) =>
...@@ -215,7 +224,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {...@@ -215,7 +224,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
215 managedDirectory: "unused-managed-installation",224 managedDirectory: "unused-managed-installation",
216 }),225 }),
217 ),226 ),
218 Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})),
219 Layer.provideMerge(227 Layer.provideMerge(
220 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {228 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
221 getEnvironmentId: Effect.succeed(229 getEnvironmentId: Effect.succeed(
...@@ -238,7 +246,10 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {...@@ -238,7 +246,10 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
238 Layer.provideMerge(ModelManifest.layerTest),246 Layer.provideMerge(ModelManifest.layerTest),
239 Layer.provideMerge(ResetCreditCoordinator.layerTest),247 Layer.provideMerge(ResetCreditCoordinator.layerTest),
240 );248 );
241 const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps));249 const layerBase = ProviderHostLive.layer.pipe(
250 Layer.provideMerge(layerSharedAccounts),
251 Layer.provideMerge(layerBaseDeps),
252 );
242 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(253 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(
243 Layer.provideMerge(layerBase),254 Layer.provideMerge(layerBase),
244 );255 );
...@@ -472,6 +483,83 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {...@@ -472,6 +483,83 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
472 }).pipe(Effect.provide(layerTest)),483 }).pipe(Effect.provide(layerTest)),
473 );484 );
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
475 const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) =>563 const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) =>
476 Effect.gen(function* () {564 Effect.gen(function* () {
477 const fs = yield* FileSystem.FileSystem;565 const fs = yield* FileSystem.FileSystem;
...@@ -657,7 +745,10 @@ describe("ProviderInstanceRegistry — all drivers slice", () => {...@@ -657,7 +745,10 @@ describe("ProviderInstanceRegistry — all drivers slice", () => {
657 Layer.provideMerge(ModelManifest.layerTest),745 Layer.provideMerge(ModelManifest.layerTest),
658 Layer.provideMerge(ResetCreditCoordinator.layerTest),746 Layer.provideMerge(ResetCreditCoordinator.layerTest),
659 );747 );
660 const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps));748 const layerBase = ProviderHostLive.layer.pipe(
749 Layer.provideMerge(layerSharedAccounts),
750 Layer.provideMerge(layerBaseDeps),
751 );
661 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(752 const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe(
662 Layer.provideMerge(layerBase),753 Layer.provideMerge(layerBase),
663 );754 );
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 @@...@@ -1,6 +1,16 @@
1#!/usr/bin/env node1#!/usr/bin/env node
2import * as NodeFS from "node:fs";2import * as NodeFS from "node:fs";
3import * as NodeReadline from "node:readline";3import * 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}
4// Opt-in: act like a `claude` wrapper that only runs inside one directory.14// Opt-in: act like a `claude` wrapper that only runs inside one directory.
5const requiredCwd = process.env.T3_CLAUDE_REQUIRED_CWD;15const requiredCwd = process.env.T3_CLAUDE_REQUIRED_CWD;
6if (requiredCwd && process.cwd() !== NodeFS.realpathSync(requiredCwd)) {16if (requiredCwd && process.cwd() !== NodeFS.realpathSync(requiredCwd)) {
apps/server/src/server.ts+28
...@@ -1,3 +1,7 @@...@@ -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";
1import * as UiSettings from "./uiSettings.ts";5import * as UiSettings from "./uiSettings.ts";
2import * as ScriptProgress from "./progress/ScriptProgress.ts";6import * as ScriptProgress from "./progress/ScriptProgress.ts";
3import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";7import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
...@@ -549,6 +553,24 @@ const layerProviderInstallationRefresh = Layer.effectDiscard(...@@ -549,6 +553,24 @@ const layerProviderInstallationRefresh = Layer.effectDiscard(
549);553);
550554
551const layerRuntimeCoreDependenciesBase = Layer.mergeAll(555const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
556 Layer.effectDiscard(
557 Effect.gen(function* () {
558 const profiles = yield* SharedProfiles.SharedProfiles;
559 yield* ServerActivation.forkParked(
560 Effect.forever(
561 profiles.execute({ type: "sync" }).pipe(
562 Effect.catchTags({
563 SharedAccountError: (error) =>
564 Effect.logWarning("Shared account sync could not finish", {
565 reason: error.reason,
566 }),
567 }),
568 Effect.andThen(Effect.sleep("1 minute")),
569 ),
570 ),
571 );
572 }),
573 ),
552 AgentAwarenessRelay.layer,574 AgentAwarenessRelay.layer,
553 // Asks T3 Connect to deliver webhooks it held while this environment was offline.575 // Asks T3 Connect to deliver webhooks it held while this environment was offline.
554 HeldHooksWaker.layer,576 HeldHooksWaker.layer,
...@@ -626,6 +648,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -626,6 +648,10 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
626);648);
627649
628const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(650const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
651 Layer.provideMerge(SharedProfiles.layer),
652 Layer.provideMerge(ManagedProviderHome.layer),
653 Layer.provideMerge(SharedAccountClient.layer),
654 Layer.provideMerge(SharedAccounts.layer),
629 Layer.provideMerge(layerPtyAdapter),655 Layer.provideMerge(layerPtyAdapter),
630 // Search, prepare, status inspection, and turn launch share one registry656 // Search, prepare, status inspection, and turn launch share one registry
631 // cache so every client and provider instance sees the same prepared agents.657 // cache so every client and provider instance sees the same prepared agents.
...@@ -719,6 +745,8 @@ const layerMakeRoutes = Layer.mergeAll(...@@ -719,6 +745,8 @@ const layerMakeRoutes = Layer.mergeAll(
719 ServerHttp.layerOtlpTracesProxyRoute,745 ServerHttp.layerOtlpTracesProxyRoute,
720 ServerHttp.layerAssetRoute,746 ServerHttp.layerAssetRoute,
721 ServerHttp.layerAttachmentUploadRoute,747 ServerHttp.layerAttachmentUploadRoute,
748 ServerHttp.layerSharedAccountRoute,
749 ServerHttp.layerSharedProfilesRoute,
722 DeviceHubProxy.layer,750 DeviceHubProxy.layer,
723 ServerBrowserStream.routeLayer,751 ServerBrowserStream.routeLayer,
724 ServerHttp.layerStaticAndDevRoute,752 ServerHttp.layerStaticAndDevRoute,
apps/web/src/components/settings/ProviderSettingsForm.test.ts+8
...@@ -8,6 +8,14 @@ import {...@@ -8,6 +8,14 @@ import {
8} from "./ProviderSettingsForm";8} from "./ProviderSettingsForm";
99
10describe("ProviderSettingsForm helpers", () => {10describe("ProviderSettingsForm helpers", () => {
11 it("keeps managed account homes out of the editable runtime fields", () => {
12 const codex = providerClients.get(ProviderDriverKind.make("codex"))!;
13 expect(
14 deriveProviderSettingsFields(codex, { sharedAccountId: "managed-account" }).map(
15 (field) => field.key,
16 ),
17 ).toEqual(["binaryPath", "launchArgs"]);
18 });
11 it("derives visible provider config fields from the client definition schema", () => {19 it("derives visible provider config fields from the client definition schema", () => {
12 const codex = providerClients.get(ProviderDriverKind.make("codex"));20 const codex = providerClients.get(ProviderDriverKind.make("codex"));
1321
apps/web/src/components/settings/ProviderSettingsForm.tsx+2
...@@ -92,6 +92,7 @@ export function deriveProviderSettingsFields(...@@ -92,6 +92,7 @@ export function deriveProviderSettingsFields(
92 const isLocalAcp =92 const isLocalAcp =
93 definition.driverKind === "acpRegistry" &&93 definition.driverKind === "acpRegistry" &&
94 readProviderConfigString(value, "source") === "local";94 readProviderConfigString(value, "source") === "local";
95 const sharedAccount = readProviderConfigString(value, "sharedAccountId");
95 const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition);96 const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition);
96 const orderedKeys = new Map(97 const orderedKeys = new Map(
97 (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const),98 (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const),
...@@ -110,6 +111,7 @@ export function deriveProviderSettingsFields(...@@ -110,6 +111,7 @@ export function deriveProviderSettingsFields(
110 const fieldSchema = definition.settingsSchema.fields[key]!;111 const fieldSchema = definition.settingsSchema.fields[key]!;
111 const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema);112 const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema);
112 if (formAnnotation.hidden) return [];113 if (formAnnotation.hidden) return [];
114 if (sharedAccount && (key === "homePath" || key === "shadowHomePath")) return [];
113 if (isLocalAcp && key !== "source" && key !== "commandPath") return [];115 if (isLocalAcp && key !== "source" && key !== "commandPath") return [];
114116
115 const annotatedTitle = readFieldAnnotationString(fieldSchema, "title");117 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";...@@ -69,3 +69,4 @@ export * from "./desktopTerminal.ts";
69export * from "./scriptProgress.ts";69export * from "./scriptProgress.ts";
7070
71export * from "./uiSettings.ts";71export * from "./uiSettings.ts";
72export * from "./sharedAccounts.ts";
packages/contracts/src/settings.ts+11
...@@ -45,6 +45,7 @@ import {...@@ -45,6 +45,7 @@ import {
45 ProviderDriverKind,45 ProviderDriverKind,
46} from "./providerInstance.ts";46} from "./providerInstance.ts";
47import { PullRequestMergeMethod } from "./pullRequest.ts";47import { PullRequestMergeMethod } from "./pullRequest.ts";
48import { SharedAccountId } from "./sharedAccounts.ts";
4849
49// ── Client Settings (local-only) ───────────────────────────────50// ── Client Settings (local-only) ───────────────────────────────
5051
...@@ -617,6 +618,9 @@ export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fi...@@ -617,6 +618,9 @@ export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fi
617618
618export const CodexSettings = makeProviderSettingsSchema(619export const CodexSettings = makeProviderSettingsSchema(
619 {620 {
621 sharedAccountId: Schema.optionalKey(SharedAccountId).pipe(
622 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
623 ),
620 setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe(624 setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe(
621 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),625 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
622 ),626 ),
...@@ -679,6 +683,13 @@ const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/;...@@ -679,6 +683,13 @@ const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/;
679683
680export const ClaudeSettings = makeProviderSettingsSchema(684export const ClaudeSettings = makeProviderSettingsSchema(
681 {685 {
686 // Claude children read setup tokens at spawn; a revision change rebuilds the instance.
687 sharedAccountRevision: Schema.optionalKey(Schema.Int).pipe(
688 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
689 ),
690 sharedAccountId: Schema.optionalKey(SharedAccountId).pipe(
691 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
692 ),
682 enabled: Schema.Boolean.pipe(693 enabled: Schema.Boolean.pipe(
683 Schema.withDecodingDefault(Effect.succeed(true)),694 Schema.withDecodingDefault(Effect.succeed(true)),
684 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),695 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}