Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,13 @@ automatically. If that fallback cannot determine the token state, it retains the
error. Foreign tasks and operations can never emit the automatic-elevation marker. Approve the
dashboard UAC prompt or rerun `ocx service install` in an elevated PowerShell window.

For a fresh install where the OpenCodex scheduler task is confirmed absent, UAC approval now
happens before the installer stops any existing proxy. The task is registered without being run;
only after registration succeeds does OpenCodex stop the old listener, publish the service assets,
and start the scheduled task. Cancelling or denying UAC therefore leaves the working proxy and its
Codex routing in place. Existing or conflicting scheduler registrations continue to fail closed
rather than being deleted as an unsafe best-effort rollback.

### `ocx codex-shim <install|status|uninstall|remove>`

Wrap a script-based `codex` launcher on PATH with a lightweight autostart script. Real `codex.exe`
Expand Down
9 changes: 8 additions & 1 deletion src/server/startup-action-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,17 @@ function runCliInstall(
const cli = join(import.meta.dir, "..", "cli", "index.ts");
const argv = [cli, ...startupInstallArgv(action, options)];
return new Promise((resolve, reject) => {
const timeout = process.platform === "win32" && action === "install-service"
? 0
: 60_000;
execFile(bun, argv, {
encoding: "utf8",
env: process.env,
timeout: 60_000,
// A fresh Windows scheduler install now owns its UAC prompt inside this CLI
// transaction. Killing only the CLI at 60s can orphan its elevated schtasks child,
// which may register the task after the Dashboard has reported failure. Keep the
// async request/attempt lock alive until Windows returns approval or cancellation.
timeout,
windowsHide: true,
maxBuffer: 256 * 1024,
Comment on lines +167 to 179

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

The disabled timeout has no upper bound, and the test pins that value. Both sites follow from one decision: replacing the 60-second bound with 0 rather than with a longer bound. An unanswered UAC prompt therefore leaves installState in running forever and blocks every later install attempt, and the test locks the literal 0 in place so the source cannot be corrected without touching it.

  • src/server/startup-action-control.ts#L167-L179: replace 0 with a named long bound, for example WINDOWS_ELEVATED_INSTALL_TIMEOUT_MS, and classify its expiry as the existing indeterminate state defined at lines 27-34.
  • tests/startup-action-control-elevation.test.ts#L81-L86: assert against the exported bound constant instead of the literal 0, and add a case that an expired elevated install ends in indeterminate rather than running.
📍 Affects 2 files
  • src/server/startup-action-control.ts#L167-L179 (this comment)
  • tests/startup-action-control-elevation.test.ts#L81-L86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/startup-action-control.ts` around lines 167 - 179, Replace the
zero Windows install timeout in src/server/startup-action-control.ts lines
167-179 with an exported named long-bound constant such as
WINDOWS_ELEVATED_INSTALL_TIMEOUT_MS, and classify timeout expiry through the
existing indeterminate install state rather than leaving installState running.
Update tests/startup-action-control-elevation.test.ts lines 81-86 to assert the
exported constant instead of literal 0 and add coverage verifying an expired
elevated install ends in indeterminate.

}, (error, stdout, stderr) => {
Expand Down
174 changes: 173 additions & 1 deletion src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
runWindowsElevated,
toWindowsSchtasksError,
WindowsElevationError,
WindowsSchtasksError,
type ElevatedSchedulerOutcome,
type ElevatedSchtasksCreateAndRunExecution,
type ElevatedSchtasksCreateAndRunResult,
Expand Down Expand Up @@ -1506,6 +1507,11 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
}

/** Build the fixed scheduler-create command from an explicit staged XML document. */
export function buildWindowsSchtasksCreateArgsForXml(xml: string): string[] {
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
}
Comment on lines +1510 to +1513

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Let buildWindowsSchtasksCreateArgs delegate to the new helper.

Lines 1505-1508 and lines 1511-1513 now build the same argument array. Two copies of the fixed create shape can drift, and the security argument in the code comments depends on the shape staying identical everywhere. Keep one definition.

♻️ Proposed refactor
 export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
   const xml = script === windowsServiceScriptPath() ? windowsTaskXmlPath() : `${script}.xml`;
-  return ["/create", "/tn", TASK, "/xml", xml, "/f"];
+  return buildWindowsSchtasksCreateArgsForXml(xml);
 }

Move buildWindowsSchtasksCreateArgsForXml above buildWindowsSchtasksCreateArgs or rely on function hoisting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/service.ts` around lines 1510 - 1513, Update
buildWindowsSchtasksCreateArgs to delegate to
buildWindowsSchtasksCreateArgsForXml, passing its generated XML instead of
constructing the fixed argument array itself. Keep
buildWindowsSchtasksCreateArgsForXml as the single definition of the
scheduler-create argument shape.


/**
* VBS launcher that starts the batch wrapper with a hidden window (style 0).
* bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
Expand Down Expand Up @@ -1823,6 +1829,84 @@ function writeWindowsSchedulerAssets(): void {
writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
}

function stageWindowsSchedulerRegistrationXml(): string {
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
const path = join(getConfigDir(), `.opencodex-service-task.${randomUUID()}.xml`);
// This document points at the canonical launcher but does not publish or rewrite that
// launcher. UAC can therefore be refused while the current proxy still owns its port.
writeServiceAssetWithRetry(
path,
`\uFEFF${buildWindowsTaskXml(windowsServiceScriptPath(), windowsLauncherVbsPath())}`,
"utf16le",
);
return path;
}

export interface FreshWindowsSchedulerRegistrationDeps {
create?: (args: string[]) => void;
elevate?: (args: string[]) => Promise<void>;
probe?: () => WindowsSchedulerTaskProbe;
queryXml?: () => string;
rollback?: () => Promise<string | null>;
}

export async function registerFreshWindowsSchedulerTask(
xmlPath: string,
deps: FreshWindowsSchedulerRegistrationDeps = {},
): Promise<void> {
const args = buildWindowsSchtasksCreateArgsForXml(xmlPath);
try {
(deps.create ?? schtasks)(args);
} catch (error) {
if (
!(error instanceof WindowsSchtasksError)
|| error.operation !== "create"
|| error.reason !== "access-denied"
) {
throw error;
}
// The elevated command is still the fixed trusted schtasks executable plus the
// owned create shape. It registers only; the task is not run until cleanup commits.
await (deps.elevate ?? elevateSchtasks)(args);
}

const rollbackTask = deps.rollback ?? (() => rollbackElevatedSchedulerTask(TASK));
const probe = (deps.probe ?? (() => probeWindowsSchedulerTask(TASK)))();
if (probe.status === "absent") {
throw new Error("Task Scheduler reported success, but the new registration is absent; no service cleanup was started.");
}
if (probe.status === "unknown") {
const rollback = await rollbackTask();
throw new Error(
`Task Scheduler registration was not verifiably present after create (${probe.detail}).`
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
);
}

let registeredXml = "";
let queryDetail: string | null = null;
try {
registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", TASK, "/xml"])))();
} catch (error) {
queryDetail = error instanceof Error ? error.message : String(error);
}
if (!registeredXml.trim()) {
const rollback = await rollbackTask();
throw new Error(
"Task Scheduler registration was created, but its live XML could not be verified."
+ (queryDetail ? ` Query failed: ${queryDetail}` : " The query returned an empty document.")
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."),
);
}
if (!windowsTaskRegistrationHealthy(registeredXml)) {
const rollback = await rollbackTask();
throw new Error(
"Task Scheduler registration was created but failed the OpenCodex action/trigger verification."
+ (rollback ? ` Cleanup also failed: ${rollback}` : " The invalid registration was rolled back."),
);
}
}

function installWindows(): void {
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
// Transactional backend switch: installing the scheduler backend removes a native
Expand Down Expand Up @@ -2437,6 +2521,82 @@ export async function installServiceSafely(
await install();
}

export interface FreshWindowsSchedulerInstallDeps {
stageRegistrationXml?: () => string;
register?: (xmlPath: string) => Promise<void>;
prepare?: () => Promise<void>;
publishAssets?: () => void;
runTask?: () => void;
writeState?: () => void;
rollbackTask?: () => Promise<string | null>;
removeStagedXml?: (xmlPath: string) => void;
}

/**
* Fresh Windows scheduler install with UAC before the destructive commit.
*
* The registration is created but never run before `prepare`: UAC cancellation and
* create failure therefore cannot stop the existing proxy or trigger its native-routing
* cleanup. This path is used only after Task Scheduler absence was proved, so rollback
* can delete the exact registration this attempt created without touching prior state.
*/
export async function installFreshWindowsSchedulerSafely(
deps: FreshWindowsSchedulerInstallDeps = {},
): Promise<void> {
const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml;
const register = deps.register ?? registerFreshWindowsSchedulerTask;
const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
const runTask = deps.runTask ?? startWindows;
const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
const rollbackTask = deps.rollbackTask ?? (() => rollbackElevatedSchedulerTask(TASK));
const removeStagedXml = deps.removeStagedXml ?? ((path: string) => {
if (existsSync(path)) unlinkSync(path);
});

let stagedXml: string | null = null;
let registered = false;
let started = false;
try {
stagedXml = stage();
await register(stagedXml);
registered = true;

// The destructive boundary begins only after Task Scheduler accepted the definition.
await prepare();
publishAssets();
runTask();
started = true;
writeState();
Comment on lines +2543 to +2570

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

The fresh path skips the native (WinSW) backend switch and the owned-path record.

installWindows() at lines 1910-1932 does three things before it creates the task:

  1. recordOwnedConfigPath(getConfigDir(), serviceStatePath()) (line 1911).
  2. Removal and re-verification of an installed WinSW service (lines 1914-1924).
  3. Asset write, create, run, state write.

The new dispatch at lines 2980-2981 calls installFreshWindowsSchedulerSafely() instead of ops.install, so steps 1 and 2 no longer run. Failure mode: a machine that has the native WinSW service installed and no scheduler task takes the fresh path. The installer then registers and starts a scheduler task while the WinSW service is still registered. Two live managers respawn the proxy, which is exactly the conflict the comment on lines 1912-1913 exists to prevent. The install-state ownership record is also lost.

Add the WinSW switch and the ownership record to the transactional path, before prepare() and ideally before registration, or call the shared preparation helper that performs them.

🐛 Sketch of the missing phase
   let stagedXml: string | null = null;
   let registered = false;
   let started = false;
   try {
+    recordOwnedConfigPath(getConfigDir(), serviceStatePath());
+    // Two live managers would both respawn the proxy; remove the native backend
+    // before the scheduler backend is registered.
+    removeNativeServiceBeforeSchedulerSwitch();
     stagedXml = stage();
     await register(stagedXml);
     registered = true;

Expose the WinSW removal block of installWindows() as removeNativeServiceBeforeSchedulerSwitch() and call it from both paths.

#!/bin/bash
# Check whether prepareServiceInstall already removes the WinSW service and records the owned path.
ast-grep run --pattern 'export async function prepareServiceInstall($$$) { $$$ }' --lang typescript src/service.ts
rg -nP -C 5 'uninstallWinswService|statusWinswRaw|recordOwnedConfigPath' --type=ts src
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/service.ts` around lines 2543 - 2570, Update
installFreshWindowsSchedulerSafely to record the owned config path and
remove/re-verify any installed WinSW service before registration, preferably by
reusing or extracting the shared preparation helper from installWindows. Ensure
both fresh and existing installation paths perform the native-service switch,
while preserving the transactional ordering before prepare() and task startup.

} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
if (registered && !started) {
const rollback = await rollbackTask();
throw new Error(
`${detail}\n`
+ (rollback
? `The new Task Scheduler registration may remain: ${rollback}`
: "The new Task Scheduler registration was rolled back. The previous proxy/routing state was not assumed restored."),
);
}
if (started) {
throw new Error(
`${detail}\nThe scheduler task started, but install state was not published. `
+ "The task was left in place; inspect `ocx service status` before retrying.",
);
}
throw error;
} finally {
if (stagedXml) {
try { removeStagedXml(stagedXml); } catch (error) {
console.error(
`⚠️ Failed to remove temporary Task Scheduler XML ${stagedXml}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
}

/**
* If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
* Returns true if a service was found and stopped.
Expand Down Expand Up @@ -2812,7 +2972,19 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
// restart-loops on EADDRINUSE, and the old standalone process makes the install
// verification report a false success.
try {
await installServiceSafely(backend, ops.install);
if (process.platform === "win32" && backend === "scheduler") {
const scheduler = probeWindowsSchedulerTask(TASK);
if (scheduler.status === "unknown") {
throw new Error(`Task Scheduler state could not be verified before install: ${scheduler.detail}`);
}
if (scheduler.status === "absent") {
await installFreshWindowsSchedulerSafely();
} else {
await installServiceSafely(backend, ops.install);
}
} else {
await installServiceSafely(backend, ops.install);
}
} catch (error) {
console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
Expand Down
23 changes: 23 additions & 0 deletions structure/05_gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,29 @@ the effective-token elevation probe may classify it as access denied only when t
to be non-elevated. An unavailable probe remains `other` and cannot trigger UAC. Query, run, delete,
native-service, file-write, and foreign task failures never use this fallback.

For a fresh scheduler install whose task is proven absent, registration is the non-destructive
first phase. OpenCodex writes a unique temporary XML definition and asks Task Scheduler to create
the owned task without running it. Only after that succeeds may service-manager cleanup stop the
existing proxy, publish the canonical scheduler assets, run the task, and write install state.
UAC cancellation or create failure removes the temporary XML before any manager/proxy stop, so the
working proxy's shutdown cleanup cannot strip Codex routing merely because elevation was refused.
The Dashboard does not apply its ordinary 60-second child timeout to this Windows service command:
killing only the CLI could orphan the already-launched elevated child, which might register a task
after the UI reported failure. The asynchronous request and install-attempt lock remain pending
until Windows returns approval or cancellation; other proxy requests keep running normally.
Existing or conflicting registrations stay on the older fail-closed path because deleting or
replacing them cannot be called a rollback without an exact prior-registration snapshot.

```text
[Decision Log]
- 목적과 의도: Keep a refused fresh Windows service install from stopping a working proxy and removing managed Codex routing.
- 기존 구현 및 제약 조건: The generic installer stopped service managers and the standalone proxy before the first scheduler create attempt; the Dashboard UAC path depended on assets produced by that already-destructive failure.
- 검토한 주요 대안: Reject every non-elevated caller up front, restart and re-inject after failure, snapshot every runtime/config artifact for rollback, or separate registration approval from the destructive commit.
- 선택한 방식: When scheduler absence is proven, create but do not run the owned registration from a temporary XML first; cleanup and canonical asset publication begin only after registration succeeds.
- 다른 대안 대신 이 방식을 선택한 이유: An early rejection breaks Dashboard UAC, while a best-effort restart cannot prove that manager, proxy, and routing state were restored. The two-phase boundary makes denial/cancellation a real pre-commit failure.
- 장점, 단점 및 영향: Fresh-install UAC failure preserves the live proxy and routing. Failures after registration remain explicit partial-install cases, and existing/conflicting scheduler recovery remains conservative until exact prior-state restoration is available.
```

```text
[Decision Log]
- 목적과 의도: Make Windows scheduler installation recovery work on non-English systems without broadening the commands that may request UAC.
Expand Down
Loading
Loading