1import type { ProviderInstanceEnvironment, ProviderInstanceId } from "@t3tools/contracts";
2import type { AcpRegistrySettings } from "../settings.ts";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as Hex from "effect/encoding/Hex";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Ref from "effect/Ref";
9import * as Schema from "effect/Schema";
10import * as Semaphore from "effect/Semaphore";
11
12import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
13
14const decodeState = Schema.decodeUnknownEffect(
15 Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })),
16);
17const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
18const hash = Effect.fn("AcpRegistryAuthenticationState.hash")(function* (value: unknown) {
19 const crypto = yield* Crypto.Crypto;
20 const json = yield* encodeJson(value).pipe(Effect.orDie);
21 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(json)).pipe(Effect.orDie);
22 return Hex.encode(digest);
23});
24
25/** Remember explicit sign-in success, never discovery success or the agent's credentials. */
26export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")(
27 function* (input: {
28 readonly cacheDir: string;
29 readonly instanceId: ProviderInstanceId;
30 readonly settings: AcpRegistrySettings;
31 readonly environment: ProviderInstanceEnvironment;
32 readonly processEnvironment: NodeJS.ProcessEnv;
33 }) {
34 const fs = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const filePath = path.join(input.cacheDir, `acp-auth-${yield* hash(input.instanceId)}.json`);
37 // Cosmetic settings and model discovery can rebuild the driver without
38 // changing the account. Credential overrides and profile paths cannot.
39 const binding = yield* hash({
40 ...(input.settings.source === "local"
41 ? { source: "local", commandArgs: input.settings.commandArgs }
42 : {}),
43 agentId: input.settings.agentId,
44 commandPath: input.settings.commandPath,
45 distribution: input.settings.distribution,
46 authMethodId: input.settings.authMethodId,
47 environment: [...input.environment].toSorted((a, b) => a.name.localeCompare(b.name)),
48 profiles: [
49 "HOME",
50 "XDG_CONFIG_HOME",
51 "XDG_DATA_HOME",
52 "XDG_STATE_HOME",
53 "APPDATA",
54 "LOCALAPPDATA",
55 ].map((name) => input.processEnvironment[name] ?? null),
56 });
57 const saved = yield* fs.readFileString(filePath).pipe(
58 Effect.flatMap(decodeState),
59 Effect.orElseSucceed(() => undefined),
60 );
61 const confirmed = yield* Ref.make(saved?.binding === binding && saved.authenticated);
62 const lock = yield* Semaphore.make(1);
63 const persist = (authenticated: boolean) =>
64 writeFileStringAtomically({
65 filePath,
66 contents: JSON.stringify({ binding, authenticated }),
67 }).pipe(
68 Effect.provideService(FileSystem.FileSystem, fs),
69 Effect.provideService(Path.Path, path),
70 Effect.tapError(() => Effect.logWarning("Could not save ACP sign-in confirmation.")),
71 Effect.ignore,
72 );
73 if (saved && saved.binding !== binding) yield* persist(false);
74 return {
75 get: Ref.get(confirmed),
76 set: (authenticated: boolean) =>
77 lock.withPermit(
78 Ref.set(confirmed, authenticated).pipe(Effect.andThen(persist(authenticated))),
79 ),
80 };
81 },
82);