From 4108d02c32e6a0e0c94f914e9eda3230bc202631 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 10 Nov 2026 01:56:37 +0100 Subject: [PATCH] Fix Windows PowerShell service crash boundary --- CHANGELOG.md | 10 + docs/ARCHITECTURE.md | 29 ++- docs/REFERENCE.md | 76 ++++--- src/config.rs | 30 +++ src/exec_sessions.rs | 110 +++++++--- src/main.rs | 54 +++++ src/service.rs | 372 ++++++++++++++++++++++++++------ src/service/windows_launcher.rs | 267 +++++++++++++++++------ 8 files changed, 764 insertions(+), 184 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c21694..14bdb156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- Prefer PowerShell 7 for model-selected PowerShell commands on Windows, never + downgrade an explicit `pwsh` request to Windows PowerShell 5.1, and use + `cmd.exe` as the implicit fallback when PowerShell 7 is unavailable. +- Run the persistent Windows service launcher in Codexify instead of + `powershell.exe`, containing and restarting the native supervisor independently + of user PowerShell processes. Migrate recognized encoded and file-based legacy + launchers while preserving custom Scheduled Task actions. + ## [2.3.29] - 2026-11-09 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d78e42ef..070c8fb1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -807,9 +807,12 @@ commands manage one per-user native definition: an XDG-aware systemd user unit on Linux, a launchd agent on macOS, or an at-logon Scheduled Task on Windows. Installation stores the current executable path and the selected config path as absolute arguments. The hidden -`service run` command is the native manager's entry point. The updater also uses -the hidden `service wait-ready` command to poll the loopback health endpoint under -a deadline after a restart. +`service run` command is the systemd/launchd entry point and the supervised +server runner. On Windows, the Scheduled Task enters through hidden `service +launch --scheduled-task`, which detaches the task console before its native +restart loop starts `service run`. The updater also uses +the hidden `service wait-ready` command to poll the loopback health endpoint +under a deadline after a restart. `service status [--json]` reuses the native `service::status()` inspection used by Doctor, without loading `AppConfig` or probing server health. Its human and JSON @@ -822,17 +825,21 @@ The service runner does not construct a second server configuration. It waits if the selected file does not exist, then starts the ordinary Codexify executable with `--config `. Child stdout and stderr are serialized into a private 10 MiB log with five numbered generations. Child failures use bounded -exponential restart backoff; systemd, launchd, and Task Scheduler separately -restart the runner if the runner itself fails. On shutdown, the runner terminates -the server process group/tree before exiting. Windows children are assigned to a -kill-on-close Job Object so Task Scheduler termination cannot orphan the server -or its descendants. +exponential restart backoff; systemd and launchd restart the runner if it fails. +On Windows, the native launcher applies the same bounded restart policy to the +runner, while Task Scheduler restarts the launcher itself. On shutdown, each +layer terminates its child process group/tree before exiting. The Windows +launcher and runner use nested kill-on-close Job Objects so Task Scheduler +termination cannot orphan the runner, server, or their descendants. `start`, `stop`, and `restart` preserve the manager's enablement state. The older `enable` and `disable` commands intentionally combine enablement and runtime changes. On Windows, the scheduled action launches the fixed supervisor command -through the active PowerShell engine with a hidden window and Interactive logon; -the supervisor sets `CREATE_NO_WINDOW` on its server child as a second boundary. +directly through `codexify.exe service launch --scheduled-task` with Interactive +logon. The native +launcher starts the supervisor with `CREATE_NO_WINDOW`, and the supervisor sets +the same flag on its server child. Start/restart/enable migrate exact recognized +PowerShell launchers from older releases; custom actions are not rewritten. Service-supervised tracing is emitted without terminal escapes. The public log viewer reassembles partial lines, strips escapes from older files, highlights @@ -987,7 +994,7 @@ the original order and rejects duplicate names. | `project_clone.rs` | Strict provider-agnostic HTTPS/SSH Git repository URL parsing plus GitHub branch/PR/commit target parsing, conservative normalized remote matching, existing-checkout discovery, exact GitHub target-ref or object-ID fetching, bounded non-interactive cloning below `projectCloneDir`, cross-process repository locks, collision refusal, and post-clone verification. | | `worktrees.rs` | Per-task managed Git worktree lifecycle: create a detached checkout under `worktrees.root` via `git worktree add`, optionally at an exact fetched commit, dual source/worktree root tracking, startup sweep bounded by `keepCount`, Windows `\\?\`-prefix handling, and the opt-in `allowSetupScript` gate for per-worktree environment setup. | | `project_catalog.rs` | Live, read-only project discovery from native Codex plus explicit metadata; canonical access-root filtering, deduplication, deterministic query ranking, sanitized MCP warnings, and local diagnostics. | -| `exec_sessions.rs` | Generic-client project/scratch fallback plus task-owned unified-exec sessions and transport-local diff state: private persistent scratch storage with transport-local bindings, trusted configured-shell resolution, Codex-compatible model shell-type selection by basename, PowerShell exit-code wrapping, background stdout/stderr drain tasks, process-group kill, idle cleanup, and output truncation (UTF-16 units to match the TS). | +| `exec_sessions.rs` | Generic-client project/scratch fallback plus task-owned unified-exec sessions and transport-local diff state: private persistent scratch storage with transport-local bindings, trusted configured-shell resolution, Codex-compatible model shell-type selection by basename, strict PowerShell 7 requests and legacy-host avoidance on Windows, PowerShell exit-code wrapping, background stdout/stderr drain tasks, process-group kill, idle cleanup, and output truncation (UTF-16 units to match the TS). | | `diff.rs` | Project-scoped Git snapshots, persistent task refs, transport-local fallbacks, incremental comparisons whose emitted snapshot can advance the private diff cursor through compare-and-swap, legacy review-ref migration, diff parsing and component-payload budgets. | | `diff_ui.rs` | Embedded MCP Apps resource, component-only diff-result metadata, legacy review-card compatibility, and persisted private interaction state for the interactive `show_diff` card. | | `setup_ui.rs` / `setup_ui.html` | Compact setup/status MCP App with a searchable project/scratch chooser, selected direct/worktree/scratch path rendering, full-task continuation token generation, retired-owner read-only state, cached-bypass update checks, background structured doctor diagnostics, direct connector-settings Refresh routing, conversational Autofix, and debug timing display. | diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4ca5cb23..d61d2d79 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -683,21 +683,33 @@ trust state, warnings, and diagnostics. `--json`, redirected output, and ## Background service The installation scripts register a per-user native service, start it -immediately, and enable it for subsequent user logins. The service definition -invokes the installed executable with an absolute config path: +immediately, and enable it for subsequent user logins. Linux and macOS invoke +the installed executable with an absolute config path: ```text codexify service run --config /absolute/path/to/codexify.config.json ``` +The Windows Scheduled Task instead invokes the hidden native launcher: + +```text +codexify service launch --scheduled-task --config C:\absolute\path\to\codexify.config.json +``` + +That long-lived launcher starts `service run`, records its exit, and restarts it +with bounded backoff. Keeping the persistent task action native prevents a user +PowerShell process and the service launcher from sharing the same host or +failure domain. + The hidden `service run` command supervises the ordinary Codexify server. It waits when the config file has not been created yet, restarts a failed server with bounded exponential backoff, and forwards stdout and stderr into `~/.codexify/logs/codexify.log`. The current log rotates at 10 MiB, with five -numbered generations retained. The native service manager also restarts the -supervisor if the supervisor itself fails. On Windows, the supervised server is -contained in a kill-on-close Job Object so stopping the scheduled task cannot -leave its process tree behind. +numbered generations retained. The native service manager also restarts its +entry point if that process fails. On Windows, the launcher contains the native +supervisor tree in a kill-on-close Job Object, and the supervisor separately +contains the server tree, so stopping the Scheduled Task cannot leave either +layer or its descendants behind. Persisted log files remain plain text. `codexify service logs` applies terminal colors only while displaying them, strips legacy ANSI control sequences, moves @@ -733,15 +745,18 @@ codexify service remove enable or disable login startup. Use `enable` to enable and start the definition, or `disable` to stop it and disable future automatic startup. -On Windows, the Task Scheduler action starts the supervisor through the current -PowerShell host with `-WindowStyle Hidden -File` and a private UTF-8 launcher at -`~/.codexify/service/supervisor.ps1`. Registration uses the adjacent `install.ps1`; -registration failures include both paths so the exact definition can be inspected. -Neither script contains the configuration's credentials. Management helpers and -diagnostic probes use isolated consoles rather than the foreground server's. -The supervisor also creates its ordinary server child with `CREATE_NO_WINDOW`. This -prevents the otherwise empty console window without changing the Interactive -logon principal required for tunnel and network access. +On Windows, the Task Scheduler action points directly at `codexify.exe service +launch --scheduled-task`; the private marker detaches the task-launched console +before the asynchronous runtime starts, and no persistent PowerShell process is +part of the service tree. +Registration still uses the private UTF-8 +`~/.codexify/service/install.ps1` as a one-shot management helper, and that file +contains no configuration credentials. Management helpers and diagnostic probes +use isolated consoles rather than the foreground server's. The native launcher +creates the supervisor with `CREATE_NO_WINDOW`, and the supervisor applies the +same boundary to its ordinary server child. This prevents empty console windows +without changing the Interactive logon principal required for tunnel and +network access. The Windows supervisor begins liveness monitoring after the server first answers `/health` successfully. Six consecutive failed probes (with a five-second pause @@ -755,14 +770,14 @@ panic markers to the service log with their process ID. Normal Ctrl+C delivery and server returns are logged separately. Forced termination can bypass these records; the absence of a marker does not establish a crash cause. -The scheduled-task launcher waits for the supervisor's process handle and records -its exit code. It restarts unexpectedly terminated supervisors, including code -zero, with a two-to-sixty-second backoff, independently of Task Scheduler's -failure retry behavior for on-demand starts. Explicit `service stop` and -`service disable` remain stopped. -Recognized older launchers are upgraded by `service start`, `restart`, or `enable`, -including the `enable` call made by older self-update workers; custom task actions -are preserved. +The native Scheduled Task launcher waits for the supervisor's process handle and +records its exit code. It restarts unexpectedly terminated supervisors, +including code zero, with a two-to-sixty-second backoff, independently of Task +Scheduler's failure retry behavior for on-demand starts. Explicit `service stop` +and `service disable` remain stopped. `service start`, `restart`, or `enable` +upgrades exact recognized encoded and file-based PowerShell launchers to the +native action, including the `enable` call made by older self-update workers; +modified and custom task actions are preserved. `service status` queries the native service manager without loading the server configuration, starting the MCP server, or changing the service. It works even @@ -1555,7 +1570,7 @@ The `exec` block governs resident-process resources and the default shell for |-----|---------|-------------| | `maxSessions` | `8` | Cap on concurrent background sessions per ChatGPT conversation, or per MCP transport for clients without conversation metadata | | `idleTimeoutMs` | `300000` | Milliseconds without a tool interaction before a resident process is killed and forgotten; `0` disables idle expiry | -| `defaultShell` | `$SHELL`, else PowerShell on Windows and `/bin/sh` elsewhere | Shell used when an `exec_command` call names none | +| `defaultShell` | `$SHELL`; otherwise PowerShell 7 then `cmd.exe` on Windows, and `/bin/sh` elsewhere | Shell used when an `exec_command` call names none | `exec_command` does not tokenize, filter, or allow-list commands. Its `cmd` value is passed to the selected shell as written after ordinary input and working-directory @@ -3092,7 +3107,18 @@ Windows, macOS and Linux are supported natively. Which shell runs is decided by | `powershell`, `pwsh` | ` -NoProfile -Command ""` | | `cmd` | `cmd /c ""` | -The default comes from `$SHELL` on every platform, so starting the server from Git Bash on Windows gets bash — with real `ls -la`, pipes and `$VAR` — rather than PowerShell. Set `exec.defaultShell` to override, or pass `shell` on an individual `exec_command` call. +The default comes from `$SHELL` on every platform, so starting the server from +Git Bash on Windows gets bash — with real `ls -la`, pipes and `$VAR` — rather +than PowerShell. Without that explicit environment choice, Windows selects +PowerShell 7 from `PATH` or its standard installation directory and falls back +to `cmd.exe`; it never implicitly selects Windows PowerShell 5.1. Set +`exec.defaultShell` to override, or pass `shell` on an individual +`exec_command` call. + +On Windows, an explicit `pwsh` request is strict and never downgrades to +`powershell.exe`. The broader `powershell` name prefers PowerShell 7 and retains +Windows PowerShell 5.1 only as a compatibility fallback. A configured absolute +`exec.defaultShell` remains authoritative. Two Windows-specific details are handled: `powershell -Command` collapses every non-zero child exit code to `1`, so commands are wrapped to re-raise `$LASTEXITCODE`; and `exec_command`'s description gains Codex's PowerShell rules (`-LiteralPath` over `-Path`, `-WindowStyle Hidden`) when the server runs there. diff --git a/src/config.rs b/src/config.rs index 87c3f4bf..4f3f4c62 100644 --- a/src/config.rs +++ b/src/config.rs @@ -322,6 +322,9 @@ pub enum ServiceCommand { /// Run the service supervisor under the native service manager. #[command(hide = true)] Run, + /// Run the persistent Windows task launcher. + #[command(hide = true)] + Launch(ServiceLaunchArgs), /// Wait until the restarted service and its tunnel are ready. #[command(hide = true)] WaitReady(ServiceWaitReadyArgs), @@ -334,6 +337,12 @@ pub struct ServiceStatusArgs { pub json: bool, } +#[derive(Args, Debug)] +pub struct ServiceLaunchArgs { + #[arg(long, hide = true)] + pub scheduled_task: bool, +} + #[derive(Args, Debug)] pub struct ServiceLogsArgs { /// Continue printing new log output until interrupted. @@ -3732,6 +3741,27 @@ mod tests { )); } + #[test] + fn service_launcher_cli_accepts_definition_argument_order() { + let parsed = Cli::try_parse_from([ + "codexify", + "service", + "launch", + "--scheduled-task", + "--config", + "/tmp/codexify.config.json", + ]) + .unwrap(); + assert_eq!(parsed.config.as_deref(), Some("/tmp/codexify.config.json")); + assert!(matches!( + parsed.command, + Some(CliCommand::Service { + command: ServiceCommand::Launch(args) + }) + if args.scheduled_task + )); + } + #[test] fn existing_server_cli_syntax_remains_valid() { let parsed = Cli::try_parse_from([ diff --git a/src/exec_sessions.rs b/src/exec_sessions.rs index 9d795025..698cf486 100644 --- a/src/exec_sessions.rs +++ b/src/exec_sessions.rs @@ -118,8 +118,9 @@ pub fn shell_type_of(bin: &str) -> ShellType { } } -/// The shell used when the caller names none. `$SHELL` wins on every platform; -/// Windows falls back to PowerShell, matching Codex. +/// The shell used when the caller names none. `$SHELL` wins on every platform. +/// Windows prefers PowerShell 7 but does not implicitly select Windows +/// PowerShell 5.1 because the legacy host is not a safe implicit execution path. pub fn default_shell_bin() -> String { if let Ok(shell) = std::env::var("SHELL") && !shell.is_empty() @@ -127,12 +128,47 @@ pub fn default_shell_bin() -> String { return shell; } if cfg!(windows) { - "powershell.exe".to_string() + preferred_windows_shell_bin() } else { "/bin/sh".to_string() } } +fn preferred_windows_shell_bin() -> String { + windows_pwsh_candidates() + .into_iter() + .find_map(|candidate| executable_on_path(&candidate)) + .unwrap_or_else(|| "cmd.exe".to_string()) +} + +fn windows_pwsh_candidates() -> Vec { + let mut candidates = vec!["pwsh.exe".to_string(), "pwsh".to_string()]; + for variable in ["ProgramW6432", "ProgramFiles"] { + let Some(root) = std::env::var_os(variable).map(std::path::PathBuf::from) else { + continue; + }; + let candidate = root.join("PowerShell").join("7").join("pwsh.exe"); + let candidate = candidate.to_string_lossy().into_owned(); + if !candidates.contains(&candidate) { + candidates.push(candidate); + } + } + candidates.push(r"C:\Program Files\PowerShell\7\pwsh.exe".to_string()); + candidates +} + +#[cfg(any(windows, test))] +fn windows_powershell_candidates() -> Vec { + windows_pwsh_candidates() + .into_iter() + .chain([ + "powershell.exe".to_string(), + "powershell".to_string(), + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(), + ]) + .collect() +} + fn ultimate_fallback_shell_bin() -> String { if cfg!(windows) { "cmd.exe".to_string() @@ -170,29 +206,32 @@ pub fn resolve_model_shell(explicit: &str) -> Vec { return shell_argv(default); } - let candidates: &[&str] = match requested.as_str() { - "zsh" => &["zsh", "/bin/zsh"], - "bash" => &["bash", "/bin/bash", "/usr/bin/bash"], - "sh" => &["sh", "/bin/sh"], + let candidates: Vec = match requested.as_str() { + "zsh" => ["zsh", "/bin/zsh"] + .into_iter() + .map(str::to_string) + .collect(), + "bash" => ["bash", "/bin/bash", "/usr/bin/bash"] + .into_iter() + .map(str::to_string) + .collect(), + "sh" => ["sh", "/bin/sh"].into_iter().map(str::to_string).collect(), #[cfg(windows)] - "pwsh" => &[ - "pwsh", - "powershell", - r"C:\Program Files\PowerShell\7\pwsh.exe", - ], + "pwsh" => windows_pwsh_candidates(), #[cfg(not(windows))] - "pwsh" => &["pwsh", "powershell", "/usr/local/bin/pwsh"], + "pwsh" => ["pwsh", "powershell", "/usr/local/bin/pwsh"] + .into_iter() + .map(str::to_string) + .collect(), #[cfg(windows)] - "powershell" => &[ - "powershell", - "pwsh", - r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", - r"C:\Program Files\PowerShell\7\pwsh.exe", - ], + "powershell" => windows_powershell_candidates(), #[cfg(not(windows))] - "powershell" => &["powershell", "pwsh", "/usr/local/bin/pwsh"], - "cmd" => &["cmd.exe", "cmd"], - _ => &[], + "powershell" => ["powershell", "pwsh", "/usr/local/bin/pwsh"] + .into_iter() + .map(str::to_string) + .collect(), + "cmd" => ["cmd.exe", "cmd"].into_iter().map(str::to_string).collect(), + _ => Vec::new(), }; let bin = candidates .iter() @@ -212,11 +251,7 @@ fn shell_basename(path: &str) -> Option { } fn shell_names_match(actual: &str, requested: &str) -> bool { - actual == requested - || matches!( - (actual, requested), - ("pwsh", "powershell") | ("powershell", "pwsh") - ) + actual == requested || matches!((actual, requested), ("pwsh", "powershell")) } fn shell_argv(bin: String) -> Vec { @@ -1713,6 +1748,27 @@ mod tests { assert_eq!(shell_type_of(&resolved[0]), shell_type_of(supplied)); } + #[test] + fn pwsh_requests_never_match_legacy_windows_powershell() { + assert!(shell_names_match("pwsh", "powershell")); + assert!(!shell_names_match("powershell", "pwsh")); + assert!( + windows_pwsh_candidates() + .iter() + .all(|candidate| shell_basename(candidate).as_deref() == Some("pwsh")) + ); + let generic = windows_powershell_candidates(); + let first_legacy = generic + .iter() + .position(|candidate| shell_basename(candidate).as_deref() == Some("powershell")) + .unwrap(); + assert!( + generic[..first_legacy] + .iter() + .all(|candidate| shell_basename(candidate).as_deref() == Some("pwsh")) + ); + } + #[test] fn unknown_model_shell_uses_codex_ultimate_fallback() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 86366819..f8b3dece 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,6 +145,8 @@ fn run_projects_list(cli: &Cli, args: &ProjectsListArgs) -> Result<(), String> { } fn main() { + #[cfg(windows)] + detach_scheduled_launcher_console(); if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new(codexify::tools::grep::WORKER_ARGUMENT)) { @@ -157,6 +159,26 @@ fn main() { run_main(); } +#[cfg(windows)] +fn detach_scheduled_launcher_console() { + if !scheduled_launcher_console_detach_requested(std::env::args_os()) { + return; + } + unsafe { + windows_sys::Win32::System::Console::FreeConsole(); + } +} + +#[cfg(any(windows, test))] +fn scheduled_launcher_console_detach_requested( + mut arguments: impl Iterator, +) -> bool { + let _ = arguments.next(); + arguments.next().as_deref() == Some(std::ffi::OsStr::new("service")) + && arguments.next().as_deref() == Some(std::ffi::OsStr::new("launch")) + && arguments.any(|argument| argument == "--scheduled-task") +} + #[tokio::main] async fn run_main() { // We build reqwest with `rustls-no-provider`, so a rustls crypto provider @@ -479,8 +501,40 @@ async fn run_service(cli: &Cli, command: ServiceCommand) -> anyhow::Result<()> { let config = config_path_for_service(cli).map_err(anyhow::Error::msg)?; service::run_supervisor(config).await } + ServiceCommand::Launch(_) => { + let config = config_path_for_service(cli).map_err(anyhow::Error::msg)?; + service::run_launcher(config).await + } ServiceCommand::WaitReady(args) => { service::wait_ready(args.port, std::time::Duration::from_millis(args.timeout_ms)).await } } } + +#[cfg(test)] +mod tests { + use super::scheduled_launcher_console_detach_requested; + + fn args<'a>(values: &'a [&'a str]) -> impl Iterator + 'a { + values.iter().map(std::ffi::OsString::from) + } + + #[test] + fn only_the_scheduled_launcher_marker_detaches_the_console() { + assert!(scheduled_launcher_console_detach_requested(args(&[ + "codexify", + "service", + "launch", + "--scheduled-task", + "--config", + r"C:\Users\test\codexify.config.json", + ]))); + for values in [ + &["codexify", "service", "launch"][..], + &["codexify", "service", "run", "--scheduled-task"][..], + &["codexify", "doctor", "--scheduled-task"][..], + ] { + assert!(!scheduled_launcher_console_detach_requested(args(values))); + } + } +} diff --git a/src/service.rs b/src/service.rs index 4ad709cb..88b24abb 100644 --- a/src/service.rs +++ b/src/service.rs @@ -926,21 +926,6 @@ fn encode_powershell_script(script: &str) -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } -#[cfg(any(target_os = "windows", test))] -fn windows_hidden_service_script(spec: &ServiceSpec) -> String { - let executable = powershell_quote(&spec.executable.to_string_lossy()); - let config = powershell_quote(&spec.config.to_string_lossy()); - let log = powershell_quote( - &spec - .home - .join(".codexify") - .join("logs") - .join(LOG_FILE) - .to_string_lossy(), - ); - windows_launcher_source(&executable, &config, &log) -} - #[cfg(any(windows, test))] fn windows_launcher_source(executable: &str, config: &str, log: &str) -> String { format!( @@ -999,34 +984,24 @@ while ($true) {{ #[cfg(any(target_os = "windows", test))] fn windows_action_arguments(spec: &ServiceSpec) -> String { format!( - "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{}\"", - windows_launcher_path(spec).display() + "service launch --scheduled-task --config \"{}\"", + spec.config.display() ) } -#[cfg(any(windows, test))] -fn windows_launcher_path(spec: &ServiceSpec) -> PathBuf { - spec.home.join(".codexify/service/supervisor.ps1") -} - -#[cfg(windows)] -fn write_windows_launcher(spec: &ServiceSpec) -> anyhow::Result<()> { - // Windows PowerShell 5.1 otherwise reads non-ASCII paths using the ANSI - // code page. The BOM is required for a file-based UTF-8 launcher. - write_definition( - &windows_launcher_path(spec), - &format!("\u{feff}{}", windows_hidden_service_script(spec)), - 0o600, - ) +#[cfg(target_os = "windows")] +fn windows_registration_path(spec: &ServiceSpec) -> PathBuf { + spec.home.join(".codexify/service/install.ps1") } #[cfg(any(target_os = "windows", test))] fn windows_install_script(spec: &ServiceSpec) -> String { + let executable = powershell_quote(&spec.executable.to_string_lossy()); let arguments = powershell_quote(&windows_action_arguments(spec)); let working_dir = powershell_quote(&spec.working_dir.to_string_lossy()); let task = powershell_quote(WINDOWS_TASK); format!( - "$ErrorActionPreference = 'Stop'\n$taskName = {task}\n$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue\nif ($existing) {{ Stop-ScheduledTask -InputObject $existing -ErrorAction SilentlyContinue }}\n$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name\n$engine = (Get-Process -Id $PID).Path\n$action = New-ScheduledTaskAction -Execute $engine -Argument {arguments} -WorkingDirectory {working_dir}\n$trigger = New-ScheduledTaskTrigger -AtLogOn -User $identity\n$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew\n$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited\nRegister-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null\nEnable-ScheduledTask -TaskName $taskName | Out-Null\nStart-ScheduledTask -TaskName $taskName\n" + "$ErrorActionPreference = 'Stop'\n$taskName = {task}\n$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue\nif ($existing) {{ Stop-ScheduledTask -InputObject $existing -ErrorAction SilentlyContinue }}\n$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name\n$action = New-ScheduledTaskAction -Execute {executable} -Argument {arguments} -WorkingDirectory {working_dir}\n$trigger = New-ScheduledTaskTrigger -AtLogOn -User $identity\n$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew\n$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited\nRegister-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null\nEnable-ScheduledTask -TaskName $taskName | Out-Null\nStart-ScheduledTask -TaskName $taskName\n" ) } @@ -1576,8 +1551,7 @@ fn platform_status() -> anyhow::Result { #[cfg(target_os = "windows")] fn platform_install(spec: &ServiceSpec) -> anyhow::Result<()> { - write_windows_launcher(spec)?; - let registration = windows_launcher_path(spec).with_file_name("install.ps1"); + let registration = windows_registration_path(spec); write_definition( ®istration, &format!("\u{feff}{}", windows_install_script(spec)), @@ -1587,13 +1561,7 @@ fn platform_install(spec: &ServiceSpec) -> anyhow::Result<()> { windows_file_command(®istration), "install the Codexify scheduled task", ) - .with_context(|| { - format!( - "registration script: {}; launcher: {}", - registration.display(), - windows_launcher_path(spec).display() - ) - })?; + .with_context(|| format!("registration script: {}", registration.display()))?; Ok(()) } @@ -1973,6 +1941,206 @@ impl Drop for WindowsChildJob { } } +#[cfg(windows)] +pub async fn run_launcher(config: PathBuf) -> anyhow::Result<()> { + if !config.is_absolute() { + bail!("service launcher requires an absolute --config path"); + } + let config = normalize_native_path(config); + validate_native_path(&config)?; + let executable = std::env::current_exe() + .context("locate the running codexify executable")? + .canonicalize() + .context("canonicalize the running codexify executable")?; + let executable = normalize_native_path(executable); + let log = Arc::new(StdMutex::new(RotatingLog::open( + log_path()?, + LOG_MAX_BYTES, + LOG_GENERATIONS, + )?)); + + launch_supervisor( + config, + executable, + log, + SUPERVISOR_POLICY, + shutdown_signal(), + ) + .await +} + +#[cfg(not(windows))] +pub async fn run_launcher(_config: PathBuf) -> anyhow::Result<()> { + bail!("the native service launcher is only supported on Windows") +} + +#[cfg(any(windows, test))] +async fn launch_supervisor( + config: PathBuf, + executable: PathBuf, + log: Arc>, + policy: SupervisorPolicy, + shutdown: F, +) -> anyhow::Result<()> +where + F: std::future::Future, +{ + append_event( + &log, + &format!( + "native launcher started with executable {} and config {}", + executable.display(), + config.display() + ), + ); + + tokio::pin!(shutdown); + let mut restart_delay = policy.restart_min_delay; + loop { + let mut command = TokioCommand::new(&executable); + command + .arg("service") + .arg("run") + .arg("--config") + .arg(&config) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + command.process_group(0); + #[cfg(windows)] + command.creation_flags(windows_server_creation_flags()); + + let started = Instant::now(); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + append_event( + &log, + &format!("failed to start the service supervisor: {error}"), + ); + tokio::select! { + _ = &mut shutdown => { + append_event(&log, "native launcher stopped"); + return Ok(()); + } + _ = tokio::time::sleep(restart_delay) => {} + } + restart_delay = (restart_delay * 2).min(policy.restart_max_delay); + continue; + } + }; + + #[cfg(windows)] + let mut child_job = Some( + match WindowsChildJob::new().and_then(|job| { + job.assign(&child)?; + Ok(job) + }) { + Ok(job) => job, + Err(error) => { + append_event( + &log, + &format!("failed to contain the service supervisor tree: {error}"), + ); + if let Some(pid) = child.id() { + taskkill(pid, true); + } + let _ = child.wait().await; + tokio::select! { + _ = &mut shutdown => { + append_event(&log, "native launcher stopped"); + return Ok(()); + } + _ = tokio::time::sleep(restart_delay) => {} + } + restart_delay = (restart_delay * 2).min(policy.restart_max_delay); + continue; + } + }, + ); + + let pid = child.id(); + append_event( + &log, + &format!( + "started service supervisor{}", + pid.map(|pid| format!(" with pid {pid}")) + .unwrap_or_default() + ), + ); + let stdout_task = child + .stdout + .take() + .map(|stdout| tokio::spawn(drain_to_log(stdout, log.clone()))); + let stderr_task = child + .stderr + .take() + .map(|stderr| tokio::spawn(drain_to_log(stderr, log.clone()))); + let wait = child.wait(); + tokio::pin!(wait); + + let status = tokio::select! { + status = &mut wait => Some(status), + _ = &mut shutdown => { + append_event(&log, "stopping the service supervisor after launcher shutdown request"); + #[cfg(windows)] + { + drop(child_job.take()); + let _ = (&mut wait).await; + } + #[cfg(not(windows))] + terminate_child(pid, &mut wait).await; + None + } + }; + + #[cfg(unix)] + if let Some(pid) = pid { + signal_process_group(pid, libc::SIGKILL); + } + #[cfg(windows)] + drop(child_job.take()); + + await_drain(stdout_task).await; + await_drain(stderr_task).await; + + let Some(status) = status else { + append_event(&log, "native launcher stopped"); + return Ok(()); + }; + match status { + Ok(status) => append_event(&log, &format!("service supervisor exited with {status}")), + Err(error) => append_event( + &log, + &format!("could not wait for the service supervisor: {error}"), + ), + } + + let delay = if started.elapsed() >= policy.stable_runtime { + policy.restart_min_delay + } else { + restart_delay + }; + restart_delay = (delay * 2).min(policy.restart_max_delay); + append_event( + &log, + &format!( + "restarting the service supervisor in {} seconds", + delay.as_secs_f64() + ), + ); + tokio::select! { + _ = &mut shutdown => { + append_event(&log, "native launcher stopped"); + return Ok(()); + } + _ = tokio::time::sleep(delay) => {} + } + } +} + async fn terminate_child( pid: Option, wait: &mut (impl std::future::Future> + Unpin), @@ -2524,17 +2692,14 @@ mod tests { assert!(script.contains("New-ScheduledTaskTrigger -AtLogOn")); assert!(script.contains("-RestartCount 999")); assert!(script.contains("New-TimeSpan -Minutes 1")); - assert!(script.contains("-WindowStyle Hidden")); - assert!(script.contains("-File")); + assert!(script.contains("service launch --scheduled-task --config")); assert!(!script.contains("-EncodedCommand")); - assert!(script.contains("supervisor.ps1")); - assert!(script.contains("(Get-Process -Id $PID).Path")); - assert!( - !script.contains("New-ScheduledTaskAction -Execute '/tmp/codexify-test/bin/codexify'") - ); - let launcher = windows_hidden_service_script(&spec); - assert!(launcher.contains(&powershell_quote(&spec.executable.to_string_lossy()))); - assert!(launcher.contains(&powershell_quote(&spec.config.to_string_lossy()))); + assert!(!script.contains("supervisor.ps1")); + assert!(!script.contains("(Get-Process -Id $PID).Path")); + assert!(script.contains(&format!( + "New-ScheduledTaskAction -Execute {}", + powershell_quote(&spec.executable.to_string_lossy()) + ))); assert_eq!(windows_server_creation_flags(), 0x0800_0000); } @@ -2856,16 +3021,10 @@ Write-Output 'ISOLATED' #[test] fn scheduled_task_definition_is_valid_powershell() { let root = tempfile::tempdir().unwrap(); - for (name, script) in [ - ( - "service-install.ps1", - windows_install_script(&spec(root.path())), - ), - ( - "service-launcher.ps1", - windows_hidden_service_script(&spec(root.path())), - ), - ] { + for (name, script) in [( + "service-install.ps1", + windows_install_script(&spec(root.path())), + )] { let path = root.path().join(name); fs::write(&path, script).unwrap(); let path = powershell_quote(&path.to_string_lossy()); @@ -3021,6 +3180,97 @@ Write-Output 'ISOLATED' assert_ne!(unsafe { libc::kill(descendant, 0) }, 0); } + #[cfg(unix)] + #[tokio::test] + async fn native_launcher_restarts_failed_supervisor_and_captures_output() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let config = root.path().join("codexify.config.json"); + let executable = root.path().join("codexify-fake"); + let count = root.path().join("starts"); + let arguments = root.path().join("arguments"); + let descendant = root.path().join("descendant"); + let log_path = root.path().join("codexify.log"); + fs::write(&config, "{}").unwrap(); + fs::write( + &executable, + format!( + "#!/bin/sh\ncount=0\n[ ! -f '{}' ] || count=$(cat '{}')\nnext=$((count + 1))\nsleep 30 &\ndescendant_tmp='{}.tmp.'$$\nprintf '%s\\n' $! > \"$descendant_tmp\"\nmv \"$descendant_tmp\" '{}'\narguments_tmp='{}.tmp.'$$\nprintf '%s\\n' \"$@\" > \"$arguments_tmp\"\nmv \"$arguments_tmp\" '{}'\ncount_tmp='{}.tmp.'$$\nprintf '%s\\n' $next > \"$count_tmp\"\nmv \"$count_tmp\" '{}'\nprintf 'fake supervisor run %s\\n' $next\nexit 7\n", + count.display(), + count.display(), + descendant.display(), + descendant.display(), + arguments.display(), + arguments.display(), + count.display(), + count.display() + ), + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let log = Arc::new(StdMutex::new( + RotatingLog::open(log_path.clone(), 1024 * 1024, 2).unwrap(), + )); + let policy = SupervisorPolicy { + restart_min_delay: Duration::from_millis(5), + restart_max_delay: Duration::from_millis(20), + stable_runtime: Duration::from_secs(1), + config_retry_delay: Duration::from_millis(5), + }; + + let count_for_shutdown = count.clone(); + tokio::time::timeout( + Duration::from_secs(5), + launch_supervisor(config.clone(), executable, log, policy, async move { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if fs::read_to_string(&count_for_shutdown) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .is_some_and(|starts| starts >= 2) + || Instant::now() >= deadline + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }), + ) + .await + .expect("native launcher hung while a descendant held its output pipe") + .unwrap(); + + let output = fs::read_to_string(&log_path).unwrap(); + let starts: usize = fs::read_to_string(count) + .unwrap_or_else(|error| panic!("{error}; service log:\n{output}")) + .trim() + .parse() + .unwrap(); + assert!( + starts >= 2, + "launcher started the supervisor only {starts} time(s)" + ); + assert_eq!( + fs::read_to_string(arguments).unwrap().trim(), + format!("service\nrun\n--config\n{}", config.display()) + ); + assert!(output.contains("fake supervisor run")); + assert!(output.contains("service supervisor exited with")); + assert!(output.contains("restarting the service supervisor")); + + let descendant: i32 = fs::read_to_string(descendant) + .unwrap() + .trim() + .parse() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while unsafe { libc::kill(descendant, 0) } == 0 && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_ne!(unsafe { libc::kill(descendant, 0) }, 0); + } + #[cfg(windows)] #[test] fn windows_service_paths_drop_verbatim_prefixes() { diff --git a/src/service/windows_launcher.rs b/src/service/windows_launcher.rs index a0283690..10482e27 100644 --- a/src/service/windows_launcher.rs +++ b/src/service/windows_launcher.rs @@ -1,5 +1,5 @@ -//! Upgrade the known legacy task action before start/enable, including when an -//! updater from an older release invokes the newly installed executable. +//! Upgrade recognized PowerShell task launchers to the native launcher before +//! start/enable, including when an updater invokes the newly installed binary. #[cfg(windows)] use super::{ @@ -7,46 +7,132 @@ use super::{ windows_action_arguments, }; -fn legacy_task(output: &str) -> anyhow::Result> { +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskAction { + execute: String, + #[serde(default)] + arguments: String, + #[serde(default)] + working_directory: String, +} + +#[derive(Debug)] +struct LegacyTask { + action: TaskAction, + executable: String, + config: String, +} + +fn powershell_host(path: &str) -> bool { + let base = path.rsplit(['\\', '/']).next().unwrap_or(path); + let base = if base.len() >= 4 && base[base.len() - 4..].eq_ignore_ascii_case(".exe") { + &base[..base.len() - 4] + } else { + base + }; + base.eq_ignore_ascii_case("powershell") || base.eq_ignore_ascii_case("pwsh") +} + +fn task_action(output: &str) -> anyhow::Result> { use anyhow::{Context, bail}; + if output.len() > 64 * 1024 { bail!("scheduled task launcher exceeds the command-line limit"); } - // Diagnostic text truncation must never be applied to structured output: - // current encoded launchers legitimately exceed the 4 KiB diagnostic tail. - let arguments: Option = - serde_json::from_str(output.trim()).context("parse the scheduled task launcher")?; - let Some((executable, config)) = arguments.as_deref().and_then(legacy_paths) else { + serde_json::from_str(output.trim()).context("parse the scheduled task launcher") +} + +fn legacy_task(output: &str) -> anyhow::Result> { + let Some(action) = task_action(output)? else { + return Ok(None); + }; + if !powershell_host(&action.execute) { + return Ok(None); + } + let Some((executable, config)) = legacy_paths(&action.arguments) else { return Ok(None); }; - Ok(Some((arguments.unwrap(), executable, config))) + Ok(Some(LegacyTask { + action, + executable, + config, + })) } fn legacy_paths(arguments: &str) -> Option<(String, String)> { - use base64::Engine; + legacy_paths_with_reader(arguments, |path| { + let expected = super::service_root() + .ok()? + .join("service") + .join("supervisor.ps1"); + if !same_file::is_same_file(path, expected).unwrap_or(false) { + return None; + } + read_bounded_launcher(path) + }) +} - let encoded = arguments.trim().strip_prefix( - "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand ", - )?; - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .ok()?; - if bytes.len() % 2 != 0 || bytes.len() > 32 * 1024 { +fn read_bounded_launcher(path: impl AsRef) -> Option { + use std::io::Read; + + const MAX_LAUNCHER_BYTES: u64 = 64 * 1024; + let path = path.as_ref(); + let metadata = std::fs::metadata(path).ok()?; + if !metadata.is_file() || metadata.len() > MAX_LAUNCHER_BYTES { return None; } - let units: Vec<_> = bytes - .as_chunks::<2>() - .0 - .iter() - .map(|b| u16::from_le_bytes(*b)) - .collect(); - let script = String::from_utf16(&units).ok()?.replace("\r\n", "\n"); + let mut contents = String::with_capacity(metadata.len() as usize); + std::fs::File::open(path) + .ok()? + .take(MAX_LAUNCHER_BYTES + 1) + .read_to_string(&mut contents) + .ok()?; + (contents.len() as u64 <= MAX_LAUNCHER_BYTES).then_some(contents) +} + +fn legacy_paths_with_reader( + arguments: &str, + read_launcher: impl Fn(&str) -> Option, +) -> Option<(String, String)> { + use base64::Engine; + + const ENCODED_PREFIX: &str = "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand "; + const FILE_PREFIX: &str = + "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "; + + let arguments = arguments.trim(); + if let Some(encoded) = arguments.strip_prefix(ENCODED_PREFIX) { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + if bytes.len() % 2 != 0 || bytes.len() > 32 * 1024 { + return None; + } + let units = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|bytes| u16::from_le_bytes(*bytes)) + .collect::>(); + return launcher_script_paths(&String::from_utf16(&units).ok()?); + } + + let path = arguments.strip_prefix(FILE_PREFIX)?; + let path = path.strip_prefix('"')?.strip_suffix('"')?; + launcher_script_paths(&read_launcher(path)?) +} + +fn launcher_script_paths(script: &str) -> Option<(String, String)> { + let script = script + .strip_prefix('\u{feff}') + .unwrap_or(script) + .replace("\r\n", "\n"); if script.starts_with("# Codexify supervisor launcher v2\n") { let literal = |name: &str| { let pattern = regex::Regex::new(&format!(r"(?m)^\${name} = ('(?:[^'\r\n]|'')+')$")).ok()?; - let value = pattern.captures(&script)?.get(1)?.as_str(); - Some(value) + Some(pattern.captures(&script)?.get(1)?.as_str()) }; let executable = literal("serviceExecutable")?; let config = literal("serviceConfig")?; @@ -59,13 +145,14 @@ fn legacy_paths(arguments: &str) -> Option<(String, String)> { config[1..config.len() - 1].replace("''", "'"), )); } + let invocation = script .trim_end() .strip_prefix("$ErrorActionPreference = 'Stop'\n")? .strip_suffix("\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }")?; let pattern = regex::Regex::new(r"^& '((?:[^'\r\n]|'')+)' service run --config '((?:[^'\r\n]|'')+)'$") - .unwrap(); + .ok()?; let paths = pattern.captures(invocation)?; Some((paths[1].replace("''", "'"), paths[2].replace("''", "'"))) } @@ -76,25 +163,29 @@ pub(super) fn refresh() -> anyhow::Result<()> { let task_name = powershell_quote(WINDOWS_TASK); let query = format!( - "$ErrorActionPreference='Stop'\n$ProgressPreference='SilentlyContinue'\n$task=Get-ScheduledTask -TaskName {task_name} -ErrorAction SilentlyContinue\nif (-not $task -or @($task.Actions).Count -ne 1) {{ 'null'; exit 0 }}\nConvertTo-Json -InputObject $task.Actions[0].Arguments -Compress\n" + "$ErrorActionPreference='Stop'\n$ProgressPreference='SilentlyContinue'\n$task=Get-ScheduledTask -TaskName {task_name} -ErrorAction SilentlyContinue\nif (-not $task -or @($task.Actions).Count -ne 1) {{ 'null'; exit 0 }}\n$action=$task.Actions[0]\n[ordered]@{{ execute=[string]$action.Execute; arguments=[string]$action.Arguments; workingDirectory=[string]$action.WorkingDirectory }} | ConvertTo-Json -Compress\n" ); let output = command_output( powershell_command(&query), "inspect the scheduled task launcher", )?; - let Some((arguments, executable, config)) = legacy_task(&output)? else { - return Ok(()); // Current launchers and custom task actions are preserved. + let Some(legacy) = legacy_task(&output)? else { + return Ok(()); }; let current = std::env::current_exe().context("locate the service executable")?; - if !same_file::is_same_file(&executable, ¤t).unwrap_or(false) { + if !same_file::is_same_file(&legacy.executable, ¤t).unwrap_or(false) { return Ok(()); } - let spec = ServiceSpec::resolve(std::path::Path::new(&config))?; - super::write_windows_launcher(&spec)?; - let expected = powershell_quote(&arguments); - let replacement = powershell_quote(&windows_action_arguments(&spec)); + + let spec = ServiceSpec::resolve(std::path::Path::new(&legacy.config))?; + let expected_execute = powershell_quote(&legacy.action.execute); + let expected_arguments = powershell_quote(&legacy.action.arguments); + let expected_working_directory = powershell_quote(&legacy.action.working_directory); + let replacement_execute = powershell_quote(&spec.executable.to_string_lossy()); + let replacement_arguments = powershell_quote(&windows_action_arguments(&spec)); + let replacement_working_directory = powershell_quote(&spec.working_dir.to_string_lossy()); let update = format!( - "$ErrorActionPreference='Stop'\n$ProgressPreference='SilentlyContinue'\n$task=Get-ScheduledTask -TaskName {task_name} -ErrorAction Stop\nif (@($task.Actions).Count -ne 1 -or $task.Actions[0].Arguments -cne {expected}) {{ throw 'The scheduled task changed while updating its launcher; retry the service command' }}\n$task.Actions[0].Arguments={replacement}\nSet-ScheduledTask -TaskName {task_name} -Action $task.Actions | Out-Null\n" + "$ErrorActionPreference='Stop'\n$ProgressPreference='SilentlyContinue'\n$task=Get-ScheduledTask -TaskName {task_name} -ErrorAction Stop\nif (@($task.Actions).Count -ne 1) {{ throw 'The scheduled task changed while updating its launcher; retry the service command' }}\n$current=$task.Actions[0]\nif ([string]$current.Execute -cne {expected_execute} -or [string]$current.Arguments -cne {expected_arguments} -or [string]$current.WorkingDirectory -cne {expected_working_directory}) {{ throw 'The scheduled task changed while updating its launcher; retry the service command' }}\n$action=New-ScheduledTaskAction -Execute {replacement_execute} -Argument {replacement_arguments} -WorkingDirectory {replacement_working_directory}\nSet-ScheduledTask -TaskName {task_name} -Action $action | Out-Null\n" ); command_output( powershell_command(&update), @@ -107,13 +198,19 @@ pub(super) fn refresh() -> anyhow::Result<()> { mod tests { use super::*; - fn arguments(script: &str) -> String { + fn encoded_arguments(script: &str) -> String { format!( "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand {}", super::super::encode_powershell_script(script) ) } + fn file_arguments(path: &str) -> String { + format!( + "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{path}\"" + ) + } + #[test] fn legacy_task_paths_are_decoded_without_executing_powershell() { let executable = r"C:\Users\O'Brien\Program & Files\app.exe"; @@ -124,29 +221,17 @@ mod tests { super::super::powershell_quote(config) ); assert_eq!( - legacy_paths(&arguments(&script)), + legacy_paths_with_reader(&encoded_arguments(&script), |_| None), Some((executable.into(), config.into())) ); assert_eq!( - legacy_paths(&arguments(&script.replace('\n', "\r\n"))), + legacy_paths_with_reader(&encoded_arguments(&script.replace('\n', "\r\n")), |_| None), Some((executable.into(), config.into())) ); } #[test] - fn current_custom_and_malformed_launchers_are_not_rewritten() { - for script in [ - "# Codexify supervisor launcher v2\n$serviceConfig='custom'", - "$ErrorActionPreference = 'Stop'\n& 'app.exe' service run --config $env:PRIVATE_CONFIG\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n", - "$ErrorActionPreference = 'Stop'\n& 'app.exe' service run --config 'config'\nRemove-Item 'unrelated'\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n", - ] { - assert!(legacy_paths(&arguments(script)).is_none()); - } - assert!(legacy_paths("-EncodedCommand not-base64").is_none()); - } - - #[test] - fn recognized_v2_payloads_upgrade_but_modified_scripts_are_preserved() { + fn recognized_v2_file_launcher_upgrades_but_modified_scripts_are_preserved() { let executable = r"C:\Users\O'Brien\app.exe"; let config = r"C:\Users\O'Brien\custom.json"; let script = super::super::windows_launcher_source( @@ -154,23 +239,85 @@ mod tests { &super::super::powershell_quote(config), "'C:\\Users\\test\\service.log'", ); + let arguments = file_arguments(r"C:\Users\test\supervisor.ps1"); assert_eq!( - legacy_paths(&arguments(&script)), + legacy_paths_with_reader(&arguments, |_| Some(format!("\u{feff}{script}"))), Some((executable.into(), config.into())) ); - assert!(legacy_paths(&arguments(&(script + "\nWrite-Output 'custom'\n"))).is_none()); assert!( - legacy_paths("-NoLogo -NoProfile -File \"C:\\Users\\test\\supervisor.ps1\"").is_none() + legacy_paths_with_reader(&arguments, |_| { + Some(format!("{script}\nWrite-Output 'custom'\n")) + }) + .is_none() ); } #[test] - fn current_launcher_queries_larger_than_the_diagnostic_tail_are_preserved() { - let script = format!("# Codexify supervisor launcher v2\n{}", "x".repeat(3000)); - let query = serde_json::to_string(&arguments(&script)).unwrap(); - assert!(query.len() > 4096); - assert!(legacy_task(&query).unwrap().is_none()); + fn current_custom_and_malformed_launchers_are_not_rewritten() { + for script in [ + "# Codexify supervisor launcher v2\n$serviceConfig='custom'", + "$ErrorActionPreference = 'Stop'\n& 'app.exe' service run --config $env:PRIVATE_CONFIG\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n", + "$ErrorActionPreference = 'Stop'\n& 'app.exe' service run --config 'config'\nRemove-Item 'unrelated'\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n", + ] { + assert!(legacy_paths_with_reader(&encoded_arguments(script), |_| None).is_none()); + } + assert!(legacy_paths_with_reader("-EncodedCommand not-base64", |_| None).is_none()); + assert!( + legacy_paths_with_reader( + "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File unquoted.ps1", + |_| Some("ignored".into()) + ) + .is_none() + ); + } + + #[test] + fn only_exact_powershell_actions_are_migrated() { + let script = "$ErrorActionPreference = 'Stop'\n& 'app.exe' service run --config 'config.json'\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n"; + let arguments = encoded_arguments(script); + for execute in [ + "powershell.exe", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "pwsh.exe", + ] { + let action = serde_json::json!({ + "execute": execute, + "arguments": arguments, + "workingDirectory": r"C:\work" + }); + let legacy = legacy_task(&action.to_string()).unwrap().unwrap(); + assert_eq!(legacy.executable, "app.exe"); + assert_eq!(legacy.config, "config.json"); + assert_eq!(legacy.action.working_directory, r"C:\work"); + } + let custom = serde_json::json!({ + "execute": "custom.exe", + "arguments": arguments, + "workingDirectory": r"C:\work" + }); + assert!(legacy_task(&custom.to_string()).unwrap().is_none()); + } + + #[test] + fn launcher_queries_are_bounded_and_nullable() { + let action = serde_json::json!({ + "execute": "powershell.exe", + "arguments": "x".repeat(3000), + "workingDirectory": r"C:\work" + }); + assert!(legacy_task(&action.to_string()).unwrap().is_none()); assert!(legacy_task("null").unwrap().is_none()); assert!(legacy_task("truncated-query").is_err()); + assert!(legacy_task(&format!("\"{}\"", "x".repeat(70 * 1024))).is_err()); + } + + #[test] + fn file_launcher_reads_are_bounded() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("supervisor.ps1"); + std::fs::write(&path, "x".repeat(64 * 1024)).unwrap(); + assert_eq!(read_bounded_launcher(&path).unwrap().len(), 64 * 1024); + std::fs::write(&path, "x".repeat(64 * 1024 + 1)).unwrap(); + assert!(read_bounded_launcher(&path).is_none()); } }