Skip to content

Commit c13279f

Browse files
author
pavelhov
authored
Merge pull request #37 from pavelhov/development
release: promote v0.1.5 to main
2 parents 92c8275 + 8380357 commit c13279f

52 files changed

Lines changed: 908 additions & 192 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-app-models.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ openai-apikey/gpt-5.6-sol # API key
3434

3535
Fresh installs and configs with no saved mode default to Pool.
3636

37+
## Continuing after a model change
38+
39+
When you switch from a routed model back to OpenAI, the next request uses the newly selected
40+
model and reasoning effort. For requests with `store: false`, Commander removes lookup IDs
41+
from unencrypted reasoning summaries carried over in the conversation. This prevents OpenAI
42+
from trying to retrieve a reasoning item that it never stored. The summaries, messages, and
43+
tool-call results remain in the replayed history. Proxy-created reasoning envelopes receive
44+
the same ID repair after their provider-specific payload is removed.
45+
46+
Native OpenAI encrypted history stays unchanged, including across account switches. This repair
47+
does not guarantee that every backend accepts every prior model's encrypted history, and it does
48+
not turn storage on or retry a rejected request. It applies to normal Responses requests,
49+
the WebSocket-to-HTTP bridge, and native compaction.
50+
3751
## Desktop remote-allowlist limitation
3852

3953
If `codex debug models` and app-server `model/list` contain a routed model but Desktop does not show

‎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/adapters/openai-responses.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,28 @@ function scrubCodexCommanderCompactionItems(body: unknown): unknown {
168168
return changed ? { ...body, input } : body;
169169
}
170170

171-
/** Repair only proxy-created items; native encrypted and readable siblings stay identical. */
171+
/** Repair proxy envelopes and unstored readable reasoning; native ciphertext stays identical. */
172172
export function normalizeNativeResponsesHistory(body: unknown): unknown {
173173
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
174174
let changed = false;
175175
const input = body.input.map(item => {
176-
if (!isPlainObject(item) || typeof item.encrypted_content !== "string"
177-
|| !/^(?:ccx1:|ccxr1:)/.test(item.encrypted_content)) return item;
178-
const repaired = sanitizeReasoningInputContent(scrubCodexCommanderCompactionItems({ input: [item] })) as { input: unknown[] };
179-
if (repaired.input[0] !== item) changed = true;
180-
return repaired.input[0];
176+
if (!isPlainObject(item)) return item;
177+
const proxyEnvelope = typeof item.encrypted_content === "string"
178+
&& /^(?:ccx1:|ccxr1:)/.test(item.encrypted_content);
179+
// A routed model can emit summary-only reasoning with a proxy-minted rs_ id.
180+
// With storage disabled OpenAI cannot resolve that id. Replay the supplied
181+
// summary instead, without altering opaque native history or id-only references.
182+
const unstoredReasoning = body.store === false && item.type === "reasoning"
183+
&& !item.encrypted_content && (Array.isArray(item.summary) || Array.isArray(item.content));
184+
if (!proxyEnvelope && !unstoredReasoning) return item;
185+
const repaired = sanitizeReasoningInputContent(scrubCodexCommanderCompactionItems({ input: [item] })) as { input: Record<string, unknown>[] };
186+
let next = repaired.input[0]!;
187+
if (next.type === "reasoning" && "id" in next) {
188+
next = { ...next };
189+
delete next.id;
190+
}
191+
if (next !== item) changed = true;
192+
return next;
181193
});
182194
return changed ? { ...body, input } : body;
183195
}

‎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);

0 commit comments

Comments
 (0)