Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion skills/ocx/references/01_surface_lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ State-changing: yes.

| Flag | Value | Meaning |
|---|---|---|
| `--restart-codex` | boolean | Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog or cache write, on macOS, Linux and Windows. |
| `--restart-codex` | boolean | Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog, cache, or changed configuration write, on macOS, Linux and Windows. |
| `--restart-app-server-only` | boolean | Restart only the Codex app-servers and leave the desktop app running; wins over --restart-codex when both are given. |
| `--restart-desktop-app` | boolean | Deprecated alias of --restart-codex. |

Expand Down
2 changes: 1 addition & 1 deletion skills/ocx/references/01_surface_observe-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ State-changing: yes.

JSON mode: `payload`.

- `sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request.
- `sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog, cache, or changed configuration write, so it cannot restart a healthy install on request.
- Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request.
- --yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand.

Expand Down
4 changes: 2 additions & 2 deletions src/cli/capabilities-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ export const CAPABILITIES: readonly Capability[] = [
flags: [{"name":"--yes","value":"boolean","summary":"Required: fully quits and relaunches the operator's Codex desktop app, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts; also restarts its app-servers."},{"name":"--json","value":"boolean","summary":"Emit the restart result as JSON."}],
mutates: true,
json: "payload",
details: ["`sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request.","Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request.","--yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand."],
details: ["`sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog, cache, or changed configuration write, so it cannot restart a healthy install on request.","Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request.","--yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand."],
usage: "ocx system codex-restart --yes [--json]",
},
{
Expand Down Expand Up @@ -694,7 +694,7 @@ export const CAPABILITIES: readonly Capability[] = [
summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.",
routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }],
flags: [
{ name: "--restart-codex", value: "boolean", summary: "Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog or cache write, on macOS, Linux and Windows." },
{ name: "--restart-codex", value: "boolean", summary: "Restart the Codex app-servers and fully quit and relaunch the Codex desktop app after a catalog, cache, or changed configuration write, on macOS, Linux and Windows." },
{ name: "--restart-app-server-only", value: "boolean", summary: "Restart only the Codex app-servers and leave the desktop app running; wins over --restart-codex when both are given." },
{ name: "--restart-desktop-app", value: "boolean", summary: "Deprecated alias of --restart-codex." },
],
Expand Down
8 changes: 6 additions & 2 deletions src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,11 +461,15 @@ const commandRunners: Record<string, CommandRunner> = {
code = 1;
console.error("Codex sync did not complete. Fix the reported Codex config issue and retry.");
}
// Only warn/restart when a catalog or models_cache write actually happened. This is
// Catalog/model-cache writes retain their warning/restart handling. This is
// deliberately not an `else`: refreshCodexModelCatalog runs before injectCodexConfig,
// so a sync can fail (`ok: false`) after the catalog was already rewritten — which is
// exactly when a long-lived app-server is holding the stale list.
if (synced.catalogWritten || synced.cacheSynced) {
// A configuration-only change also needs a reload, but only when explicitly
// requested. A successful no-op or refused write is never restart authority.
const requestedConfigRestart = (restartScope.appServers || restartScope.desktopApp)
&& synced.ok && synced.configChanged === true;
if (synced.catalogWritten || synced.cacheSynced || requestedConfigRestart) {
await handleRestartScopeAfterWrite(restartScope, console);
}
// `ocx sync` is a direct CLI path; it does not call the management
Expand Down
2 changes: 1 addition & 1 deletion src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
summary: "Fetch provider models and inject them into Codex config.",
details: [
"After writing the catalog, warns if long-lived Codex app-server processes are still running.",
"--restart-codex restarts the app-servers AND fully quits and relaunches the Codex desktop app on macOS, Linux and Windows, so its model picker re-reads the catalog. It ends live conversations.",
"--restart-codex restarts the app-servers AND fully quits and relaunches the Codex desktop app on macOS, Linux and Windows, after catalog/cache writes or a committed configuration change. It ends live conversations.",
"--restart-app-server-only keeps the narrow behaviour: SIGTERM to matching app-server / code-mode-host processes, desktop app left running. It wins over --restart-codex when both are given.",
"--restart-desktop-app is a deprecated alias of --restart-codex and prints a notice.",
],
Expand Down
10 changes: 10 additions & 0 deletions src/codex/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export interface CodexInjectResult {
message: string;
/** False when injection intentionally preserves configuration owned by another provider. */
configApplied?: false;
/** True only after a successful configuration transaction changes config.toml bytes. */
configChanged?: boolean;
/**
* Structured read-only history preflight refusal; never parsed from display text.
*
Expand Down Expand Up @@ -717,6 +719,11 @@ async function injectCodexConfigImpl(
releaseConfigWriteLock(configLock.handle);
heldConfigWriteLock = undefined;
}
// Both write paths have committed, including the coordinator's post-callback checks.
// Compare against the admitted original, not post-v1-reconcile input: the native
// feature transition is part of this transaction too. Refusals/rollbacks return
// above and must never publish this receipt. No filesystem reread after unlock.
const configChanged = Boolean(missingConfig) || effectivePlan.content !== rawContent;
// Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
// the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore
// machinery; cheap no-op when there is nothing to migrate). The client-compaction opt-in keeps
Expand Down Expand Up @@ -799,6 +806,7 @@ async function injectCodexConfigImpl(
if (effectivePlan.keptUserBaseUrl && effectivePlan.keepRootOverrideAlongsideTable) {
return {
success: true,
configChanged,
...(effectivePlan.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning } : {}),
...(effectivePlan.historyRelabelRefusal ? { historyPreflightFailureReason: effectivePlan.historyRelabelRefusal } : {}),
message:
Expand All @@ -817,6 +825,7 @@ async function injectCodexConfigImpl(
if (effectivePlan.keptUserBaseUrl) {
return {
success: true,
configChanged,
...(effectivePlan.nativeSubagentDefaultsWarning
? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning }
: {}),
Expand All @@ -839,6 +848,7 @@ async function injectCodexConfigImpl(
: `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url + realtime sideband override).\n`;
return {
success: true,
configChanged,
...(effectivePlan.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: effectivePlan.nativeSubagentDefaultsWarning } : {}),
...(effectivePlan.historyRelabelRefusal ? { historyPreflightFailureReason: effectivePlan.historyRelabelRefusal } : {}),
message:
Expand Down
4 changes: 4 additions & 0 deletions src/codex/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface CodexSyncResult {
catalogPath: string | null;
catalogExists: boolean;
catalogWritten: boolean;
/** Committed configuration-byte change, never inferred from a successful no-op. */
configChanged?: boolean;
cacheSynced: boolean;
/**
* Whether the catalog owner committed a validated catalog or refused the
Expand Down Expand Up @@ -214,6 +216,7 @@ export async function syncModelsToCodex(
return {
status: "applied",
ok: result.success,
...(result.success && result.configChanged === true ? { configChanged: true } : {}),
added: 0,
catalogPath: null,
catalogExists: false,
Expand Down Expand Up @@ -332,6 +335,7 @@ export async function syncModelsToCodex(
return {
status: "applied",
ok: result.success,
...(result.success && result.configChanged === true ? { configChanged: true } : {}),
added,
...(refreshOutcome ? { refreshOutcome } : {}),
catalogPath,
Expand Down
2 changes: 2 additions & 0 deletions structure/cli-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,5 @@ Internal version observations in `src/service/managing-cli.ts` (`probeVersionOnc
`src/cli/account-auth.ts` creates a Grok coupon operation UUID before every explicitly confirmed consume request unless the caller supplies one. Uncertain delivery and unknown-outcome API responses, including `operation_state_changed`, print guidance to preserve that same `--operation-id`; the CLI performs no automatic redemption retry. Ledger and reconciliation semantics are owned by [Grok reset coupons](providers/xai-grok.md#grok-reset-coupons-billing-api-parity).

Remote Link enrollment is shared with the dashboard through `src/server/management/link-enrollment.ts` and `src/cli/link-enrollment.ts`. `ocx link probe <alias>` returns a temporary SSH fingerprint/key type/version offer. `ocx link add <alias> --expect-fingerprint SHA256:…` confirms exact trust and applies under loopback admin authority, with TTY explicit confirmation when the flag is omitted. Non-TTY omission fails before discovery. One Home URL and a 360-second deadline cover all requests and bodies; redirects are refused, credentials are never printed, and post-connect residual failures retain fixed recovery. `tests/cli/cli-link-enrollment.test.ts` verifies these boundaries. Child `link status` fallback uses the shared projector with persisted client connection and `clientLinkTunnelStatus`, plus validated client-owned sidecar/marker initiation identity; ambiguous evidence never invents direction. An unobserved Home-owned tunnel is conservatively idle. Link-mode `status` reads supported Hub projections via the local Child without sending a credential; a standalone Home role-based 404 explains absent Hub state rather than recommending an upgrade.

An explicit `sync --restart-codex` (or app-server-only scope) also reloads after a committed configuration-byte change, even when no catalog/cache was written. Without an explicit restart scope, configuration-only changes do not restart clients. Existing catalog/cache-write restart behavior remains unchanged; success alone is not proof of changed configuration.
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ The former `showCodexSparkQuota` key is inert passthrough data when loading an o

## Config injection

An explicit desktop restart after injection uses the [runtime process-membership contract](runtime.md#codex-desktop-process-membership); mixed Windows path spelling does not change which installation the restart targets.
Configuration injection exposes `configChanged` only after a successful configuration transaction, comparing admitted original bytes with final committed bytes including native feature reconciliation. Preflight, preserved external ownership, skipped writes and compensated failures never authorize a configuration-only restart. An explicit desktop restart after injection uses the [runtime process-membership contract](runtime.md#codex-desktop-process-membership); mixed Windows path spelling does not change which installation the restart targets.

One further root key is conditional rather than part of either routing form. While the web-search
sidecar is switched off (`webSearchSidecar.enabled: false`), the injection also owns Codex's own
Expand Down
37 changes: 37 additions & 0 deletions tests/cli/cli-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1364,3 +1364,40 @@ describe("CLI usage recovery contracts", () => {
},
);
});


describe("explicit sync restart after a committed configuration change", () => {
for (const scenario of [
{ changed: true, ok: true, requested: true, expected: 1 },
{ changed: false, ok: true, requested: true, expected: 0 },
{ changed: true, ok: false, requested: true, expected: 0 },
{ changed: true, ok: true, requested: false, expected: 0 },
]) test(JSON.stringify(scenario), async () => {
const state = await import("../../src/client/state");
const sync = await import("../../src/codex/sync");
const restart = await import("../../src/cli/restart-scope");
const aside = await import("../../src/cli/aside-profiles");
const stateSpy = spyOn(state, "readClientConnectionState").mockReturnValue({ kind: "disconnected" });
const syncSpy = spyOn(sync, "syncModelsToCodex").mockResolvedValue({
status: "applied", ok: scenario.ok, configChanged: scenario.changed,
added: 0, catalogPath: null, catalogExists: false, catalogWritten: false, cacheSynced: false, message: "fixture",
});
const restartSpy = spyOn(restart, "handleRestartScopeAfterWrite").mockResolvedValue({});
const asideSpy = spyOn(aside, "refreshAsideProfilesThroughServer").mockResolvedValue([]);
const log = spyOn(console, "log").mockImplementation(() => {});
const error = spyOn(console, "error").mockImplementation(() => {});
const network = spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected network request"));
try {
const args = scenario.requested ? ["sync", "--restart-codex"] : ["sync"];
expect(await dispatchCommand({ kind: "command", command: "sync", args }, {
...fakeDeps, args, findLiveProxy: async () => null,
})).toBe(scenario.ok ? 0 : 1);
expect(restartSpy).toHaveBeenCalledTimes(scenario.expected);
if (scenario.expected) expect(restartSpy.mock.calls[0]![0]).toEqual({ appServers: true, desktopApp: true });
expect(network).not.toHaveBeenCalled();
} finally {
stateSpy.mockRestore(); syncSpy.mockRestore(); restartSpy.mockRestore(); asideSpy.mockRestore();
log.mockRestore(); error.mockRestore(); network.mockRestore();
}
});
});
11 changes: 9 additions & 2 deletions tests/codex-integration/codex-inject-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,10 +900,12 @@ describe("injectCodexConfig integration (Design B)", () => {
expect(result.status).toBe(0);
const value = JSON.parse(result.stdout.trim());
expect(value.preflight.success).toBe(true);
expect(value.preflight.configChanged).not.toBe(true);
expect(value.before).toBe(original);
expect(value.afterPreflight).toBe(original);
expect(value.journalAfterPreflight).toBe(false);
expect(value.committed.success).toBe(true);
expect(value.committed.configChanged).toBe(true);
expect(value.injected).toContain('base_url = "https://hub.example.test/v1"');
expect(value.injected).toContain('env_key = "OPENCODEX_API_AUTH_TOKEN"');
expect(value.owner).toEqual({ kind: "client", apiKeyId: "client-key-1" });
Expand Down Expand Up @@ -971,9 +973,13 @@ describe("injectCodexConfig integration (Design B)", () => {
test("re-inject over a Design B config is idempotent", () => {
writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8");

expect(runInject(codexHome, ocxHome).status).toBe(0);
const firstResult = runInject(codexHome, ocxHome);
expect(firstResult.status, firstResult.stderr).toBe(0);
expect(JSON.parse(firstResult.stdout)).toMatchObject({ success: true, configChanged: true });
const first = readFileSync(join(codexHome, "config.toml"), "utf8");
expect(runInject(codexHome, ocxHome).status).toBe(0);
const secondResult = runInject(codexHome, ocxHome);
expect(secondResult.status, secondResult.stderr).toBe(0);
expect(JSON.parse(secondResult.stdout)).toMatchObject({ success: true, configChanged: false });
const second = readFileSync(join(codexHome, "config.toml"), "utf8");

expect(second.match(/openai_base_url/g)?.length).toBe(1);
Expand Down Expand Up @@ -1514,6 +1520,7 @@ describe("injectCodexConfig integration (Design B)", () => {
expect(result.message).toContain("routing NOT injected");
expect(result.message).toContain('external model_provider "custom"');
expect(result.configApplied).toBe(false);
expect(result.configChanged).not.toBe(true);
expect(result.message).toContain("http://127.0.0.1:10100/v1");
expect(result.message).toContain("Responses passthrough");
expect(result.nativeSubagentDefaultsWarning).toContain("external model_provider");
Expand Down
Loading
Loading