Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
c5b28a4
feat(windows): port desktop sync and remote clients from #999
nsxdavid Aug 1, 2026
5bcd2c3
refactor(windows): isolate desktop IPC from packaging metadata
arul28 Aug 1, 2026
fdd79e0
fix(windows): bind shared credentials to DPAPI
arul28 Aug 1, 2026
1f6f0c4
fix(windows): harden DPAPI credential migration
arul28 Aug 2, 2026
bbac621
fix(windows): align App Control startup shell syntax
arul28 Aug 2, 2026
d8472ed
test(windows): derive Git Bash cd expectations from the fixture path
arul28 Aug 2, 2026
bc1a4b9
fix(windows): source Settings and Account machine copy from the ident…
arul28 Aug 2, 2026
d96f1de
ci(windows): run App Control launch-command contracts on Windows
arul28 Aug 2, 2026
fce6a18
fix(windows): stop naming the local machine "This Mac" in settings
arul28 Aug 2, 2026
ba9033b
fix(windows): stop failing DPAPI when PowerShell is slow to start
arul28 Aug 3, 2026
f7575bf
fix(windows): cache DPAPI key material instead of respawning PowerShell
arul28 Aug 3, 2026
816f29d
fix(windows): resolve provider CLIs the way Windows actually resolves…
arul28 Aug 3, 2026
e4c6660
fix(windows): close the Cursor lane guard for backslash paths
arul28 Aug 3, 2026
2ec0719
fix(windows): drop the drive prefix from Cursor project slugs
arul28 Aug 3, 2026
3d5b99c
fix(windows): show Cursor's PowerShell installer in onboarding
arul28 Aug 3, 2026
6ac6785
fix(windows): find standalone Codex installs on Windows
arul28 Aug 3, 2026
48172f8
fix(windows): assert the real Codex task launch shape on Windows
arul28 Aug 3, 2026
dae17de
fix(windows): match Droid's real per-project session directory name
arul28 Aug 3, 2026
185a975
fix(windows): reap the whole Droid worker tree on dispose
arul28 Aug 3, 2026
2e7f9e7
fix(windows): give Windows users a runnable Droid install and API-key…
arul28 Aug 3, 2026
94f77d4
fix(windows): stop leaking OpenCode servers ADE can never reap
arul28 Aug 3, 2026
d4876bc
fix(windows): stop telling Windows users to install OpenCode with brew
arul28 Aug 3, 2026
7c4ad62
fix(windows): launch and reap Claude Code the way Windows requires
arul28 Aug 3, 2026
679e1a1
fix(windows): make OpenCode session import work off a .cmd shim
arul28 Aug 3, 2026
f7c7c87
fix(windows): honour CODEX_HOME when discovering Codex prompts
arul28 Aug 3, 2026
6fcd0d5
fix(windows): stop rendering an unreadable session as a sign-out
arul28 Aug 3, 2026
fec3780
fix(windows): keep the This-Machine card alive without the background…
arul28 Aug 3, 2026
cfc76c7
fix(windows): stop sending the Codex planner prompt through cmd.exe argv
arul28 Aug 3, 2026
89681d2
fix(windows): keep the Codex work-tab prompt off the cmd.exe command …
arul28 Aug 3, 2026
d632811
fix(windows): match Cursor's own project-slug rule byte for byte
arul28 Aug 3, 2026
5b9f80d
fix(windows): trust only proven executables in the Droid presence branch
arul28 Aug 3, 2026
c6608ac
fix(windows): keep the title-bar controls clear of the caption buttons
arul28 Aug 3, 2026
ef9cb71
fix(windows): stop leaking Cursor SDK worker state and process trees
arul28 Aug 3, 2026
44ceb56
fix(windows): forward hook arguments through the Cursor gate wrapper
arul28 Aug 3, 2026
20caee5
fix(windows): find standalone Codex installs and invert Cursor slugs …
arul28 Aug 3, 2026
29adbe1
perf(windows): stop the sidebar hover from resizing the pane groups
arul28 Aug 3, 2026
ab551c6
fix(windows): let this computer be renamed from the account list
arul28 Aug 3, 2026
74d5adf
fix(windows): stop probing for CLIs by launching them
arul28 Aug 3, 2026
677c1cb
fix(windows): match Claude's project-slug rule in the usage ledger
arul28 Aug 3, 2026
670d77a
fix(windows): choose shell quoting by the shell that receives it
arul28 Aug 3, 2026
c2794ff
fix(windows): launch discovered Droid resumes as argv, not a bash script
arul28 Aug 3, 2026
60d3188
fix(windows): escape the drive colon in Claude and Droid project lookups
arul28 Aug 3, 2026
b134d2e
fix(windows): stop doubling % in cmd command-line arguments
arul28 Aug 3, 2026
c80c410
Merge branch 'codex/worker-win-agents' into codex/integration-windows
arul28 Aug 3, 2026
c9fcc37
Merge branch 'codex/worker-win-pty' into codex/integration-windows
arul28 Aug 3, 2026
d40c77b
Merge branch 'codex/worker-prov-droid' into codex/integration-windows
arul28 Aug 3, 2026
29ac788
Merge branch 'codex/worker-prov-cursor' into codex/integration-windows
arul28 Aug 3, 2026
c6e44b5
Merge branch 'codex/worker-prov-codex' into codex/integration-windows
arul28 Aug 3, 2026
128f866
Merge branch 'codex/worker-prov-opencode' into codex/integration-windows
arul28 Aug 3, 2026
bef86e5
Merge branch 'codex/worker-win-slug' into codex/integration-windows
arul28 Aug 3, 2026
ae3fa86
Merge branch 'codex/worker-win-connections' into codex/integration-wi…
arul28 Aug 3, 2026
4e71570
Merge branch 'codex/worker-win-titlebar' into codex/integration-windows
arul28 Aug 3, 2026
4748df7
Merge branch 'codex/worker-win-perf' into codex/integration-windows
arul28 Aug 3, 2026
2b4bfff
refactor(windows): collapse the four cross-lane duplications
arul28 Aug 3, 2026
62a6dca
test(windows): give the OpenCode import fixture a launchable Windows …
arul28 Aug 3, 2026
e501bc4
perf(windows): skip the cmd.exe wrapper in whichCommand
arulsharma Aug 3, 2026
23c012b
fix(windows): stop doubling percent signs in the copyable cmd command
arulsharma Aug 3, 2026
02de138
perf(windows): make the pushing tab rail cheap instead of making it o…
arul28 Aug 3, 2026
09c4866
fix(windows): hide the Cursor provider on Windows on ARM
arulsharma Aug 3, 2026
03e504a
Merge branch 'codex/worker-win-sidebar' into codex/integration-windows
arul28 Aug 3, 2026
c661541
Merge branch 'codex/worker-win-arm' into codex/integration-windows
arul28 Aug 3, 2026
72a48bd
Merge branch 'codex/windows-cli-shells-providers' into codex/windows-…
arulsharma Aug 3, 2026
f035486
Merge branch 'codex/windows-cli-shells-providers' into codex/windows-…
arul28 Aug 3, 2026
478d2ce
fix(windows): stop Droid opening a visible terminal window per session
arul28 Aug 3, 2026
1ebd879
fix(windows): stop reporting a signed-out Droid as authenticated
arul28 Aug 3, 2026
130e3f1
Merge branch 'codex/windows-cli-shells-providers' into codex/windows-…
arul28 Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ jobs:
- name: Test Windows path, spawn, window, and update contracts
run: >-
cd apps/desktop && npx vitest run
src/main/services/appControl/appControlLaunchCommand.test.ts
src/main/services/appControl/appControlService.test.ts
src/main/services/shared/processExecution.test.ts
src/main/services/updates/autoUpdateService.test.ts
Expand Down
11 changes: 11 additions & 0 deletions apps/ade-cli/src/cursorCloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";

import {
CURSOR_WINDOWS_ARM_BLOCKER,
isCursorProviderSupported,
} from "../../desktop/src/shared/providerPlatformSupport";

type CursorSdk = typeof import("@cursor/sdk");

const requireFromRuntime = createRequire(
Expand All @@ -42,6 +47,12 @@ function isCursorSdkResolutionError(error: unknown): boolean {
}

async function getSdk(): Promise<CursorSdk> {
// @cursor/sdk publishes no win32-arm64 runtime, so this import can never
// succeed on Windows on ARM. Fail with the reason instead of a bare
// ERR_MODULE_NOT_FOUND. See desktop/src/shared/providerPlatformSupport.ts.
if (!isCursorProviderSupported(process.platform, process.arch)) {
throw new Error(CURSOR_WINDOWS_ARM_BLOCKER);
}
if (!sdkModulePromise) {
sdkModulePromise = import("@cursor/sdk")
.catch((error) => {
Expand Down
127 changes: 121 additions & 6 deletions apps/ade-cli/src/services/credentials/credentialStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
KeytarCredentialStore,
createDefaultCredentialStore,
} from "./credentialStore";
import {
readOrCreateWindowsDpapiMaterial,
readOrCreateWindowsDpapiMaterialAsync,
resolveWindowsDpapiPowerShellPath,
} from "./windowsDpapiMaterial";

let tempDir = "";

Expand All @@ -22,6 +27,93 @@ afterEach(() => {
});

describe("EncryptedFileCredentialStore", () => {
it.runIf(process.platform === "win32")(
"resolves Windows DPAPI PowerShell through kernel SystemRoot despite poisoned environment paths",
() => {
const previousSystemRoot = process.env.SystemRoot;
const previousWinDir = process.env.windir;
process.env.SystemRoot = path.join(tempDir, "attacker-system-root");
process.env.windir = path.join(tempDir, "attacker-windir");
try {
const resolved = resolveWindowsDpapiPowerShellPath();
expect(path.win32.isAbsolute(resolved)).toBe(true);
expect(resolved.toLowerCase()).toMatch(
/\\system32\\windowspowershell\\v1\.0\\powershell\.exe$/,
);
expect(resolved.toLowerCase()).not.toContain(tempDir.toLowerCase());
} finally {
if (previousSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = previousSystemRoot;
if (previousWinDir === undefined) delete process.env.windir;
else process.env.windir = previousWinDir;
}
},
);

it.runIf(process.platform === "win32")(
"binds headless credential encryption to the current Windows account with DPAPI",
async () => {
const previousNodeEnv = process.env.NODE_ENV;
const previousVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
try {
const syncDir = path.join(tempDir, "sync-dpapi");
const syncMaterial = readOrCreateWindowsDpapiMaterial(syncDir);
const protectedKeyPath = path.join(syncDir, ".credential-key.dpapi");
const protectedKey = fs.readFileSync(protectedKeyPath, "utf8");

expect(syncMaterial).toHaveLength(32);
expect(protectedKey).toContain("ADE_WINDOWS_DPAPI_KEY_V1");
expect(protectedKey).not.toContain(syncMaterial.toString("base64"));
expect(readOrCreateWindowsDpapiMaterial(syncDir)).toEqual(syncMaterial);

const store = new EncryptedFileCredentialStore({ secretsDir: syncDir });
store.setSync("account.session.v1", "windows-account-session");
const credentialsPath = path.join(syncDir, "credentials.json.enc");
const machineKeyPath = path.join(syncDir, ".machine-key");
expect(fs.readFileSync(credentialsPath, "utf8"))
.not.toContain("windows-account-session");

const explicitPathReader = new EncryptedFileCredentialStore({
credentialsPath,
machineKeyPath,
});
expect(explicitPathReader.getSync("account.session.v1"))
.toBe("windows-account-session");
await expect(explicitPathReader.get("account.session.v1"))
.resolves.toBe("windows-account-session");

const customCredentialDir = path.join(tempDir, "custom-credential-dir");
const customKeyDir = path.join(tempDir, "custom-key-dir");
const customMachineKeyPath = path.join(customKeyDir, ".machine-key");
const customStore = new EncryptedFileCredentialStore({
secretsDir: customCredentialDir,
machineKeyPath: customMachineKeyPath,
});
customStore.setSync("account.session.v1", "custom-key-location");
expect(fs.existsSync(path.join(customKeyDir, ".credential-key.dpapi"))).toBe(true);
expect(fs.existsSync(path.join(customCredentialDir, ".credential-key.dpapi"))).toBe(false);
expect(new EncryptedFileCredentialStore({
credentialsPath: path.join(customCredentialDir, "credentials.json.enc"),
machineKeyPath: customMachineKeyPath,
}).getSync("account.session.v1")).toBe("custom-key-location");

const asyncDir = path.join(tempDir, "async-dpapi");
const asyncMaterial = await readOrCreateWindowsDpapiMaterialAsync(asyncDir);
expect(asyncMaterial).toHaveLength(32);
expect(fs.readFileSync(path.join(asyncDir, ".credential-key.dpapi"), "utf8"))
.not.toContain(asyncMaterial.toString("base64"));
} finally {
if (previousNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = previousNodeEnv;
if (previousVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = previousVitest;
}
},
20_000,
);

it("persists credentials encrypted on disk", async () => {
const store = new EncryptedFileCredentialStore({ secretsDir: tempDir });

Expand Down Expand Up @@ -195,6 +287,31 @@ new EncryptedFileCredentialStore({ secretsDir }).setSync(key, value);
expect(unbound.getSync("linear.token.v1")).toBeNull();
});

it("atomically binds legacy Windows ciphertext on the first asynchronous credential read", async () => {
const legacyStore = new EncryptedFileCredentialStore({
secretsDir: tempDir,
keyMaterialProvider: () => null,
});
legacyStore.setSync("account.session.v1", "legacy-async-windows-session");
const credentialsPath = path.join(tempDir, "credentials.json.enc");
const legacyCiphertext = fs.readFileSync(credentialsPath, "utf8");
const osMaterial = Buffer.from("windows-async-account-bound-material");

const upgraded = new EncryptedFileCredentialStore({
secretsDir: tempDir,
keyMaterialProvider: () => {
throw new Error("async migration must not use synchronous key access");
},
keyMaterialProviderAsync: async () => osMaterial,
});
await expect(upgraded.get("account.session.v1")).resolves.toBe("legacy-async-windows-session");
expect(fs.readFileSync(credentialsPath, "utf8")).not.toBe(legacyCiphertext);
expect(new EncryptedFileCredentialStore({
secretsDir: tempDir,
keyMaterialProvider: () => null,
}).getSync("account.session.v1")).toBeNull();
});

it("uses the asynchronous key-material path for asynchronous reads", async () => {
const osMaterial = Buffer.from("test-os-material");
new EncryptedFileCredentialStore({
Expand Down Expand Up @@ -251,23 +368,21 @@ new EncryptedFileCredentialStore({ secretsDir }).setSync(key, value);
expect(asyncProvider).not.toHaveBeenCalled();
});

it("can read legacy machine-key ciphertext before rewriting with OS-bound key material", async () => {
it("atomically binds legacy Windows ciphertext on the first synchronous credential read", () => {
const legacy = new EncryptedFileCredentialStore({
secretsDir: tempDir,
keyMaterialProvider: () => null,
});
legacy.setSync("agent.token", "legacy_secret");
const credentialPath = path.join(tempDir, "credentials.json.enc");
const legacyCiphertext = fs.readFileSync(credentialPath, "utf8");

const upgraded = new EncryptedFileCredentialStore({
secretsDir: tempDir,
keyMaterialProvider: () => Buffer.from("test-os-material"),
});
expect(upgraded.getSync("agent.token")).toBe("legacy_secret");
expect(legacy.getSync("agent.token")).toBe("legacy_secret");

upgraded.setSync("agent.token", "bound_secret");

expect(upgraded.getSync("agent.token")).toBe("bound_secret");
expect(fs.readFileSync(credentialPath, "utf8")).not.toBe(legacyCiphertext);
expect(legacy.getSync("agent.token")).toBeNull();
});

Expand Down
105 changes: 91 additions & 14 deletions apps/ade-cli/src/services/credentials/credentialStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { execFile, execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { resolveMachineAdeLayout } from "../projects/machineLayout";
import {
readOrCreateWindowsDpapiMaterial,
readOrCreateWindowsDpapiMaterialAsync,
} from "./windowsDpapiMaterial";

export interface CredentialStore {
get(key: string): Promise<string | null>;
Expand Down Expand Up @@ -57,6 +61,10 @@ const CREDENTIAL_CHANGE_POLL_INTERVAL_MS = 250;
const MACOS_KEYCHAIN_READ_TIMEOUT_MS = 2_000;
const MACOS_KEYCHAIN_NEGATIVE_CACHE_MS = 30_000;
let cachedDefaultOsBoundKeyMaterial: Buffer | null = null;
// Keyed by resolved secrets directory: DPAPI material is protected per
// directory, so unlike the single macOS keychain item these cannot share a slot.
const windowsDpapiMaterialCache = new Map<string, Buffer>();
const windowsDpapiReadInFlight = new Map<string, Promise<Buffer | null>>();
let defaultOsBoundKeyMaterialReadInFlight: Promise<Buffer | null> | null = null;
let lastMissingDefaultOsBoundKeyMaterialAt = 0;

Expand Down Expand Up @@ -599,11 +607,28 @@ async function readMacKeychainMaterialAsync(): Promise<Buffer | null> {
});
}

function readDefaultOsBoundKeyMaterial(): Buffer | null {
function readDefaultOsBoundKeyMaterial(secretsDir: string): Buffer | null {
const envMaterial = readCredentialPassphraseFromEnv();
if (envMaterial) return envMaterial;
if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null;
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null;
if (process.platform === "win32") {
// Windows re-spawned `powershell.exe` on every credential read, where macOS
// spawns `security` once and caches. That is a far worse trade than it
// looks: PowerShell 5.1 pays CLR load, System.Security from disk, and
// Defender's on-access scan each time.
//
// The cache must be keyed by directory, unlike macOS. Keychain material is
// one global item, but DPAPI material is protected per secrets directory
// (`<secretsDir>/.credential-key.dpapi`), so a single shared slot would
// hand one store another store's key.
const key = path.resolve(secretsDir);
const cached = windowsDpapiMaterialCache.get(key);
if (cached) return cached;
const material = readOrCreateWindowsDpapiMaterial(secretsDir);
if (material) windowsDpapiMaterialCache.set(key, material);
return material;
}
if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial;
const material = readOrCreateMacKeychainMaterial();
if (material) {
Expand All @@ -613,11 +638,36 @@ function readDefaultOsBoundKeyMaterial(): Buffer | null {
return material;
}

async function readDefaultOsBoundKeyMaterialAsync(): Promise<Buffer | null> {
async function readDefaultOsBoundKeyMaterialAsync(secretsDir: string): Promise<Buffer | null> {
const envMaterial = readCredentialPassphraseFromEnv();
if (envMaterial) return envMaterial;
if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null;
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null;
if (process.platform === "win32") {
const key = path.resolve(secretsDir);
const cached = windowsDpapiMaterialCache.get(key);
if (cached) return cached;
// In-flight dedup matters more here than it ever did on macOS: without it,
// concurrent credential reads each spawn their own PowerShell, and that
// contention is what makes a cold start slow enough to hit the timeout.
// No negative cache -- a locked keychain is a durable state worth backing
// off from, but a DPAPI failure is usually a transient timeout, and
// suppressing retries would make one slow cold start look permanent.
const pending = windowsDpapiReadInFlight.get(key);
if (pending) return await pending;
const inFlight = readOrCreateWindowsDpapiMaterialAsync(secretsDir).then((material) => {
if (material) windowsDpapiMaterialCache.set(key, material);
return material;
});
windowsDpapiReadInFlight.set(key, inFlight);
try {
return await inFlight;
} finally {
if (windowsDpapiReadInFlight.get(key) === inFlight) {
windowsDpapiReadInFlight.delete(key);
}
}
}
if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial;
if (
lastMissingDefaultOsBoundKeyMaterialAt > 0
Expand Down Expand Up @@ -675,12 +725,14 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore {
const secretsDir = args.secretsDir ?? resolveMachineAdeLayout().secretsDir;
this.credentialsPath = args.credentialsPath ?? path.join(secretsDir, DEFAULT_CREDENTIALS_FILE);
this.machineKeyPath = args.machineKeyPath ?? path.join(secretsDir, DEFAULT_MACHINE_KEY_FILE);
const osBindingDir = path.dirname(this.machineKeyPath);
this.lockPath = args.lockPath ?? defaultLockPath(this.credentialsPath);
this.keyMaterialProvider = args.keyMaterialProvider ?? readDefaultOsBoundKeyMaterial;
this.keyMaterialProvider = args.keyMaterialProvider
?? (() => readDefaultOsBoundKeyMaterial(osBindingDir));
this.keyMaterialProviderAsync = args.keyMaterialProviderAsync
?? (args.keyMaterialProvider
? async () => args.keyMaterialProvider?.() ?? null
: readDefaultOsBoundKeyMaterialAsync);
: () => readDefaultOsBoundKeyMaterialAsync(osBindingDir));
this.credentialChangePollIntervalMs = args.credentialChangePollIntervalMs === undefined
? CREDENTIAL_CHANGE_POLL_INTERVAL_MS
: args.credentialChangePollIntervalMs;
Expand All @@ -707,7 +759,9 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore {

getSync(key: string): string | null {
const normalized = normalizeKey(key);
return this.readAll({ allowRewrite: false })[normalized] ?? null;
return this.withLock(
() => this.readAll({ allowRewrite: false, migrateLegacy: true })[normalized] ?? null,
);
}

getLastReadState(): CredentialStoreReadState {
Expand Down Expand Up @@ -774,7 +828,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore {
return this.readAll({ allowRewrite: false });
}

private readAll(args: { allowRewrite: boolean }): Record<string, string> {
private readAll(args: { allowRewrite: boolean; migrateLegacy?: boolean }): Record<string, string> {
const credentialsExist = fs.existsSync(this.credentialsPath);
const raw = readJsonObject(this.credentialsPath);
const machineKey = readOrCreateMachineKey(this.machineKeyPath);
Expand All @@ -797,12 +851,8 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore {
throw error;
}
this.lastReadState = credentialsExist ? "available" : "missing";
if (args.allowRewrite) {
try {
this.writeAll(values);
} catch {
// Preserve read compatibility if migration cannot rewrite right now.
}
if (args.allowRewrite || args.migrateLegacy) {
this.writeAllWithKey(values, key);
}
return values;
}
Expand Down Expand Up @@ -831,15 +881,25 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore {
throw new Error("Unsupported ADE credential store format.");
}
const machineKey = await readOrCreateMachineKeyAsync(this.machineKeyPath);
const key = deriveOsBoundCredentialKey(machineKey, await this.keyMaterialProviderAsync());
const osMaterial = await this.keyMaterialProviderAsync();
const key = deriveOsBoundCredentialKey(machineKey, osMaterial);
if (!key.equals(machineKey)) {
try {
const values = deserializeStore(raw, key, { emptyOnDecryptFailure: false });
this.lastReadState = "available";
return values;
} catch {
try {
const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false });
deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false });
} catch (error) {
this.lastReadState = "unreadable";
throw error;
}
try {
if (!osMaterial || osMaterial.length === 0) {
throw new Error("OS-bound credential material is unavailable during migration.");
}
const values = this.withLock(() => this.migrateLegacyUnderLock(osMaterial));
this.lastReadState = "available";
return values;
} catch (error) {
Expand All @@ -861,9 +921,26 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore {
private writeAll(values: Record<string, string>): void {
const machineKey = readOrCreateMachineKey(this.machineKeyPath);
const key = deriveOsBoundCredentialKey(machineKey, this.keyMaterialProvider());
this.writeAllWithKey(values, key);
}

private writeAllWithKey(values: Record<string, string>, key: Buffer): void {
writeFileAtomic(this.credentialsPath, `${JSON.stringify(serializeStore(values, key), null, 2)}\n`);
}

private migrateLegacyUnderLock(osMaterial: Buffer): Record<string, string> {
const raw = readJsonObject(this.credentialsPath);
const machineKey = readOrCreateMachineKey(this.machineKeyPath);
const key = deriveOsBoundCredentialKey(machineKey, osMaterial);
try {
return deserializeStore(raw, key, { emptyOnDecryptFailure: false });
} catch {
const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false });
this.writeAllWithKey(values, key);
return values;
}
}

private withLock<T>(fn: () => T): T {
return withCredentialFileLock(this.lockPath, fn);
}
Expand Down
Loading
Loading