Skip to content

Commit 5853095

Browse files
refactor(desktop): put browser cookie import on Effect services
The import module reached for `node:fs/promises`, `node:os`, `node:path` and `node:sqlite` directly and threaded results through hand-rolled promises, which meant a blanket `nodeBuiltinImport:off` on two files and failures that were plain thrown `Error`s. It now uses FileSystem, Path and the shared Effect SQL client, and the cookie snapshot is a scoped resource rather than a try/finally with a cleanup callback. `node:crypto` stays — it implements the OSCrypt primitives Chromium uses and has no Effect equivalent — with the suppression narrowed to it and a reason attached. Failure reasons are a typed union in contracts rather than free strings, so the renderer maps a reason to copy instead of substring-matching an error message. Porting `isSourceRunning` to `FileSystem.stat` reintroduced the dangling SingletonLock bug, because `stat` and `exists` both follow symlinks; `readLink` is the probe that answers for the entry itself. The regression test caught it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 37dc626 commit 5853095

7 files changed

Lines changed: 504 additions & 289 deletions

File tree

Lines changed: 70 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
// @effect-diagnostics nodeBuiltinImport:off - Builds the on-disk browser layout the import reads.
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
22
import { assert, describe, it } from "@effect/vitest";
3+
import {
4+
HostProcessEnvironment,
5+
HostProcessExecutablePath,
6+
HostProcessPlatform,
7+
} from "@t3tools/shared/hostProcess";
38
import * as Effect from "effect/Effect";
9+
import * as FileSystem from "effect/FileSystem";
410
import * as Layer from "effect/Layer";
5-
import * as NodeFSP from "node:fs/promises";
6-
import * as NodeOS from "node:os";
7-
import * as NodePath from "node:path";
811

9-
import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1012
import * as BrowserSession from "../BrowserSession.ts";
1113
import * as BrowserImport from "./BrowserImport.ts";
12-
import { BROWSER_IMPORT_SOURCES } from "./Sources.ts";
14+
import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts";
1315

1416
const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
1517

1618
/**
17-
* Fails loudly if the import ever reaches session work: every test here covers
18-
* a request that must be rejected before a cookie is read or written.
19+
* Dies if the import reaches session work: every case here covers a request
20+
* that must be rejected before a cookie is read or written.
1921
*/
2022
const rejectedBeforeSession = Layer.succeed(BrowserSession.BrowserSession, {
2123
derivePartition: () => Effect.die("derivePartition must not be reached"),
@@ -24,41 +26,46 @@ const rejectedBeforeSession = Layer.succeed(BrowserSession.BrowserSession, {
2426
clearCache: () => Effect.die("clearCache must not be reached"),
2527
} as unknown as BrowserSession.BrowserSession["Service"]);
2628

27-
const layer = BrowserImport.layer.pipe(
28-
Layer.provide(rejectedBeforeSession),
29-
Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")),
30-
Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")),
31-
);
32-
33-
const withScratchHome = Effect.fnUntraced(function* () {
34-
const realHome = process.env.HOME;
35-
const home = yield* Effect.acquireRelease(
36-
Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-import-"))),
37-
(dir) =>
38-
Effect.promise(async () => {
39-
if (realHome === undefined) delete process.env.HOME;
40-
else process.env.HOME = realHome;
41-
await NodeFSP.rm(dir, { recursive: true, force: true });
42-
}),
29+
/**
30+
* Builds the service against a scratch home containing an installed, closed
31+
* copy of the source browser.
32+
*/
33+
const withImporter = Effect.fnUntraced(function* () {
34+
const fileSystem = yield* FileSystem.FileSystem;
35+
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });
36+
const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });
37+
const paths = yield* sourcePaths.pipe(
38+
Effect.provideService(HostProcessEnvironment, { HOME: home }),
4339
);
44-
process.env.HOME = home;
45-
yield* Effect.promise(() =>
46-
NodeFSP.mkdir(NodePath.join(helium.userDataDirectory(), "Default"), { recursive: true }),
40+
yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, {
41+
recursive: true,
42+
});
43+
44+
const importer = yield* BrowserImport.BrowserImport.pipe(
45+
Effect.provide(
46+
BrowserImport.layer.pipe(
47+
Layer.provide(rejectedBeforeSession),
48+
Layer.provide(environment),
49+
Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")),
50+
Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")),
51+
Layer.provide(NodeServices.layer),
52+
),
53+
),
4754
);
48-
return home;
55+
return { importer, home, paths };
4956
});
5057

5158
describe("BrowserImport.importCookies", () => {
52-
it.effect("rejects a profile directory the source never reported", () =>
59+
it.effect("rejects a source profile the browser never reported", () =>
5360
Effect.gen(function* () {
54-
const home = yield* withScratchHome();
55-
// A cookie database that is reachable on disk but outside the browser's
61+
const fileSystem = yield* FileSystem.FileSystem;
62+
const { importer, home } = yield* withImporter();
63+
64+
// A cookie database reachable on disk but outside the browser's
5665
// user-data directory — the payoff a traversal would be after.
57-
const secrets = NodePath.join(home, "secrets");
58-
yield* Effect.promise(() => NodeFSP.mkdir(secrets, { recursive: true }));
59-
yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(secrets, "Cookies"), "not-a-db"));
66+
yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true });
67+
yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db");
6068

61-
const importer = yield* BrowserImport.BrowserImport;
6269
const error = yield* importer
6370
.importCookies({
6471
input: {
@@ -73,6 +80,33 @@ describe("BrowserImport.importCookies", () => {
7380

7481
assert.instanceOf(error, BrowserImport.BrowserImportFailedError);
7582
assert.equal(error.reason, "unknownSourceProfile");
76-
}).pipe(Effect.provide(layer), Effect.scoped),
83+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
84+
);
85+
86+
it.effect("refuses to import while the source browser holds its profile", () =>
87+
Effect.gen(function* () {
88+
const fileSystem = yield* FileSystem.FileSystem;
89+
const { importer, paths } = yield* withImporter();
90+
// The lock Chromium leaves while it is running, dangling target and
91+
// all. This must stop the import before it ever asks the keychain.
92+
yield* fileSystem.symlink(
93+
"host-that-does-not-exist-1234",
94+
`${helium.userDataDirectory(paths)}/SingletonLock`,
95+
);
96+
97+
const error = yield* importer
98+
.importCookies({
99+
input: {
100+
sourceId: "helium",
101+
sourceProfileDirectory: "Default",
102+
targetProfileId: "default",
103+
},
104+
scope: "persist:t3code-preview-test",
105+
persistent: true,
106+
})
107+
.pipe(Effect.flip);
108+
109+
assert.equal(error.reason, "browserRunning");
110+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
77111
);
78112
});

apps/desktop/src/preview/BrowserImport/BrowserImport.ts

Lines changed: 61 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,40 @@ import type {
1010
BrowserImportSource,
1111
BrowserImportUnavailableReason,
1212
} from "@t3tools/contracts";
13+
import { BrowserImportFailureReason } from "@t3tools/contracts";
1314
import * as Context from "effect/Context";
1415
import * as Effect from "effect/Effect";
16+
import * as FileSystem from "effect/FileSystem";
1517
import * as Layer from "effect/Layer";
18+
import * as Path from "effect/Path";
1619
import * as Schema from "effect/Schema";
1720

1821
import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1922

2023
import * as BrowserSession from "../BrowserSession.ts";
21-
import {
22-
ChromiumCookieReadError,
23-
readChromiumCookies,
24-
type ChromiumCookie,
25-
} from "./ChromiumCookies.ts";
24+
import { readChromiumCookies } from "./ChromiumCookies.ts";
2625
import {
2726
BROWSER_IMPORT_SOURCES,
2827
cookieDatabasePath,
2928
isSourceInstalled,
3029
isSourceRunning,
3130
listSourceProfiles,
31+
sourcePaths,
3232
type BrowserImportSourceDefinition,
33+
type SourcePaths,
3334
} from "./Sources.ts";
3435

3536
export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
3637
"BrowserImportFailedError",
3738
{
3839
sourceId: Schema.String,
39-
reason: Schema.String,
40+
reason: BrowserImportFailureReason,
41+
/** Kept for the log; the user only ever sees the reason's copy. */
42+
cause: Schema.optional(Schema.Defect()),
4043
},
4144
) {
45+
// The reason token is part of the message on purpose: IPC flattens the error
46+
// to its message, and the renderer maps that token back to user-facing copy.
4247
override get message(): string {
4348
return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
4449
}
@@ -57,36 +62,40 @@ export class BrowserImport extends Context.Service<
5762
}
5863
>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
5964

60-
const unavailableReason = async (
65+
const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
6166
definition: BrowserImportSourceDefinition,
6267
platform: NodeJS.Platform,
63-
): Promise<BrowserImportUnavailableReason | undefined> => {
68+
paths: SourcePaths,
69+
): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
6470
if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
65-
if (!(await isSourceInstalled(definition))) return "notInstalled";
66-
if (await isSourceRunning(definition)) return "browserRunning";
71+
if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
72+
if (yield* isSourceRunning(definition, paths)) return "browserRunning";
6773
return undefined;
68-
};
74+
});
6975

7076
export const make = Effect.gen(function* BrowserImportMake() {
7177
const browserSession = yield* BrowserSession.BrowserSession;
7278
const platform = yield* HostProcessPlatform;
7379
const executablePath = yield* HostProcessExecutablePath;
74-
75-
const listSources = Effect.promise(async (): Promise<ReadonlyArray<BrowserImportSource>> => {
76-
const sources: BrowserImportSource[] = [];
77-
for (const definition of BROWSER_IMPORT_SOURCES) {
78-
const unavailable = await unavailableReason(definition, platform);
79-
sources.push({
80+
// Captured here so the service's methods stay free of a requirements
81+
// channel: the layer is built where NodeServices is already in scope.
82+
const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
83+
const paths = yield* sourcePaths;
84+
85+
const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
86+
BROWSER_IMPORT_SOURCES,
87+
Effect.fnUntraced(function* (definition) {
88+
const unavailable = yield* unavailableReason(definition, platform, paths);
89+
return {
8090
id: definition.id,
8191
name: definition.name,
8292
// Listing profiles touches the source's own files, so skip it when the
8393
// source is unusable anyway.
84-
profiles: unavailable === undefined ? await listSourceProfiles(definition) : [],
94+
profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
8595
...(unavailable === undefined ? {} : { unavailable }),
86-
});
87-
}
88-
return sources;
89-
});
96+
} satisfies BrowserImportSource;
97+
}),
98+
).pipe(Effect.provide(platformServices));
9099

91100
const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: {
92101
readonly input: BrowserImportInput;
@@ -99,11 +108,13 @@ export const make = Effect.gen(function* BrowserImportMake() {
99108
if (!definition) {
100109
return yield* new BrowserImportFailedError({
101110
sourceId: input.input.sourceId,
102-
reason: "unknown source",
111+
reason: "unknownSource",
103112
});
104113
}
105114

106-
const blocked = yield* Effect.promise(() => unavailableReason(definition, platform));
115+
const blocked = yield* unavailableReason(definition, platform, paths).pipe(
116+
Effect.provide(platformServices),
117+
);
107118
if (blocked !== undefined) {
108119
return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
109120
}
@@ -120,7 +131,9 @@ export const make = Effect.gen(function* BrowserImportMake() {
120131
// source itself reported it. Forwarding it unchecked would let `..`
121132
// segments walk out of the browser's user-data directory and read any
122133
// cookie database reachable on disk.
123-
const sourceProfiles = yield* Effect.promise(() => listSourceProfiles(definition));
134+
const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
135+
Effect.provide(platformServices),
136+
);
124137
const requestedProfile = sourceProfiles.find(
125138
(profile) => profile.directory === input.input.sourceProfileDirectory,
126139
);
@@ -131,28 +144,30 @@ export const make = Effect.gen(function* BrowserImportMake() {
131144
});
132145
}
133146

134-
const cookies: ReadonlyArray<ChromiumCookie> = yield* Effect.tryPromise({
135-
try: () =>
136-
readChromiumCookies({
137-
cookieDatabasePath: cookieDatabasePath(definition, requestedProfile.directory),
138-
keychainService: definition.keychainService,
139-
keychainAccount: definition.keychainAccount,
140-
platform,
141-
}),
142-
catch: (cause) =>
143-
new BrowserImportFailedError({
144-
sourceId: definition.id,
145-
reason: cause instanceof ChromiumCookieReadError ? cause.failure.reason : "readFailed",
146-
}),
147-
});
147+
const cookies = yield* readChromiumCookies({
148+
cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory),
149+
keychainService: definition.keychainService,
150+
keychainAccount: definition.keychainAccount,
151+
platform,
152+
}).pipe(
153+
Effect.scoped,
154+
Effect.provide(platformServices),
155+
Effect.mapError(
156+
(cause) =>
157+
new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
158+
),
159+
);
148160

149-
const session = yield* browserSession
150-
.getSession(input.scope, input.persistent)
151-
.pipe(
152-
Effect.mapError(
153-
() => new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }),
154-
),
155-
);
161+
const session = yield* browserSession.getSession(input.scope, input.persistent).pipe(
162+
Effect.mapError(
163+
(cause) =>
164+
new BrowserImportFailedError({
165+
sourceId: definition.id,
166+
reason: "sessionUnavailable",
167+
cause,
168+
}),
169+
),
170+
);
156171

157172
// Written one at a time rather than in parallel: Chromium's cookie store
158173
// serialises writes anyway, and a rejected cookie should only cost itself.

0 commit comments

Comments
 (0)