Skip to content

Commit 39c2bfd

Browse files
authored
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
1 parent a86cc4e commit 39c2bfd

4 files changed

Lines changed: 498 additions & 57 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)