Skip to content

Commit d677814

Browse files
author
pavelhov
authored
Merge pull request #62 from pavelhov/development
fix(macos): finish catalog sync before update recovery completes
2 parents 67c43a1 + 00f5962 commit d677814

4 files changed

Lines changed: 83 additions & 3 deletions

File tree

‎docs-site/src/content/docs/guides/macos-menu-bar.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ files or force-start the proxy. A cancellation or an old-version relaunch is not
6161
update or permission to restart. If Finish Update cannot complete, open **Logs** and retain the
6262
recovery state for diagnosis.
6363

64+
If recovery cannot tell its own routing edit from a newer Native choice, it keeps the update pending
65+
instead of silently reporting success with Codex on the wrong route. **Stop CodexCommander and Quit…**
66+
records an explicit Off choice. Reopen the app to finish recovery in the stopped, native state, then
67+
choose **Start Proxy** to start CC and route Codex through it.
68+
6469
Updater-enabled previews authenticate both the update feed and archives with Sparkle EdDSA
6570
signatures. They remain ad-hoc signed, **unnotarized previews**; Apple Developer ID signing and
6671
notarization are deferred.

‎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.12",
3+
"version": "0.1.13",
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/cli/macos-update.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ export async function resumeProduction(transaction:MacosUpdateTransaction, autho
189189
live?: typeof findLiveProxy;
190190
inspect?: (pid:number) => Promise<Record<string,unknown>>;
191191
stop?: typeof stopProxyLifecycleUnderAuthority;
192+
ensure?: typeof ensureProxyLifecycleUnderLock;
193+
intentFingerprint?: () => string;
194+
routing?: () => "owned" | "native" | "external";
192195
} = {}): Promise<boolean> {
193196
// Preparation does not touch an independent runtime's routes. A later native
194197
// choice is deferred until recovery, and must be published before any early
@@ -203,6 +206,16 @@ export async function resumeProduction(transaction:MacosUpdateTransaction, autho
203206
// A newer verified service choice supersedes the snapshot; never recreate or
204207
// take over that service merely to restore the previous update state.
205208
if (service.fingerprint !== transaction.original.supervisorFingerprint || service.kind === "independent") return true;
209+
// A crash between our settings write and route publication looks like a
210+
// newer native edit by metadata alone. Keep the recovery record until the
211+
// intent is explicit instead of completing with the captured owned route
212+
// missing. Independent/newer service choices above remain authoritative.
213+
if (!restoreOwned && transaction.original.routing === "owned"
214+
&& transaction.latestIntent?.routing !== "native"
215+
&& transaction.preparationIntent !== "superseded"
216+
&& transaction.postPreparationFingerprint !== null
217+
&& transaction.postPreparationFingerprint !== (io.intentFingerprint ?? macOSUpdateIntentFingerprint)()
218+
&& (io.routing ?? currentRouting)() === "native") return false;
206219
const live = await (io.live ?? findLiveProxy)();
207220
if (transaction.latestIntent?.running === false || !transaction.original.running) {
208221
if (!live) return !service.active || (await (io.stop ?? stopProxyLifecycleUnderAuthority)({io:{attestedTargetPolicy:()=>false}},authority)).ok;
@@ -219,11 +232,17 @@ export async function resumeProduction(transaction:MacosUpdateTransaction, autho
219232
const info = await (io.inspect ?? (pid => admission(authority,"GET",pid)))(live.pid);
220233
// Exclusion admits only the one-shot recovery child. A crash after spawn may
221234
// leave it healthy; prove its bundle and changed birth identity before adopting.
222-
return info.pid === live.pid && info.bundlePath === transaction.target.bundlePath
235+
const adopted = info.pid === live.pid && info.bundlePath === transaction.target.bundlePath
223236
&& typeof info.fingerprint === "string" && info.fingerprint !== transaction.original.process?.fingerprint
224237
&& (transaction.original.supervision === "launchd" ? service.kind === "bundle" && service.active : !service.active);
238+
if (!adopted) return false;
239+
// A crash can occur after the replacement child starts but before its
240+
// route and catalog sync. Re-enter the canonical lifecycle for that exact
241+
// child; process identity alone does not complete the update.
242+
const resumed = await (io.ensure ?? ensureProxyLifecycleUnderLock)({action:restoreOwned ? "start" : "ensure",honorAutoStart:false,ensureCompanion:false,preferService:transaction.original.supervision === "launchd"},authority);
243+
return resumed.ok && resumed.state === "running";
225244
}
226-
const resumed = await ensureProxyLifecycleUnderLock({action:restoreOwned ? "start" : "ensure",honorAutoStart:false,ensureCompanion:false,preferService:transaction.original.supervision === "launchd"},authority);
245+
const resumed = await (io.ensure ?? ensureProxyLifecycleUnderLock)({action:restoreOwned ? "start" : "ensure",honorAutoStart:false,ensureCompanion:false,preferService:transaction.original.supervision === "launchd"},authority);
227246
return resumed.ok && resumed.state === "running";
228247
}
229248
export async function performMacOSUpdateCommand(command:MacOSUpdateCommand, io:MacOSUpdateHelperIo = {}): Promise<MacOSUpdateResult> {

‎tests/macos-update-helper.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,62 @@ test.skipIf(process.platform === "win32")("partial preparation stays excluded an
109109
});
110110

111111
import { resumeProduction } from "../src/cli/macos-update";
112+
test.skipIf(process.platform === "win32")("adopted recovery child must complete canonical route and catalog sync",async()=>{
113+
const f=fixture();await performMacOSUpdateCommand(prepare,f.io);f.replace();
114+
const calls:string[]=[];let succeeds=false;
115+
f.io.resume=(transaction,authority,restoreOwned)=>resumeProduction(transaction,authority,restoreOwned,{
116+
service:()=>({kind:"absent",fingerprint:null,active:false}),
117+
live:async()=>({pid:123,port:1234,source:"runtime"}),
118+
inspect:async()=>({pid:123,bundlePath:"/Applications/Test.app",fingerprint:"child"}),
119+
ensure:async options=>{calls.push(options.action!);return {schemaVersion:1,action:options.action!,ok:succeeds,state:"running",changed:false,pid:123,port:1234,message:"sync"};},
120+
});
121+
expect((await performMacOSUpdateCommand({action:"reconcile"},f.io)).status).toBe("blocked");
122+
expect(f.store.read()?.phase).toBe("recovering");
123+
succeeds=true;
124+
expect((await performMacOSUpdateCommand({action:"reconcile"},f.io)).status).toBe("recovered");
125+
expect(f.store.read()).toBeNull();
126+
expect(calls).toEqual(["start","start"]);
127+
});
128+
test.skipIf(process.platform === "win32")("ambiguous native route after a recovery config write retains the transaction",async()=>{
129+
const f=fixture();await performMacOSUpdateCommand(prepare,f.io);f.replace();
130+
const authority=await f.io.authority!({includeStart:true});
131+
const transaction=f.store.read()!;
132+
expect(macOSUpdateResumeIntent(transaction,"newer-fingerprint").restoreOwned).toBe(false);
133+
const calls:string[]=[];
134+
expect(await resumeProduction(transaction,authority,false,{
135+
service:()=>({kind:"absent",fingerprint:null,active:false}),
136+
live:async()=>({pid:123,port:1234,source:"runtime"}),
137+
inspect:async()=>({pid:123,bundlePath:"/Applications/Test.app",fingerprint:"child"}),
138+
intentFingerprint:()=>"newer-fingerprint",routing:()=>"native",
139+
ensure:async options=>{calls.push(options.action!);return {schemaVersion:1,action:options.action!,ok:true,state:"running",changed:false,pid:123,port:1234,message:"sync"};},
140+
})).toBe(false);
141+
expect(calls).toEqual([]);
142+
f.store.recordOff(authority);
143+
let stopped=false;
144+
expect(await resumeProduction(f.store.read()!,authority,false,{
145+
restoreNative:()=>({success:true,changed:false,desiredChanged:false,configChanged:false,message:"native"}),
146+
service:()=>({kind:"absent",fingerprint:null,active:false}),
147+
live:async()=>({pid:123,port:1234,source:"runtime"}),
148+
inspect:async()=>({pid:123,bundlePath:"/Applications/Test.app",fingerprint:"child"}),
149+
intentFingerprint:()=>"newer-fingerprint",routing:()=>"native",
150+
stop:async()=>{stopped=true;return {schemaVersion:1,action:"stop",ok:true,state:"stopped",changed:true,pid:null,port:null,message:"stopped"};},
151+
})).toBe(true);
152+
expect(stopped).toBe(true);authority.releaseAll();
153+
});
154+
test.skipIf(process.platform === "win32")("independent or newer supervisor supersedes ambiguous native recovery",async()=>{
155+
const f=fixture();await performMacOSUpdateCommand(prepare,f.io);f.replace();
156+
const authority=await f.io.authority!({includeStart:true});
157+
for (const service of [{kind:"independent" as const,fingerprint:null,active:true},{kind:"bundle" as const,fingerprint:"newer",active:true}]) {
158+
expect(await resumeProduction(f.store.read()!,authority,false,{
159+
service:()=>service,
160+
intentFingerprint:()=>"newer-fingerprint",routing:()=>"native",
161+
live:async()=>{throw Error("Independent/newer service must not be inspected");},
162+
ensure:async()=>{throw Error("Independent/newer service must not be resumed");},
163+
stop:async()=>{throw Error("Independent/newer service must not be stopped");},
164+
})).toBe(true);
165+
}
166+
authority.releaseAll();
167+
});
112168
test.skipIf(process.platform === "win32")("newer OFF stops a recovery child or active bundle supervisor before clearing exclusion",async()=>{
113169
const f=fixture();await performMacOSUpdateCommand(prepare,f.io);await performMacOSUpdateCommand({action:"record-off"},f.io);
114170
const authority=await f.io.authority!({includeStart:true});let stops=0;

0 commit comments

Comments
 (0)