Skip to content

Commit 8380357

Browse files
author
pavelhov
authored
Merge pull request #38 from pavelhov/codex/fix-windows-launcher-verification
fix: qualify Windows coordination for v0.1.5
2 parents 06a4474 + e84e186 commit 8380357

48 files changed

Lines changed: 818 additions & 186 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/cross-platform.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,12 @@ jobs:
6161
working-directory: gui
6262
run: bun run test
6363

64+
- name: Verify Windows account coordination and CLI lifecycle
65+
if: matrix.platform == 'windows'
66+
run: bun run test:parallel tests/codex-user-identity.test.ts tests/codex-inject-write-lock.test.ts tests/codex-composed-acceptance.test.ts
67+
6468
- name: Test suite
65-
run: bun run test
69+
run: bun run test:parallel
6670

6771
- name: CLI help smoke
6872
run: bun run src/cli/index.ts help

‎app/Sources/MenuBarUITests/main.swift‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,9 +1688,16 @@ func runCompanionHeartbeatTests(_ runner: TestRunner) {
16881688
heartbeat.start()
16891689
heartbeat.start()
16901690
heartbeat.start()
1691+
// Hosted runners may delay the first timer delivery beyond 120ms.
1692+
// Wait for actual delivery, then measure a fixed window for duplicates.
1693+
let firstFireDeadline = Date().addingTimeInterval(3)
1694+
while recorder.count == 0 && Date() < firstFireDeadline {
1695+
spinMainRunLoop(seconds: 0.01)
1696+
}
1697+
runner.expect(recorder.count >= 1, "the repeating timer fires before the deadline")
1698+
let initialCount = recorder.count
16911699
spinMainRunLoop(seconds: 0.12)
1692-
let during = recorder.count
1693-
runner.expect(during >= 1, "the repeating timer fires at least once (got \(during))")
1700+
let during = recorder.count - initialCount
16941701
// One 20ms timer over 120ms fires ~6 times; stacked duplicates would fire ~18.
16951702
runner.expect(during <= 9, "repeated start() must not duplicate the timer (got \(during))")
16961703
runner.equal(recorder.statuses.first, .enabled, "the sampled status is what gets reported")

‎docs-site/src/content/docs/guides/codex-integration.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ $CODEX_HOME/codexcommander-catalog.json
147147
$CODEX_HOME/models_cache.json
148148
```
149149

150+
On Windows, processes using the same Codex home coordinate writes through the effective Windows
151+
account's LocalAppData directory. Overriding `HOME`, `USERPROFILE`, or `LOCALAPPDATA` in a launcher
152+
does not move that coordination state; `CODEX_HOME` still selects the Codex files to manage.
153+
150154
On WSL, if `CODEX_HOME` is unset and the Linux `~/.codex/config.toml` is absent, CodexCommander also
151155
checks for a single Windows Codex Desktop home at `/mnt/c/Users/*/.codex/config.toml`. When exactly
152156
one candidate exists, it uses that directory so WSL app-server mode and Windows Codex Desktop share

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexcommander",
3-
"version": "0.1.4",
3+
"version": "0.1.5",
44
"private": true,
55
"description": "CodexCommander — universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
66
"type": "module",

‎src/codex/auth-api.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,12 @@ interface PoolQuotaRefreshFlight {
613613

614614
const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
615615
const MAX_POOL_QUOTA_FLIGHTS = 16;
616+
let poolQuotaFlightJoinedForTests: (() => void) | null = null;
617+
618+
/** Observe a real compatible-flight join without changing quota admission. */
619+
export function setPoolQuotaFlightJoinedForTests(observer: (() => void) | null): void {
620+
poolQuotaFlightJoinedForTests = observer;
621+
}
616622

617623
export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
618624
constructor() {
@@ -803,7 +809,10 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
803809
?? flight.state.startCredentialGeneration;
804810
return generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
805811
});
806-
if (current) return current.promise;
812+
if (current) {
813+
poolQuotaFlightJoinedForTests?.();
814+
return current.promise;
815+
}
807816
if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
808817

809818
const state: PoolQuotaRefreshFlight["state"] = {

‎src/codex/user-identity.ts‎

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,30 @@ function powershellValue(expression: string): string {
4848
"-NoLogo",
4949
"-NoProfile",
5050
"-NonInteractive",
51-
"-Command",
52-
expression,
51+
"-EncodedCommand",
52+
Buffer.from(
53+
"$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8;\n" + expression,
54+
"utf16le",
55+
).toString("base64"),
5356
], {
5457
stdin: "ignore",
5558
stdout: "pipe",
5659
stderr: "pipe",
60+
timeout: 10_000,
5761
});
5862
} catch (cause) {
5963
refuse("Windows effective-account lookup could not start.", cause);
6064
}
61-
if (result.exitCode !== 0) refuse("Windows effective-account lookup failed.");
65+
if (result.exitCode !== 0) {
66+
// Only this fixed diagnostic grammar may cross the subprocess boundary.
67+
// Compiler messages and native errors can contain paths or account details.
68+
const diagnostic = new TextDecoder().decode(result.stderr).match(
69+
/^CCX_IDENTITY_FAILURE:(compile|token-environment|registered-folder):([A-Za-z0-9_.]{1,80}):([0-9A-F]{8})\r?$/m,
70+
);
71+
refuse(diagnostic
72+
? `Windows effective-account lookup failed (${diagnostic[1]}, ${diagnostic[2]}, HRESULT 0x${diagnostic[3]}).`
73+
: `Windows effective-account lookup failed (exit ${Number.isSafeInteger(result.exitCode) ? result.exitCode : "unknown"}).`);
74+
}
6275
const value = new TextDecoder().decode(result.stdout).trim();
6376
if (!value) refuse("Windows effective-account lookup returned an empty value.");
6477
return value;
@@ -143,12 +156,94 @@ function resolvePosixRuntimeRoot(uid: number): string {
143156

144157
function resolveWindowsRuntimeRoot(identity: Extract<UserIdentity, { platform: "win32" }>): string {
145158
if (!SID_PATTERN.test(identity.sid)) refuse("The coordinator identity contains an invalid SID.");
146-
const localAppData = powershellValue(
147-
"[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)",
148-
);
159+
// Shell folder expansion can retain the launcher's ambient USERPROFILE.
160+
// Expand the raw registered location against the OS token's environment,
161+
// without consulting or mutating this process's environment.
162+
const localAppData = powershellValue(String.raw`
163+
$stage = 'compile'
164+
try {
165+
Add-Type -TypeDefinition @'
166+
using System;
167+
using System.Collections.Generic;
168+
using System.IO;
169+
using System.Runtime.InteropServices;
170+
using System.Security.Principal;
171+
using System.Text.RegularExpressions;
172+
using Microsoft.Win32;
173+
public static class CodexCommanderKnownFolders {
174+
public static string Stage = "token-environment";
175+
[DllImport("userenv.dll", SetLastError = true, ExactSpelling = true)]
176+
[return: MarshalAs(UnmanagedType.Bool)]
177+
private static extern bool CreateEnvironmentBlock(out IntPtr block, IntPtr token, [MarshalAs(UnmanagedType.Bool)] bool inherit);
178+
[DllImport("userenv.dll", ExactSpelling = true)]
179+
[return: MarshalAs(UnmanagedType.Bool)]
180+
private static extern bool DestroyEnvironmentBlock(IntPtr block);
181+
public static string LocalAppData(string expectedSid) {
182+
using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) {
183+
if (!String.Equals(identity.User.Value, expectedSid, StringComparison.OrdinalIgnoreCase))
184+
throw new InvalidOperationException("Effective account changed during environment resolution.");
185+
IntPtr block = IntPtr.Zero;
186+
try {
187+
if (!CreateEnvironmentBlock(out block, identity.Token, false))
188+
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
189+
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
190+
IntPtr cursor = block;
191+
while (Marshal.ReadInt16(cursor) != 0) {
192+
string entry = Marshal.PtrToStringUni(cursor);
193+
int separator = entry.IndexOf('=');
194+
if (separator > 0) values[entry.Substring(0, separator)] = entry.Substring(separator + 1);
195+
cursor = IntPtr.Add(cursor, checked((entry.Length + 1) * 2));
196+
}
197+
Stage = "registered-folder";
198+
string path;
199+
bool expand;
200+
using (RegistryKey users = RegistryKey.OpenBaseKey(RegistryHive.Users, RegistryView.Registry64))
201+
using (RegistryKey key = users.OpenSubKey(identity.User.Value + @"\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders")) {
202+
if (key == null) throw new InvalidOperationException("Registered folder key is unavailable.");
203+
var kind = key.GetValueKind("Local AppData");
204+
if (kind != RegistryValueKind.String && kind != RegistryValueKind.ExpandString)
205+
throw new InvalidOperationException("Registered folder value is not a string.");
206+
expand = kind == RegistryValueKind.ExpandString;
207+
path = key.GetValue("Local AppData", null, RegistryValueOptions.DoNotExpandEnvironmentNames) as string;
208+
}
209+
if (String.IsNullOrWhiteSpace(path)) throw new InvalidOperationException("Registered folder is empty.");
210+
var seen = new HashSet<string>(StringComparer.Ordinal);
211+
for (int depth = 0; expand && path.IndexOf('%') >= 0; depth++) {
212+
if (depth >= 16 || path.Length > 32767 || !seen.Add(path))
213+
throw new InvalidOperationException("Registered folder expansion did not converge.");
214+
bool replaced = false;
215+
path = Regex.Replace(path, @"%([^%]+)%", match => {
216+
string value;
217+
if (!values.TryGetValue(match.Groups[1].Value, out value))
218+
throw new InvalidOperationException("Registered folder references an unavailable variable.");
219+
replaced = true;
220+
return value;
221+
});
222+
if (!replaced) throw new InvalidOperationException("Registered folder has an unresolved variable.");
223+
}
224+
string root = Path.GetPathRoot(path);
225+
if (path.Length > 32767 || path.IndexOf('\0') >= 0 || String.IsNullOrEmpty(root) || root.Length < 3 || root.EndsWith(":"))
226+
throw new InvalidOperationException("Registered folder is not an absolute path.");
227+
return Path.GetFullPath(path);
228+
} finally {
229+
if (block != IntPtr.Zero) DestroyEnvironmentBlock(block);
230+
}
231+
}
232+
}
233+
}
234+
'@
235+
$stage = 'token-environment'
236+
[CodexCommanderKnownFolders]::LocalAppData('${identity.sid}')
237+
} catch {
238+
if ($stage -ne 'compile') { $stage = [CodexCommanderKnownFolders]::Stage }
239+
$failure = $_.Exception.GetBaseException()
240+
[Console]::Error.WriteLine('CCX_IDENTITY_FAILURE:' + $stage + ':' + $failure.GetType().FullName + ':' + $failure.HResult.ToString('X8'))
241+
exit 1
242+
}
243+
`);
149244
if (!isAbsolute(localAppData)) refuse("Windows LocalAppData resolution returned a relative path.");
150245

151-
// The SID and known-folder values come from the effective token/.NET OS APIs,
246+
// The SID and registered folder values come from the effective token/registry,
152247
// never USERPROFILE or LOCALAPPDATA. WP11 adds descriptor/reparse/ACL checks at
153248
// the stable-database open boundary where those checks can cover SQLite too.
154249
const root = resolve(localAppData, "CodexCommander", "Runtime", "v1", identity.sid.toUpperCase());

‎src/lib/service-files.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ export function writePrivateServiceFile(path: string, content: string | Uint8Arr
8383
writeFileSync(temp, content, { encoding, mode, flag: "wx" });
8484
// Best effort on filesystems that do not support fsync; failure is surfaced
8585
// rather than silently declaring an on-disk credential durable.
86-
const fd = openSync(temp, "r");
86+
// Windows requires a writable handle for FlushFileBuffers (fsync).
87+
const fd = openSync(temp, "r+");
8788
try { fsyncSync(fd); } finally { closeSync(fd); }
8889
if (existing) {
8990
const now = assertPrivateServiceFile(path);

‎tests/ccx-launcher-runtime.test.ts‎

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,35 @@ import { createServer } from "node:net";
55
import { tmpdir } from "node:os";
66
import { join, resolve } from "node:path";
77
import { bundledBunPath } from "../src/lib/bun-runtime";
8-
import { killProxy } from "../src/lib/process-control";
8+
import { redactSecretString } from "../src/lib/redact";
99

1010
const BIN_CCX = join(import.meta.dir, "..", "bin", "ccx.mjs");
1111
const nodeAvailable = spawnSync("node", ["--version"], {
1212
stdio: "ignore",
1313
windowsHide: true,
1414
}).status === 0;
1515
const runnable = process.platform === "win32" && nodeAvailable;
16+
const STARTUP_STDERR_LIMIT = 16_384;
17+
18+
function startupStderrDiagnostic(stderr: string, env: NodeJS.ProcessEnv): string {
19+
let sanitized = stderr;
20+
for (const [name, value] of Object.entries(env)) {
21+
if (value && /token|secret|password|credential|api_?key/i.test(name)) {
22+
sanitized = sanitized.replaceAll(value, "[REDACTED]");
23+
}
24+
}
25+
return redactSecretString(sanitized).replace(/\x1b\[[0-9;]*m/g, "").trim() || "(empty)";
26+
}
27+
28+
test("launcher startup diagnostics redact labelled and inherited credentials", () => {
29+
const result = startupStderrDiagnostic(
30+
"startup failed: fixture-private-value\nAuthorization: Bearer fixture-bearer-value\n",
31+
{ CUSTOM_SECRET: "fixture-private-value" },
32+
);
33+
expect(result).toContain("startup failed: [REDACTED]");
34+
expect(result).not.toContain("fixture-private-value");
35+
expect(result).not.toContain("fixture-bearer-value");
36+
});
1637

1738
type Health = {
1839
status: string;
@@ -155,6 +176,22 @@ function inspectWindowsProcessIdentity(pid: number): WindowsProcessIdentity | nu
155176
throw lastError;
156177
}
157178

179+
function stopOwnedWindowsProcessTree(identity: WindowsProcessIdentity): void {
180+
const current = inspectWindowsProcessIdentity(identity.pid);
181+
if (!current) return;
182+
if (!sameProcess(current, identity)) throw new Error("owned test process identity changed before cleanup");
183+
// This is a test-owned Node launcher (or its proven Bun child), not the
184+
// current home's proxy. Production killProxy correctly refuses that target.
185+
const result = spawnSync("taskkill.exe", ["/PID", String(identity.pid), "/T", "/F"], {
186+
stdio: "ignore",
187+
timeout: 10_000,
188+
windowsHide: true,
189+
});
190+
if (result.status !== 0 && sameProcess(inspectWindowsProcessIdentity(identity.pid), identity)) {
191+
throw new Error(`owned test process tree did not stop (taskkill exit=${result.status})`);
192+
}
193+
}
194+
158195
function removeTree(path: string): void {
159196
// Windows can retain the copied executable's image handle briefly after
160197
// taskkill returns. Retry only transient fixture-cleanup errors, with a cap.
@@ -187,17 +224,52 @@ async function effectiveRuntime(override: string): Promise<string> {
187224
let primaryError: unknown;
188225
try {
189226
port = await freePort();
227+
const launcherEnv = isolatedLauncherEnv(root, override);
228+
// Explicit start persists routing intent, so its isolated home needs an
229+
// installed config. Use static models to keep discovery out of this test.
230+
writeFileSync(join(launcherEnv.CODEXCOMMANDER_HOME!, "config.json"), JSON.stringify({
231+
port,
232+
hostname: "127.0.0.1",
233+
multiAgentGuidanceEnabled: true,
234+
defaultProvider: "mock",
235+
providers: {
236+
mock: {
237+
adapter: "openai-chat",
238+
baseUrl: "http://127.0.0.1:9/v1",
239+
allowPrivateNetwork: true,
240+
liveModels: false,
241+
models: ["test-model"],
242+
defaultModel: "test-model",
243+
},
244+
},
245+
}), { mode: 0o600 });
246+
let startupStderr = "";
190247
launcher = spawn("node", [BIN_CCX, "start", "--port", String(port)], {
191-
stdio: "ignore",
248+
stdio: ["ignore", "ignore", "pipe"],
192249
windowsHide: true,
193-
env: isolatedLauncherEnv(root, override),
250+
env: launcherEnv,
251+
});
252+
launcher.stderr?.setEncoding("utf8");
253+
launcher.stderr?.on("data", (chunk: string) => {
254+
// Drain the pipe continuously, retaining only a bounded startup prefix.
255+
startupStderr += chunk.slice(0, Math.max(0, STARTUP_STDERR_LIMIT - startupStderr.length));
194256
});
195257
if (!launcher.pid) throw new Error("Node launcher has no process id");
196258
launcherPid = launcher.pid;
197259
ownedLauncher = captureWindowsProcessIdentity(launcherPid);
198260

199261
const health = await waitForHealth(port, 25_000, launcher);
200-
if (!health) throw new Error("proxy did not become healthy");
262+
if (!health) {
263+
// Drop the last line if the capture filled, so truncation cannot expose a
264+
// partial credential whose label/value boundary fell beyond the limit.
265+
const stderr = startupStderr.length === STARTUP_STDERR_LIMIT
266+
? startupStderr.slice(0, Math.max(0, startupStderr.lastIndexOf("\n")))
267+
: startupStderr;
268+
throw new Error(
269+
`proxy did not become healthy (exit=${launcher.exitCode}, signal=${launcher.signalCode}); `
270+
+ `startup stderr: ${startupStderrDiagnostic(stderr, launcherEnv)}`,
271+
);
272+
}
201273
const identity = windowsProcessIdentity(health.pid);
202274
if (!identity || identity.parentPid !== launcher.pid) {
203275
throw new Error("health PID is not the spawned Node launcher's direct Bun child");
@@ -218,16 +290,20 @@ async function effectiveRuntime(override: string): Promise<string> {
218290
if (ownedLauncher) {
219291
try {
220292
if (sameProcess(inspectWindowsProcessIdentity(ownedLauncher.pid), ownedLauncher)) {
221-
killProxy(ownedLauncher.pid);
293+
stopOwnedWindowsProcessTree(ownedLauncher);
222294
launcherTreeStopped = true;
223295
}
224296
} catch (error) {
225297
cleanupErrors.push(`launcher cleanup failed: ${String(error)}`);
226298
}
227299
}
228-
if (!launcherTreeStopped && launcher && launcherPid && launcher.exitCode === null && launcher.signalCode === null) {
300+
if (!ownedLauncher && !launcherTreeStopped && launcher && launcherPid && launcher.exitCode === null && launcher.signalCode === null) {
229301
try {
230-
killProxy(launcherPid);
302+
const identity = captureWindowsProcessIdentity(launcherPid);
303+
if (launcher.exitCode !== null || launcher.signalCode !== null) {
304+
throw new Error("test launcher exited before fallback cleanup identity was captured");
305+
}
306+
stopOwnedWindowsProcessTree(identity);
231307
launcherTreeStopped = true;
232308
} catch (error) {
233309
cleanupErrors.push(`launcher tree fallback failed: ${String(error)}`);
@@ -239,7 +315,7 @@ async function effectiveRuntime(override: string): Promise<string> {
239315
if (ownedProxy) {
240316
try {
241317
if (sameProcess(inspectWindowsProcessIdentity(ownedProxy.pid), ownedProxy)) {
242-
killProxy(ownedProxy.pid);
318+
stopOwnedWindowsProcessTree(ownedProxy);
243319
}
244320
} catch (error) {
245321
cleanupErrors.push(`proxy cleanup failed: ${String(error)}`);

‎tests/cli-account.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
22
import { PassThrough, Readable } from "node:stream";
3+
import { fileURLToPath } from "node:url";
34
import { cmdAccount, classifyAccount, formatAccountTable, type AccountDeps } from "../src/cli/account";
45
import type { AccountStdin } from "../src/cli/account-api";
56
import { printSubcommandUsage } from "../src/cli/help";
@@ -404,7 +405,7 @@ function stdinFrom(value: string, isTTY = false): AccountStdin {
404405

405406
test("the login URL reaches piped stdout before the polling window (#1007)", async () => {
406407
const child = Bun.spawn({
407-
cmd: [process.execPath, "run", new URL("./helpers/account-login-pipe-child.ts", import.meta.url).pathname],
408+
cmd: [process.execPath, "run", fileURLToPath(new URL("./helpers/account-login-pipe-child.ts", import.meta.url))],
408409
stdout: "pipe",
409410
stderr: "pipe",
410411
});

0 commit comments

Comments
 (0)