Skip to content
Draft
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
15 changes: 14 additions & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { loadConfig } from "./config";
import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
import { stripGrokConfig } from "./grok/inject";
import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home";
import { resolveCodexSqliteHome } from "./codex/paths";
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime";
import type { BunRuntimeSource } from "./lib/bun-runtime";
import { isProcessAlive, stopProxy } from "./lib/process-control";
Expand Down Expand Up @@ -130,6 +131,8 @@ export interface ServiceInstallState {
version: 1 | 2;
codexHome: string;
opencodexHome: string;
/** Effective Codex SQLite home used by this service's history integration. */
codexSqliteHome?: string;
/** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */
bunPath?: string;
cliPath?: string;
Expand All @@ -145,7 +148,7 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState |
if (state.version !== 1 && state.version !== 2) return null;
if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null;
if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null;
for (const key of ["bunPath", "cliPath", "winswVersion", "winswSha256"] as const) {
for (const key of ["codexSqliteHome", "bunPath", "cliPath", "winswVersion", "winswSha256"] as const) {
if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null;
}
if (state.version === 1) {
Expand All @@ -162,6 +165,7 @@ function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void {
version: 2,
codexHome: currentCodexHome(),
opencodexHome: currentOpenCodexHome(),
codexSqliteHome: resolveCodexSqliteHome({ codexHome: currentCodexHome() }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind relative SQLite homes to a stable service directory

When config.toml contains a relative sqlite_home, this records the path resolved against the installer's process.cwd(), but the generated plist, systemd unit, and Windows launcher do not preserve that working directory. The supervised process can therefore resolve the same configuration to another database, while a later command run from the installation directory passes this ownership check and restores the recorded—but wrong—database. Preserve a stable working directory in the service artifact or otherwise ensure the recorded path is exactly the one the child uses, with regression coverage for relative sqlite_home.

AGENTS.md reference: src/AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

bunPath: bun,
cliPath: cli,
backend,
Expand Down Expand Up @@ -315,6 +319,15 @@ export function assertServiceEnvironmentMatchesInstall(): void {
"Run the service command from the same OpenCodex home so service state and secrets match.",
);
}
if (state.codexSqliteHome !== undefined) {
const actualCodexSqliteHome = resolveCodexSqliteHome({ codexHome: actualCodexHome });
Comment on lines +322 to +323

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat SQLite resolution failures as ownership failures

When config.toml is unreadable or has an invalid sqlite_home, this resolver throws a plain Error before the service manager is contacted. The ocx stop path in src/cli/index.ts only sets ownershipBlocked for ServiceOwnershipError, so it treats this as an ordinary stop failure and continues into native Codex restoration and Grok cleanup while the installed service remains registered and may still be running. Convert this indeterminate resolution into the fail-closed ownership representation so callers skip shared teardown.

Useful? React with 👍 / 👎.

if (normalizePathForCompare(state.codexSqliteHome) !== normalizePathForCompare(actualCodexSqliteHome)) {
throw new ServiceOwnershipError(
`Service was installed with Codex SQLite home=${state.codexSqliteHome}, but the current Codex SQLite home=${actualCodexSqliteHome}. ` +
"Run the service command with the same sqlite_home configuration and CODEX_SQLITE_HOME so native Codex history restore updates the correct database.",
);
}
}
}


Expand Down
21 changes: 21 additions & 0 deletions tests/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import type { OcxConfig } from "../src/types";
const TEST_DIR = join(import.meta.dir, ".tmp-service-test");
const previousOpenCodexHome = process.env.OPENCODEX_HOME;
const previousCodexHome = process.env.CODEX_HOME;
const previousCodexSqliteHome = process.env.CODEX_SQLITE_HOME;
const previousApiAuthToken = process.env.OPENCODEX_API_AUTH_TOKEN;

afterEach(() => {
if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousOpenCodexHome;
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
if (previousCodexSqliteHome === undefined) delete process.env.CODEX_SQLITE_HOME;
else process.env.CODEX_SQLITE_HOME = previousCodexSqliteHome;
if (previousApiAuthToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN;
else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiAuthToken;
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
Expand Down Expand Up @@ -207,6 +210,23 @@ describe("service install auth preflight", () => {

expect(() => assertServiceEnvironmentMatchesInstall()).toThrow("Service was installed with CODEX_HOME");
});

test("rejects restore operations against a different Codex SQLite home than service install", () => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
mkdirSync(TEST_DIR, { recursive: true });
process.env.OPENCODEX_HOME = TEST_DIR;
process.env.CODEX_HOME = join(TEST_DIR, "codex-home");
delete process.env.CODEX_SQLITE_HOME;
writeFileSync(join(TEST_DIR, "service-state.json"), JSON.stringify({
version: 2,
codexHome: process.env.CODEX_HOME,
codexSqliteHome: join(TEST_DIR, "installed-sqlite-home"),
opencodexHome: TEST_DIR,
backend: "scheduler",
}) + "\n");

expect(() => assertServiceEnvironmentMatchesInstall()).toThrow("Codex SQLite home");
});
});

describe("Windows service task", () => {
Expand Down Expand Up @@ -1138,6 +1158,7 @@ describe("service diagnostics", () => {
expect(parseServiceInstallState({ ...valid, backend: undefined })).toBeNull();
expect(parseServiceInstallState({ ...valid, version: 1, backend: "scheduler" })).toBeNull();
expect(parseServiceInstallState({ ...valid, version: 1, backend: undefined })?.version).toBe(1);
expect(parseServiceInstallState({ ...valid, codexSqliteHome: "" })).toBeNull();
});

test("status summary exposes the service log path", () => {
Expand Down
Loading