History

Store file secrets under EXECUTOR_DATA_DIR when set (#1431)

* Add regression test for secrets data directory * Store file secrets in executor data directory * Harden file secrets migration * Add changeset

Rhys Sullivan committed 3 months ago

Commit 39c2bfd1 parent a86cc4e4 files · +498 −57Browse files

Unified · Split

4 files
  1. .changeset/file-secrets-data-dir.md +5 −0
  2. packages/plugins/file-secrets/src/data-dir.test.ts +115 −0
  3. packages/plugins/file-secrets/src/index.test.ts +208 −0
  4. packages/plugins/file-secrets/src/index.ts +170 −57
.changeset/file-secrets-data-dir.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/plugin-file-secrets": patch
3+---
4+
5+`fileSecretsPlugin()` now stores `auth.json` under `EXECUTOR_DATA_DIR` when that variable is set (an explicit `directory` option still wins; the XDG location remains the fallback when it is unset). Existing secrets in the legacy XDG location are migrated automatically on first use. This keeps all daemon state under one directory, so persisting `EXECUTOR_DATA_DIR` alone preserves credentials across environment recreation.
packages/plugins/file-secrets/src/data-dir.test.ts+115−0
@@ -0,0 +1,115 @@
1+import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
5+import { Effect } from "effect";
6+
7+import {
8+ AuthTemplateSlug,
9+ ConnectionName,
10+ IntegrationSlug,
11+ ProviderKey,
12+ definePlugin,
13+} from "@executor-js/sdk";
14+import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";
15+
16+import { fileSecretsPlugin } from "./index";
17+
18+const INTEGRATION = IntegrationSlug.make("durable-secrets");
19+const CONNECTION = ConnectionName.make("main");
20+const TEMPLATE = AuthTemplateSlug.make("apiKey");
21+const CREDENTIAL = "secret-token";
22+
23+const connectionFixturePlugin = definePlugin(() => ({
24+ id: "connectionFixture" as const,
25+ storage: () => ({}),
26+ resolveTools: () => Effect.succeed({ tools: [] }),
27+ extension: (ctx) => ({
28+ registerIntegration: () =>
29+ ctx.core.integrations.register({
30+ slug: INTEGRATION,
31+ description: "Durable secrets test integration",
32+ config: {},
33+ }),
34+ resolveCredential: () =>
35+ ctx.connections.resolveValue({
36+ owner: "org",
37+ integration: INTEGRATION,
38+ name: CONNECTION,
39+ }),
40+ }),
41+}))();
42+
43+const plugins = () => [fileSecretsPlugin(), connectionFixturePlugin] as const;
44+
45+describe("file secrets data directory", () => {
46+ let workDir: string;
47+ let dataDir: string;
48+ let firstSandboxDataHome: string;
49+ let recreatedSandboxDataHome: string;
50+
51+ beforeEach(() => {
52+ workDir = mkdtempSync(join(tmpdir(), "executor-file-secrets-data-dir-"));
53+ dataDir = join(workDir, "executor-data");
54+ firstSandboxDataHome = join(workDir, "sandbox-a-xdg");
55+ recreatedSandboxDataHome = join(workDir, "sandbox-b-xdg");
56+ mkdirSync(dataDir, { recursive: true });
57+ mkdirSync(firstSandboxDataHome, { recursive: true });
58+ mkdirSync(recreatedSandboxDataHome, { recursive: true });
59+ vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
60+ });
61+
62+ afterEach(() => {
63+ vi.unstubAllEnvs();
64+ rmSync(workDir, { recursive: true, force: true });
65+ });
66+
67+ it.effect("keeps credentials when only EXECUTOR_DATA_DIR survives sandbox recreation", () =>
68+ Effect.gen(function* () {
69+ vi.stubEnv("XDG_DATA_HOME", firstSandboxDataHome);
70+ const firstAuthPath = yield* Effect.scoped(
71+ Effect.gen(function* () {
72+ const first = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() });
73+ yield* first.executor.connectionFixture.registerIntegration();
74+ const connection = yield* first.executor.connections.create({
75+ owner: "org",
76+ name: CONNECTION,
77+ integration: INTEGRATION,
78+ template: TEMPLATE,
79+ value: CREDENTIAL,
80+ });
81+
82+ expect(connection.provider).toBe(ProviderKey.make("file"));
83+ const authPath = first.executor.fileSecrets.filePath;
84+ expect(authPath).toBe(join(dataDir, "auth.json"));
85+ expect(existsSync(authPath)).toBe(true);
86+ expect(readFileSync(authPath, "utf8")).toContain(
87+ '"connection:org:durable-secrets:main:token": "secret-token"',
88+ );
89+ expect(existsSync(join(dataDir, "test.db"))).toBe(true);
90+ return authPath;
91+ }),
92+ );
93+
94+ vi.stubEnv("XDG_DATA_HOME", recreatedSandboxDataHome);
95+ yield* Effect.scoped(
96+ Effect.gen(function* () {
97+ const recreated = yield* makeTestWorkspaceHarness({ dataDir, plugins: plugins() });
98+ const connections = yield* recreated.executor.connections.list({
99+ integration: INTEGRATION,
100+ });
101+ expect(connections.map((connection) => String(connection.name))).toEqual(["main"]);
102+
103+ const resolved = yield* recreated.executor.connectionFixture.resolveCredential();
104+
105+ // Regression: auth.json follows XDG_DATA_HOME instead of EXECUTOR_DATA_DIR.
106+ // After the fix it must live with test.db under dataDir and survive this home swap.
107+ expect(
108+ resolved,
109+ `credential persisted in ${firstAuthPath} was not available after recreating the sandbox with ${dataDir}`,
110+ ).toBe(CREDENTIAL);
111+ }),
112+ );
113+ }),
114+ );
115+});
packages/plugins/file-secrets/src/index.test.ts+208−0
@@ -0,0 +1,208 @@
1+import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2+import {
3+ existsSync,
4+ mkdirSync,
5+ mkdtempSync,
6+ readFileSync,
7+ readdirSync,
8+ rmSync,
9+ statSync,
10+ writeFileSync,
11+} from "node:fs";
12+import { tmpdir } from "node:os";
13+import { dirname, join } from "node:path";
14+import { Effect, Predicate, Result } from "effect";
15+
16+import { ProviderKey } from "@executor-js/sdk";
17+import { makeTestWorkspaceHarness } from "@executor-js/sdk/testing";
18+
19+import { fileSecretsPlugin } from "./index";
20+
21+const FILE_PROVIDER = ProviderKey.make("file");
22+
23+const inspectPlugin = (plugin: ReturnType<typeof fileSecretsPlugin>) =>
24+ Effect.scoped(
25+ Effect.gen(function* () {
26+ const workspace = yield* makeTestWorkspaceHarness({ plugins: [plugin] as const });
27+ const items = yield* workspace.executor.providers.items(FILE_PROVIDER);
28+ return {
29+ filePath: workspace.executor.fileSecrets.filePath,
30+ itemIds: items.map((item) => String(item.id)),
31+ };
32+ }),
33+ );
34+
35+const writeAuthFile = (filePath: string, contents: string, mode = 0o600): void => {
36+ mkdirSync(dirname(filePath), { recursive: true });
37+ writeFileSync(filePath, contents, { mode });
38+};
39+
40+describe("file secrets auth location", () => {
41+ let workDir: string;
42+ let dataDir: string;
43+ let otherDataDir: string;
44+ let xdgDataHome: string;
45+ let overrideDir: string;
46+ let legacyFilePath: string;
47+
48+ beforeEach(() => {
49+ workDir = mkdtempSync(join(tmpdir(), "executor-file-secrets-location-"));
50+ dataDir = join(workDir, "data");
51+ otherDataDir = join(workDir, "other-data");
52+ xdgDataHome = join(workDir, "xdg");
53+ overrideDir = join(workDir, "override");
54+ legacyFilePath = join(xdgDataHome, "executor", "auth.json");
55+ vi.stubEnv("XDG_DATA_HOME", xdgDataHome);
56+ });
57+
58+ afterEach(() => {
59+ vi.unstubAllEnvs();
60+ rmSync(workDir, { recursive: true, force: true });
61+ });
62+
63+ it.effect("uses auth.json directly under EXECUTOR_DATA_DIR resolved at construction", () =>
64+ Effect.gen(function* () {
65+ vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
66+ const plugin = fileSecretsPlugin();
67+ vi.stubEnv("EXECUTOR_DATA_DIR", otherDataDir);
68+
69+ const inspected = yield* inspectPlugin(plugin);
70+
71+ expect(inspected.filePath).toBe(join(dataDir, "auth.json"));
72+ }),
73+ );
74+
75+ it.effect("keeps the XDG location when EXECUTOR_DATA_DIR is unset", () =>
76+ Effect.gen(function* () {
77+ vi.stubEnv("EXECUTOR_DATA_DIR", "");
78+
79+ const inspected = yield* inspectPlugin(fileSecretsPlugin());
80+
81+ expect(inspected.filePath).toBe(legacyFilePath);
82+ }),
83+ );
84+
85+ it.effect("gives an explicit directory precedence without migration", () =>
86+ Effect.gen(function* () {
87+ vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
88+ writeAuthFile(legacyFilePath, '{"legacy":"legacy-secret"}');
89+
90+ const inspected = yield* inspectPlugin(fileSecretsPlugin({ directory: overrideDir }));
91+
92+ expect(inspected.filePath).toBe(join(overrideDir, "auth.json"));
93+ expect(inspected.itemIds).toEqual([]);
94+ expect(existsSync(join(dataDir, "auth.json"))).toBe(false);
95+ expect(readFileSync(legacyFilePath, "utf8")).toBe('{"legacy":"legacy-secret"}');
96+ }),
97+ );
98+
99+ it.effect("copies a valid legacy XDG file once and preserves 0600 permissions", () =>
100+ Effect.gen(function* () {
101+ vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
102+ const legacyContents = '{"legacy-token":"legacy-secret"}';
103+ writeAuthFile(legacyFilePath, legacyContents, 0o644);
104+
105+ const inspected = yield* inspectPlugin(fileSecretsPlugin());
106+ const migratedFilePath = join(dataDir, "auth.json");
107+
108+ expect(inspected.filePath).toBe(migratedFilePath);
109+ expect(inspected.itemIds).toEqual(["legacy-token"]);
110+ expect(readFileSync(migratedFilePath, "utf8")).toContain('"legacy-token": "legacy-secret"');
111+ expect(statSync(migratedFilePath).mode & 0o777).toBe(0o600);
112+ expect(readFileSync(legacyFilePath, "utf8")).toBe(legacyContents);
113+ }),
114+ );
115+
116+ it.effect("uses an existing data-dir file without merging the legacy file", () =>
117+ Effect.gen(function* () {
118+ vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
119+ const activeFilePath = join(dataDir, "auth.json");
120+ const activeContents = '{"active-token":"active-secret"}';
121+ const legacyContents = '{"legacy-token":"legacy-secret"}';
122+ writeAuthFile(activeFilePath, activeContents);
123+ writeAuthFile(legacyFilePath, legacyContents);
124+
125+ const inspected = yield* inspectPlugin(fileSecretsPlugin());
126+
127+ expect(inspected.itemIds).toEqual(["active-token"]);
128+ expect(readFileSync(activeFilePath, "utf8")).toBe(activeContents);
129+ expect(readFileSync(legacyFilePath, "utf8")).toBe(legacyContents);
130+ }),
131+ );
132+
133+ it.effect("leaves the new store empty when the legacy file is corrupt", () =>
134+ Effect.scoped(
135+ Effect.gen(function* () {
136+ vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
137+ writeAuthFile(legacyFilePath, "not-json");
138+ const workspace = yield* makeTestWorkspaceHarness({
139+ plugins: [fileSecretsPlugin()] as const,
140+ });
141+
142+ const initial = yield* workspace.executor.providers.items(FILE_PROVIDER);
143+ expect(initial).toEqual([]);
144+ expect(existsSync(join(dataDir, "auth.json"))).toBe(false);
145+ expect(readFileSync(legacyFilePath, "utf8")).toBe("not-json");
146+
147+ writeAuthFile(legacyFilePath, '{"repaired-token":"repaired-secret"}');
148+ const afterRepair = yield* workspace.executor.providers.items(FILE_PROVIDER);
149+ expect(afterRepair).toEqual([]);
150+ expect(existsSync(join(dataDir, "auth.json"))).toBe(false);
151+ }),
152+ ),
153+ );
154+
155+ it.effect("retries migration after a legacy read I/O failure", () =>
156+ Effect.scoped(
157+ Effect.gen(function* () {
158+ vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
159+ mkdirSync(legacyFilePath, { recursive: true });
160+ const workspace = yield* makeTestWorkspaceHarness({
161+ plugins: [fileSecretsPlugin()] as const,
162+ });
163+
164+ const failed = yield* Effect.result(workspace.executor.providers.items(FILE_PROVIDER));
165+ expect(Result.isFailure(failed)).toBe(true);
166+ if (!Result.isFailure(failed)) return;
167+ expect(Predicate.isTagged("StorageError")(failed.failure)).toBe(true);
168+
169+ rmSync(legacyFilePath, { recursive: true, force: true });
170+ writeAuthFile(legacyFilePath, '{"recovered-token":"recovered-secret"}');
171+
172+ const recovered = yield* workspace.executor.providers.items(FILE_PROVIDER);
173+ expect(recovered.map((item) => String(item.id))).toEqual(["recovered-token"]);
174+ expect(readFileSync(join(dataDir, "auth.json"), "utf8")).toContain(
175+ '"recovered-token": "recovered-secret"',
176+ );
177+ }),
178+ ),
179+ );
180+
181+ it.effect("shares one migration across concurrent first provider operations", () =>
182+ Effect.scoped(
183+ Effect.gen(function* () {
184+ vi.stubEnv("EXECUTOR_DATA_DIR", dataDir);
185+ const legacyContents = '{"legacy-token":"legacy-secret"}';
186+ writeAuthFile(legacyFilePath, legacyContents);
187+ const workspace = yield* makeTestWorkspaceHarness({
188+ plugins: [fileSecretsPlugin()] as const,
189+ });
190+
191+ const results = yield* Effect.all(
192+ [
193+ workspace.executor.providers.items(FILE_PROVIDER),
194+ workspace.executor.providers.items(FILE_PROVIDER),
195+ ],
196+ { concurrency: "unbounded" },
197+ );
198+
199+ expect(results.map((items) => items.map((item) => String(item.id)))).toEqual([
200+ ["legacy-token"],
201+ ["legacy-token"],
202+ ]);
203+ expect(readdirSync(dataDir)).toEqual(["auth.json"]);
204+ expect(readFileSync(legacyFilePath, "utf8")).toBe(legacyContents);
205+ }),
206+ ),
207+ );
208+});
packages/plugins/file-secrets/src/index.ts+170−57
@@ -1,6 +1,7 @@
1−import { Effect, Schema } from "effect";
1+import { randomUUID } from "node:crypto";
22 import * as fs from "node:fs";
33 import * as path from "node:path";
4+import { Deferred, Effect, Exit, Schema } from "effect";
45
56 import {
67 definePlugin,
@@ -11,10 +12,11 @@ import { · 4 unmodified lines
1112 } from "@executor-js/sdk";
1213
1314 // ---------------------------------------------------------------------------
14−// XDG data dir resolution
15+// Auth file location
1516 // ---------------------------------------------------------------------------
1617
1718 const APP_NAME = "executor";
19+const AUTH_FILE_NAME = "auth.json";
1820
1921 export const xdgDataHome = (): string => {
2022 if (process.env.XDG_DATA_HOME?.trim()) return process.env.XDG_DATA_HOME.trim();
@@ -28,9 +30,34 @@ export const xdgDataHome = (): string => { · 7 unmodified lines
2830 return path.join(process.env.HOME || "~", ".local", "share");
2931 };
3032
31−const authDir = (overrideDir?: string): string => overrideDir ?? path.join(xdgDataHome(), APP_NAME);
33+interface AuthLocation {
34+ readonly filePath: string;
35+ readonly legacyFilePath: string | null;
36+}
37+
38+const legacyAuthFilePath = (): string => path.join(xdgDataHome(), APP_NAME, AUTH_FILE_NAME);
39+
40+const resolveAuthLocation = (config: FileSecretsPluginConfig | undefined): AuthLocation => {
41+ if (config?.directory !== undefined) {
42+ return {
43+ filePath: path.join(config.directory, AUTH_FILE_NAME),
44+ legacyFilePath: null,
45+ };
46+ }
3247
33−const authFilePath = (overrideDir?: string): string => path.join(authDir(overrideDir), "auth.json");
48+ const dataDir = process.env.EXECUTOR_DATA_DIR?.trim();
49+ if (dataDir) {
50+ return {
51+ filePath: path.join(dataDir, AUTH_FILE_NAME),
52+ legacyFilePath: legacyAuthFilePath(),
53+ };
54+ }
55+
56+ return {
57+ filePath: legacyAuthFilePath(),
58+ legacyFilePath: null,
59+ };
60+};
3461
3562 // ---------------------------------------------------------------------------
3663 // Schema for the auth file
@@ -45,13 +72,29 @@ const authFilePath = (overrideDir?: string): string => path.join(authDir(overrid · 8 unmodified lines
4572 const FlatAuthFile = Schema.Record(Schema.String, Schema.String);
4673 const decodeFlatAuthFile = Schema.decodeUnknownEffect(Schema.fromJsonString(FlatAuthFile));
4774
75+class AuthFileDecodeError extends Schema.TaggedErrorClass<AuthFileDecodeError>()(
76+ "AuthFileDecodeError",
77+ {
78+ filePath: Schema.String,
79+ cause: Schema.Defect,
80+ },
81+ {
82+ description:
83+ "The auth file was readable but its contents were not a valid flat credential map. The caller must surface the failure or explicitly treat malformed legacy data as non-migratable.",
84+ },
85+) {
86+ override get message(): string {
87+ return `Failed to parse auth file: ${this.filePath}`;
88+ }
89+}
90+
4891 // ---------------------------------------------------------------------------
4992 // File I/O with restricted permissions
5093 //
51−// These helpers keep real I/O and decode failures in the Effect error
52−// channel as `StorageError`. Missing files are still treated as an empty
53−// auth file, but malformed JSON, schema decode failures, and permission
54−// errors no longer collapse into "empty file".
94+// These helpers keep real I/O and decode failures distinct internally, then
95+// project both to `StorageError` at the provider boundary. Missing files are
96+// still treated as an empty auth file; malformed content and permission errors
97+// do not collapse into "empty file" during ordinary provider reads.
5598 // ---------------------------------------------------------------------------
5699
57100 const isFileNotFoundCause = (cause: unknown): cause is NodeJS.ErrnoException =>
@@ -62,7 +105,9 @@ const toStorageError = · 4 unmodified lines
62105 (cause: unknown): StorageError =>
63106 new StorageError({ message, cause });
64107
65−const readAll = (filePath: string): Effect.Effect<Record<string, string>, StorageError> => {
108+const readAuthFile = (
109+ filePath: string,
110+): Effect.Effect<Record<string, string>, StorageError | AuthFileDecodeError> => {
66111 if (!fs.existsSync(filePath)) return Effect.succeed({});
67112 return Effect.try({
68113 try: () => fs.readFileSync(filePath, "utf-8"),
@@ -76,18 +121,25 @@ const readAll = (filePath: string): Effect.Effect<Record<string, string>, Storag · 7 unmodified lines
76121 raw === ""
77122 ? Effect.succeed<Record<string, string>>({})
78123 : decodeFlatAuthFile(raw).pipe(
79− Effect.mapError(toStorageError("Failed to parse auth file")),
124+ Effect.mapError((cause) => new AuthFileDecodeError({ filePath, cause })),
80125 ),
81126 ),
82127 );
83128 };
84129
130+const readAll = (filePath: string): Effect.Effect<Record<string, string>, StorageError> =>
131+ readAuthFile(filePath).pipe(
132+ Effect.catchTag("AuthFileDecodeError", (cause) =>
133+ Effect.fail(new StorageError({ message: cause.message, cause })),
134+ ),
135+ );
136+
85137 const writeAll = (
86138 filePath: string,
87139 secrets: Record<string, string>,
88140 ): Effect.Effect<void, StorageError> => {
89141 const dir = path.dirname(filePath);
90− const tmp = `${filePath}.tmp`;
142+ const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
91143 return Effect.gen(function* () {
92144 if (!fs.existsSync(dir)) {
93145 yield* Effect.try({
@@ -96,7 +148,12 @@ const writeAll = ( · 2 unmodified lines
96148 });
97149 }
98150 yield* Effect.try({
99− try: () => fs.writeFileSync(tmp, JSON.stringify(secrets, null, 2), { mode: 0o600 }),
151+ try: () => {
152+ fs.writeFileSync(tmp, JSON.stringify(secrets, null, 2), { mode: 0o600 });
153+ // `mode` only applies when the file is created; chmod afterwards covers
154+ // the case where a temporary file with broader permissions already exists.
155+ fs.chmodSync(tmp, 0o600);
156+ },
100157 catch: toStorageError("Failed to write temporary auth file"),
101158 });
102159 yield* Effect.try({
@@ -106,12 +163,28 @@ const writeAll = ( · 3 unmodified lines
106163 });
107164 };
108165
166+const migrateLegacyAuthFile = ({
167+ filePath,
168+ legacyFilePath,
169+}: AuthLocation): Effect.Effect<void, StorageError> => {
170+ if (legacyFilePath === null || fs.existsSync(filePath) || !fs.existsSync(legacyFilePath)) {
171+ return Effect.void;
172+ }
173+
174+ return readAuthFile(legacyFilePath).pipe(
175+ Effect.flatMap((secrets) => writeAll(filePath, secrets)),
176+ // Malformed legacy contents are deliberately skipped. Genuine read I/O
177+ // failures stay visible and leave migration eligible for a later retry.
178+ Effect.catchTag("AuthFileDecodeError", () => Effect.void),
179+ );
180+};
181+
109182 // ---------------------------------------------------------------------------
110183 // Plugin config
111184 // ---------------------------------------------------------------------------
112185
113186 export interface FileSecretsPluginConfig {
114− /** Override the directory for auth.json (default: XDG data dir) */
187+ /** Override the directory for auth.json (default: EXECUTOR_DATA_DIR, then XDG data dir) */
115188 readonly directory?: string;
116189 }
117190
@@ -119,8 +192,8 @@ export interface FileSecretsPluginConfig { · 1 unmodified line
119192 // Plugin extension — public API on executor.fileSecrets
120193 // ---------------------------------------------------------------------------
121194
122−const makeFileSecretsExtension = (options: FileSecretsPluginConfig | undefined) => ({
123− filePath: resolveFilePath(options),
195+const makeFileSecretsExtension = (filePath: string) => ({
196+ filePath,
124197 });
125198
126199 export type FileSecretsExtension = ReturnType<typeof makeFileSecretsExtension>;
@@ -135,54 +208,94 @@ export type FileSecretsExtension = ReturnType<typeof makeFileSecretsExtension>; · 8 unmodified lines
135208
136209 const FILE_PROVIDER_KEY = ProviderKey.make("file");
137210
138−const makeFileProvider = (filePath: string): CredentialProvider => ({
139− key: FILE_PROVIDER_KEY,
140− writable: true,
141−
142− get: (id: ProviderItemId) => readAll(filePath).pipe(Effect.map((data) => data[id] ?? null)),
143−
144− has: (id: ProviderItemId) => readAll(filePath).pipe(Effect.map((data) => id in data)),
145−
146− set: (id: ProviderItemId, value: string) =>
147− Effect.gen(function* () {
148− const data = yield* readAll(filePath);
149− data[id] = value;
150− yield* writeAll(filePath, data);
151− }),
152−
153− delete: (id: ProviderItemId) =>
154− Effect.gen(function* () {
155− const data = yield* readAll(filePath);
156− if (id in data) {
157− delete data[id];
158− yield* writeAll(filePath, data);
159− }
160− }),
161−
162− list: () =>
163− readAll(filePath).pipe(
164− Effect.map((data) => Object.keys(data).map((k) => ({ id: ProviderItemId.make(k), name: k }))),
165− ),
166−});
211+const makeFileProvider = (location: AuthLocation): CredentialProvider => {
212+ let migrationComplete = false;
213+ let migrationInFlight: Deferred.Deferred<void, StorageError> | null = null;
214+ const ensureMigration = Effect.suspend(() => {
215+ if (migrationComplete) return Effect.void;
216+ if (migrationInFlight !== null) return Deferred.await(migrationInFlight);
217+
218+ // Installing the latch inside Effect.suspend is synchronous, so a peer
219+ // fiber can only observe either no attempt or this fully initialized one.
220+ const latch = Deferred.makeUnsafe<void, StorageError>();
221+ migrationInFlight = latch;
222+ return migrateLegacyAuthFile(location).pipe(
223+ Effect.onExit((exit) =>
224+ Effect.gen(function* () {
225+ if (Exit.isSuccess(exit)) migrationComplete = true;
226+ yield* Deferred.done(latch, exit);
227+ migrationInFlight = null;
228+ }),
229+ ),
230+ );
231+ });
232+
233+ return {
234+ key: FILE_PROVIDER_KEY,
235+ writable: true,
236+
237+ get: (id: ProviderItemId) =>
238+ ensureMigration.pipe(
239+ Effect.andThen(Effect.suspend(() => readAll(location.filePath))),
240+ Effect.map((data) => data[id] ?? null),
241+ ),
242+
243+ has: (id: ProviderItemId) =>
244+ ensureMigration.pipe(
245+ Effect.andThen(Effect.suspend(() => readAll(location.filePath))),
246+ Effect.map((data) => id in data),
247+ ),
248+
249+ set: (id: ProviderItemId, value: string) =>
250+ ensureMigration.pipe(
251+ Effect.andThen(
252+ Effect.gen(function* () {
253+ const data = yield* readAll(location.filePath);
254+ data[id] = value;
255+ yield* writeAll(location.filePath, data);
256+ }),
257+ ),
258+ ),
259+
260+ delete: (id: ProviderItemId) =>
261+ ensureMigration.pipe(
262+ Effect.andThen(
263+ Effect.gen(function* () {
264+ const data = yield* readAll(location.filePath);
265+ if (id in data) {
266+ delete data[id];
267+ yield* writeAll(location.filePath, data);
268+ }
269+ }),
270+ ),
271+ ),
272+
273+ list: () =>
274+ ensureMigration.pipe(
275+ Effect.andThen(Effect.suspend(() => readAll(location.filePath))),
276+ Effect.map((data) =>
277+ Object.keys(data).map((k) => ({ id: ProviderItemId.make(k), name: k })),
278+ ),
279+ ),
280+ };
281+};
167282
168283 // ---------------------------------------------------------------------------
169284 // Plugin definition
170285 //
171−// Compute the file path identically in `extension` (for `filePath`) and
172−// `credentialProviders` (for the provider's read/write). Both are called once
173−// per createExecutor.
286+// Resolve the path once when the configured plugin is constructed. The provider
287+// performs the one-time migration before its first read or write.
174288 // ---------------------------------------------------------------------------
175289
176−const resolveFilePath = (config: FileSecretsPluginConfig | undefined): string =>
177− authFilePath(config?.directory);
290+export const fileSecretsPlugin = definePlugin((options?: FileSecretsPluginConfig) => {
291+ const location = resolveAuthLocation(options);
178292
179−export const fileSecretsPlugin = definePlugin((options?: FileSecretsPluginConfig) => ({
180− id: "fileSecrets" as const,
181− storage: () => ({}),
293+ return {
294+ id: "fileSecrets" as const,
295+ storage: () => ({}),
182296
183− extension: () => makeFileSecretsExtension(options),
297+ extension: () => makeFileSecretsExtension(location.filePath),
184298
185− credentialProviders: (): readonly CredentialProvider[] => [
186− makeFileProvider(resolveFilePath(options)),
187− ],
188−}));
299+ credentialProviders: (): readonly CredentialProvider[] => [makeFileProvider(location)],
300+ };
301+});