Skip to content
Merged
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
421 changes: 421 additions & 0 deletions devlog/_plan/260911_hub_single_port/010_launchd_repair.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@
"lab-public-wire-contract.test.ts": "lab",
"lab-read-filter-validation.test.ts": "lab",
"lab-read-surfaces.test.ts": "lab",
"launchd-repair.test.ts": "service",
"legacy-shell-compat.test.ts": "responses",
"live-service-manager-guard.test.ts": "service",
"local-management-attestation.test.ts": "server",
Expand Down
5 changes: 3 additions & 2 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]",
summary: "Run as a background service.",
details: [
"With no subcommand, installs when absent or repairs/restarts an existing service.",
"`restart` aliases `repair`; healthy Windows tasks are reused, while stale definitions may re-register and elevate.",
"With no subcommand, installs when absent or repairs an existing service.",
"`repair` refreshes the definition and reloads the manager only when something changed, so repairing a healthy service is not an outage.",
"`restart` is the same refresh but always restarts: on macOS an unchanged, already-loaded job is kickstarted in place. Healthy Windows tasks are reused, while stale definitions may re-register and elevate.",
"Use `ocx service status` to see diagnostics and log paths.",
],
},
Expand Down
5 changes: 4 additions & 1 deletion src/cli/version-skew.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ export function computeVersionSkew(cliVersion: string, proxyVersion: string | un
const order = cliSemver && proxySemver ? compareVersions(cliSemver, proxySemver) : 0;
const advice = order > 0
? "the running proxy is older than this CLI. Restart the proxy using the intended current installation. "
+ "For a background service, run ocx service repair (ocx service restart is an alias)."
// `restart`, not `repair`: a version skew leaves the service DEFINITION unchanged, and
// repair reloads only when something changed, so it would no-op and keep the old
// process serving (#4249).
+ "For a background service, run ocx service restart (repair reloads only a changed definition)."
: order < 0
? "this ocx on PATH is older than the running proxy. Upgrade the CLI or resolve PATH to the intended installation."
: "the versions differ, but neither can be identified as older. Check which installations the CLI and proxy use.";
Expand Down
49 changes: 49 additions & 0 deletions src/lib/test-home-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ function canonicalize(path: string): string {
const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir();
const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex"));
const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex"));
/**
* `~/Library/LaunchAgents` needs its own entry because HOME isolation does not reach it:
* `os.homedir()` reads the password database, not `$HOME`, so a macOS test that rewrites
* HOME still resolves `plistPath()` to the developer's real LaunchAgents directory. The
* launchd install tests were doing exactly that — replacing the live
* `com.opencodex.proxy.plist` with one whose token file, log path and Bun paths all point
* into a temp sandbox, for as long as the case ran. launchd holds its own parsed copy, so
* nothing broke until the job next restarted.
*/
const PROTECTED_LAUNCH_AGENTS = canonicalize(join(REAL_HOME, "Library", "LaunchAgents"));

/** The production home this process protects. Exported for the guard's own tests. */
export function protectedHomeForTests(): string {
Expand All @@ -76,6 +86,23 @@ export function isTestHomeGuardArmed(): boolean {
return process.env[GUARD_ENV] === "1";
}

/**
* Whether `dir` IS the protected production home, decided with the SAME canonicalization as
* {@link assertNotRealHomeUnderTest}.
*
* For the caller that must FILTER the real home out of a candidate list instead of refusing
* one write: `serviceStatePaths()` in `src/service.ts` keeps a legacy
* `~/.opencodex/service-state.json` entry so an install made before OPENCODEX_HOME existed
* can still be found, and under an armed test process that entry is the developer's live
* record. Exported so that filter cannot drift onto a weaker comparison — `resolve()` alone
* calls `/var/folders/...` and `/private/var/folders/...` different paths, which is exactly
* how a macOS sandbox path slips past a string compare.
*/
export function isProtectedHomeUnderTest(dir: string): boolean {
if (!isTestHomeGuardArmed()) return false;
return canonicalize(dir) === PROTECTED_HOME;
}

/**
* Throw when an armed test process is about to write the real OpenCodex home.
*
Expand All @@ -94,6 +121,28 @@ export function assertNotRealHomeUnderTest(dir: string): void {
);
}

/** The production LaunchAgents directory this process protects. Exported for its tests. */
export function protectedLaunchAgentsDirForTests(): string {
return PROTECTED_LAUNCH_AGENTS;
}

/**
* Throw when an armed test process is about to write the real `~/Library/LaunchAgents`.
*
* Same contract as {@link assertNotRealHomeUnderTest}: call before any mkdir/write, and
* pass a DIRECTORY. A launchd test gives `installLaunchd` an explicit plist path inside its
* own fixture directory instead.
*/
export function assertNotRealLaunchAgentsUnderTest(dir: string): void {
if (!isTestHomeGuardArmed()) return;
if (canonicalize(dir) !== PROTECTED_LAUNCH_AGENTS) return;
throw new Error(
`refusing to write the real LaunchAgents directory (${PROTECTED_LAUNCH_AGENTS}) from a test `
+ "process: os.homedir() ignores HOME, so rewriting HOME does not move this path. Pass an "
+ "explicit plist path inside the test's own fixture directory instead.",
);
}

/** Throw when an armed test process is about to write the real native Codex home. */
export function assertNotRealCodexHomeUnderTest(dir: string): void {
if (!isTestHomeGuardArmed()) return;
Expand Down
Loading
Loading