Skip to content

Commit dd6ae85

Browse files
committed
CLI service: automate the Windows Task Scheduler backend
Windows was a printed scaffold; make `executor service install` actually register and supervise the daemon, matching macOS (launchd) and Linux (systemd). Verified end-to-end on a real Windows host: install works, the daemon auto-starts after a reboot with no interactive login, and a registered integration's data survives the reboot. - Task Scheduler backend (S4U + AtStartup): runs the daemon as the user, at boot, with no stored password and no interactive logon — the Windows analog of launchd RunAtLoad / systemd linger. A `.cmd` wrapper carries the supervised env (Task Scheduler has no env field); values are sanitized for cmd.exe (strip `"`, double `%`) so a hostile PATH entry can't inject a command at boot. Registration runs via PowerShell -EncodedCommand. - isDevCliEntrypoint: recognize Bun's Windows embedded-binary path (`B:\~BUN\root\...`), anchored to a drive root, so a real executor.exe is not misread as a dev checkout and refused. - `executor call <path> @file.json`: read a tool's JSON input from a file, the cross-platform equivalent of `"$(cat f)"` — PowerShell mangles inline JSON passed to a native binary. resolveToolInvocation moves to tooling.ts. - win32 install now flows through the automated path (writes the 0600 service.key, waits for reachability) instead of printing manual steps. Adds unit tests for dev-entrypoint detection, the wrapper/register-script generators, cmd value sanitization, and the @file path.
1 parent 6aad883 commit dd6ae85

7 files changed

Lines changed: 404 additions & 95 deletions

File tree

apps/cli/src/daemon.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,38 @@ import { createServer, type Server } from "node:http";
33
import type { AddressInfo } from "node:net";
44
import * as Effect from "effect/Effect";
55

6-
import { canAutoStartLocalDaemonForHost, isExecutorServerReachable } from "./daemon";
6+
import {
7+
canAutoStartLocalDaemonForHost,
8+
isDevCliEntrypoint,
9+
isExecutorServerReachable,
10+
} from "./daemon";
11+
12+
describe("isDevCliEntrypoint", () => {
13+
it("treats source entrypoints as dev", () => {
14+
expect(isDevCliEntrypoint("/Users/x/src/executor/apps/cli/src/main.ts")).toBe(true);
15+
expect(isDevCliEntrypoint("/Users/x/dist/main.js")).toBe(true);
16+
});
17+
18+
it("treats compiled single-file binaries as NOT dev (both Unix and Windows)", () => {
19+
// Bun's embedded filesystem: `/$bunfs/...` on Unix, `B:\~BUN\...` on Windows.
20+
// Missing the Windows form made a real `executor.exe` look like a dev
21+
// checkout, so `service install` wrongly refused on Windows.
22+
expect(isDevCliEntrypoint("/$bunfs/root/main.js")).toBe(false);
23+
expect(isDevCliEntrypoint("B:/~BUN/root/main.js")).toBe(false);
24+
expect(isDevCliEntrypoint("B:\\~BUN\\root\\main.js")).toBe(false);
25+
});
26+
27+
it("only treats a DRIVE-ROOTED ~BUN as compiled (a ~BUN dir mid-tree stays dev)", () => {
28+
// The Windows bunfs root is `<drive>:\~BUN\...`; a dev checkout that merely
29+
// contains a `~BUN` directory must not be misread as a compiled binary.
30+
expect(isDevCliEntrypoint("/home/user/~BUN/project/src/main.ts")).toBe(true);
31+
expect(isDevCliEntrypoint("C:/Users/dev/~BUN/src/main.ts")).toBe(true);
32+
});
33+
34+
it("is false when no entrypoint is known", () => {
35+
expect(isDevCliEntrypoint(undefined)).toBe(false);
36+
});
37+
});
738

839
describe("canAutoStartLocalDaemonForHost", () => {
940
it("allows loopback hosts", () => {

apps/cli/src/daemon.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,32 @@ export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): Parsed
5454
// ---------------------------------------------------------------------------
5555

5656
const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
57-
const BUN_EMBEDDED_ENTRYPOINT_PREFIX = "/$bunfs/";
5857

5958
export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>
6059
LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());
6160

61+
/**
62+
* Bun's compiled-binary embedded filesystem root, drive-rooted on Windows
63+
* (`B:\~BUN\root\...`, argv normalized to `B:/~BUN/root/...`). Anchored to a
64+
* drive prefix so a dev checkout that merely *contains* a `~BUN` directory
65+
* isn't misread as a compiled binary.
66+
*/
67+
const WINDOWS_BUNFS_ENTRYPOINT = /^[a-z]:\/~BUN\//i;
68+
69+
/**
70+
* Whether the process is running from the dev source (`bun run src/main.ts`)
71+
* rather than a compiled single-file binary. A compiled binary runs from Bun's
72+
* embedded filesystem, whose entrypoint is `/$bunfs/root/main.js` on Unix but
73+
* `B:\~BUN\root\main.js` (argv like `B:/~BUN/root/main.js`) on Windows — match
74+
* BOTH. Missing the Windows form made a real `executor.exe` look like a dev
75+
* checkout, so `service install` refused on Windows. (Found by a real EC2
76+
* Windows test.)
77+
*/
6278
export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => {
6379
if (!scriptPath) return false;
64-
if (scriptPath.startsWith(BUN_EMBEDDED_ENTRYPOINT_PREFIX)) return false;
65-
return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js");
80+
const normalized = scriptPath.replaceAll("\\", "/");
81+
if (normalized.startsWith("/$bunfs/") || WINDOWS_BUNFS_ENTRYPOINT.test(normalized)) return false;
82+
return normalized.endsWith(".ts") || normalized.endsWith(".js");
6683
};
6784

6885
export const isExecutorServerReachable = (

apps/cli/src/main.ts

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ import {
114114
} from "./server-profile";
115115
import {
116116
buildResumeContentTemplate,
117-
buildToolPath,
118117
buildDescribeToolCode,
119118
filterToolPathChildren,
120119
buildInvokeToolCode,
@@ -126,6 +125,7 @@ import {
126125
inspectToolPath,
127126
normalizeCliErrorText,
128127
parseJsonObjectInput,
128+
resolveToolInvocation,
129129
sanitizeCliOutputText,
130130
shellQuoteArg,
131131
} from "./tooling";
@@ -1602,38 +1602,6 @@ const runCallHelp = (
16021602
});
16031603
}).pipe(Effect.mapError(toError));
16041604

1605-
const resolveToolInvocation = (input: {
1606-
rawPathParts: ReadonlyArray<string>;
1607-
}): Effect.Effect<{ path: string; args: Record<string, unknown> }, Error> =>
1608-
Effect.gen(function* () {
1609-
if (!Array.isArray(input.rawPathParts)) {
1610-
return yield* Effect.fail(
1611-
new Error("Invalid tool invocation: path parts were not parsed as an array"),
1612-
);
1613-
}
1614-
1615-
const maybeJsonArg = input.rawPathParts.at(-1)?.trim();
1616-
const hasInlineJsonArg = maybeJsonArg !== undefined && maybeJsonArg.startsWith("{");
1617-
const pathParts = hasInlineJsonArg ? input.rawPathParts.slice(0, -1) : input.rawPathParts;
1618-
const args = hasInlineJsonArg ? yield* parseJsonObjectInput(maybeJsonArg) : {};
1619-
1620-
if (pathParts.some((part) => part.trim().startsWith("-"))) {
1621-
return yield* Effect.fail(
1622-
new Error(
1623-
"Tool invocation no longer accepts flags. Use: executor call <path...> '{...json...}'",
1624-
),
1625-
);
1626-
}
1627-
1628-
const path = yield* Effect.try({
1629-
try: () => buildToolPath(pathParts),
1630-
catch: (cause) =>
1631-
cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`),
1632-
});
1633-
1634-
return { path, args };
1635-
});
1636-
16371605
// ---------------------------------------------------------------------------
16381606
// Commands
16391607
// ---------------------------------------------------------------------------
@@ -2198,7 +2166,7 @@ const serviceInstallCommand = Command.make(
21982166

21992167
const backend = getServiceBackend();
22002168
if (!backend.automated) {
2201-
// win32 / unsupported: the backend surfaces manual steps via its error.
2169+
// Unsupported platforms surface their manual steps via the install error.
22022170
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
22032171
return;
22042172
}

apps/cli/src/service.test.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { describe, expect, it } from "@effect/vitest";
22

33
import {
4+
cmdSetValue,
45
generateLaunchdPlist,
56
generateSystemdUnit,
7+
generateWindowsDaemonWrapper,
8+
generateWindowsRegisterScript,
69
getServiceBackend,
7-
windowsScheduledTaskCommand,
810
} from "./service";
911

1012
describe("service unit generation", () => {
@@ -76,15 +78,50 @@ describe("service unit generation", () => {
7678
expect(unit).not.toContain("EXECUTOR_AUTH_PASSWORD");
7779
});
7880

79-
it("produces a Windows scheduled-task command", () => {
80-
const cmd = windowsScheduledTaskCommand({
81-
executablePath: "C:/Program Files/Executor/executor.exe",
82-
port: 4789,
83-
version: "1.5.10",
81+
it("bakes the supervised env into the Windows wrapper .cmd", () => {
82+
const wrapper = generateWindowsDaemonWrapper(
83+
{
84+
executablePath: "C:\\Program Files\\Executor\\executor.exe",
85+
port: 4789,
86+
version: "1.5.10",
87+
},
88+
"C:\\Users\\x\\.executor",
89+
"C:\\Users\\x\\.executor\\logs",
90+
);
91+
// Task Scheduler can't set env, so it rides as `set` lines in the wrapper.
92+
expect(wrapper).toContain('set "EXECUTOR_SUPERVISED=1"');
93+
expect(wrapper).toContain('set "EXECUTOR_DATA_DIR=C:\\Users\\x\\.executor"');
94+
expect(wrapper).toContain(
95+
'"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789',
96+
);
97+
expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"');
98+
// The secret is never baked into the wrapper — the daemon reads service.key.
99+
expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD");
100+
});
101+
102+
it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => {
103+
// A `"` in PATH would close the `set "PATH=..."` quote early and let a
104+
// `& cmd &` fragment run at boot as the user; strip it (illegal in a path
105+
// anyway). A `%` would re-expand against the boot environment; double it.
106+
expect(cmdSetValue('C:\\a" & evil & "C:\\b')).toBe("C:\\a & evil & C:\\b");
107+
expect(cmdSetValue("C:\\tools\\%LOCALAPPDATA%\\bin")).toBe("C:\\tools\\%%LOCALAPPDATA%%\\bin");
108+
expect(cmdSetValue("C:\\Program Files\\node")).toBe("C:\\Program Files\\node");
109+
// The sanitized value, embedded in a `set` line, can't break out of quotes.
110+
expect(`set "PATH=${cmdSetValue('a"&b')}"`).not.toMatch(/"\s*&/);
111+
});
112+
113+
it("registers a boot-triggered S4U task (the reboot-survival contract)", () => {
114+
const script = generateWindowsRegisterScript({
115+
taskName: "ExecutorDaemon",
116+
wrapperPath: "C:\\Users\\x\\.executor\\server-control\\run-daemon.cmd",
117+
userId: "x",
84118
});
85-
expect(cmd).toContain("schtasks /Create");
86-
expect(cmd).toContain("ONLOGON");
87-
expect(cmd).toContain("--port 4789");
119+
// S4U + AtStartup = run as the user, at boot, no stored password, no logon.
120+
expect(script).toContain("-LogonType S4U");
121+
expect(script).toContain("New-ScheduledTaskTrigger -AtStartup");
122+
expect(script).toContain("-RestartCount 3");
123+
expect(script).toContain("Register-ScheduledTask -TaskName 'ExecutorDaemon'");
124+
expect(script).toContain("Start-ScheduledTask -TaskName 'ExecutorDaemon'");
88125
});
89126
});
90127

@@ -99,10 +136,10 @@ describe("service backend dispatch", () => {
99136
expect(getServiceBackend("linux").platform).toBe("linux");
100137
});
101138

102-
it("is a guided (non-automated) backend on windows", () => {
139+
it("selects Task Scheduler on windows (automated)", () => {
103140
const backend = getServiceBackend("win32");
104141
expect(backend.platform).toBe("win32");
105-
expect(backend.automated).toBe(false);
142+
expect(backend.automated).toBe(true);
106143
});
107144

108145
it("falls back to unsupported on other platforms", () => {

0 commit comments

Comments
 (0)