Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions apps/cli/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,18 +501,24 @@ const buildPreviewTarballs = async (binaries: Record<string, string>) => {
}
};

// Resolve a comma-separated list of target package names (e.g.
// "executor-windows-x64") to Targets. Shared by `--target` and the
// EXECUTOR_PREVIEW_TARGETS env used by the preview-wrapper CI job.
const resolveTargetsFromEnv = (env: string | undefined): Target[] => {
if (!env) throw new Error("EXECUTOR_PREVIEW_TARGETS must be set (comma-separated package names)");
if (!env) throw new Error("No build targets given (comma-separated package names)");
const names = env
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const resolved = names.map((name) => {
const match = ALL_TARGETS.find((t) => targetPackageName(t) === name);
if (!match) throw new Error(`Unknown preview target: ${name}`);
if (!match) {
const valid = ALL_TARGETS.map(targetPackageName).join(", ");
throw new Error(`Unknown build target: ${name}. Expected one of: ${valid}`);
}
return match;
});
if (resolved.length === 0) throw new Error("EXECUTOR_PREVIEW_TARGETS resolved to an empty list");
if (resolved.length === 0) throw new Error("Build target list resolved to empty");
return resolved;
};

Expand Down Expand Up @@ -919,6 +925,11 @@ const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
single: { type: "boolean", default: false },
// Build a specific target (or comma-separated set) by package name, e.g.
// `--target executor-windows-x64`. Used by the e2e VM harness to compile
// the guest's binary; without it `binary` builds the current platform
// (`--single`) or all targets.
target: { type: "string" },
mode: { type: "string", default: "production" },
},
allowPositionals: true,
Expand All @@ -932,7 +943,11 @@ if (mode !== "production" && mode !== "development") {
}

if (command === "binary") {
const targets = values.single ? ALL_TARGETS.filter(isCurrentPlatform) : ALL_TARGETS;
const targets = values.target
? resolveTargetsFromEnv(values.target)
: values.single
? ALL_TARGETS.filter(isCurrentPlatform)
: ALL_TARGETS;
const binaries = await buildBinaries(targets, mode);
await buildWrapperPackage(binaries);
} else if (command === "preview") {
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("service unit generation", () => {

it("never leaks the auth password into the unit", () => {
const plist = generateLaunchdPlist(launchdInput);
// The secret lives in the 0600 service.key, never in the plist env.
// No secret in the unit — the daemon reads the bearer from auth.json at boot.
expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD");
});

Expand Down Expand Up @@ -95,7 +95,7 @@ describe("service unit generation", () => {
'"C:\\Program Files\\Executor\\executor.exe" daemon run --foreground --port 4789',
);
expect(wrapper).toContain('1>> "C:\\Users\\x\\.executor\\logs\\daemon.log"');
// The secret is never baked into the wrapper — the daemon reads service.key.
// No secret in the wrapper — the daemon reads the bearer from auth.json at boot.
expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD");
});

Expand Down
128 changes: 128 additions & 0 deletions e2e/cli/service-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// The supervised daemon's durability as a WATCHABLE terminal recording: register
// a REAL integration, REBOOT the daemon's machine for real (the on-screen
// spinner runs for the actual reboot), then show the integration still there.
// Same assertions as restart-persistence — but filmed, so you can press play
// instead of trusting a green check. Runs against the cli-* VM targets.
import { randomBytes } from "node:crypto";
import { join } from "node:path";

import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { composePluginApi } from "@executor-js/api/server";
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";

import { withChatTheater } from "../src/clients/chat-theater";
import { scenario } from "../src/scenario";
import { Api, Cli, Restart, RunDir, Target } from "../src/services";

const api = composePluginApi([openApiHttpPlugin()] as const);

/** Inline OpenAPI 3 spec with a single GET /ping (its server is never called). */
const pingSpec = JSON.stringify({
openapi: "3.0.3",
info: { title: "Reboot Lifecycle API", version: "1.0.0" },
servers: [{ url: "http://127.0.0.1:59998" }],
paths: {
"/ping": {
get: {
operationId: "getPing",
summary: "Liveness ping",
responses: { "200": { description: "pong" } },
},
},
},
});

scenario(
"Supervised daemon · an integration survives a real machine reboot (recorded)",
{},
Effect.gen(function* () {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const target = yield* Target;
const restart = yield* Restart;
const { client } = yield* Api;
const cli = yield* Cli;
const runDir = yield* RunDir;

const slug = `reboot-film-${randomBytes(4).toString("hex")}`;

yield* withChatTheater(
cli,
{ title: "executor — supervised daemon", record: join(runDir, "terminal.cast") },
(chat) =>
Effect.gen(function* () {
yield* chat.user("If this machine reboots, do my connected integrations survive?");
yield* chat.assistant(
"Let's prove it — register a real integration, reboot the daemon's machine for real, then check it's still there.",
);

const before = yield* client(api, yield* target.newIdentity());

const added = yield* chat.tool(
{
name: "executor call executor.openapi.addSpec",
input: `slug: ${slug}\nspec: inline OpenAPI (GET /ping)`,
result: (a) => `registered — ${a.toolCount} tool(s)`,
},
before.openapi.addSpec({
payload: {
spec: { kind: "blob", value: pingSpec },
slug,
authenticationTemplate: [],
},
}),
);
expect(added.toolCount, "the spec registered with tools").toBeGreaterThan(0);

const listed = yield* chat.tool(
{
name: "executor tools sources",
result: (rows) =>
rows.map((r) => String(r.slug)).includes(slug) ? `${slug} is listed` : "NOT listed",
},
before.integrations.list(),
);
expect(
listed.map((i) => String(i.slug)),
"listed before the reboot",
).toContain(slug);

// The spinner here runs for the ENTIRE real reboot — the supervised
// service must auto-start at boot for this to ever return.
yield* chat.tool(
{
name: "reboot the daemon's machine",
input: "guest OS reboot — the OS service manager must auto-start the daemon at boot",
result: () => "back online; daemon auto-started",
},
restart(),
);

const after = yield* client(api, yield* target.newIdentity());
yield* Effect.ensuring(
Effect.gen(function* () {
const survived = yield* chat.tool(
{
name: "executor tools sources",
result: (rows) =>
rows.map((r) => String(r.slug)).includes(slug)
? `${slug} SURVIVED the reboot`
: "VANISHED",
},
after.integrations.list(),
);
expect(
survived.map((i) => String(i.slug)),
"survived the reboot",
).toContain(slug);
yield* chat.assistant(
"It survived — the OS restarted the daemon at boot and its data was intact.",
);
}),
// Shared guest, but ephemeral; still, never leave the spec behind.
after.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
);
}),
);
}),
);
182 changes: 182 additions & 0 deletions e2e/desktop/supervised-attach.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Desktop-only, on camera: the app ATTACHES to an already-running OS-supervised
// daemon instead of spawning its own sidecar. We start a real supervised gateway
// (the desktop sidecar server in EXECUTOR_SUPERVISED mode → it self-publishes a
// manifest of kind "cli-daemon"), launch the Electron app pointed at the same
// HOME, and prove it attached: the manifest still names OUR daemon's pid (a
// spawned sidecar would be a fresh pid + kind "desktop-sidecar"). The recording
// (session.mp4 + screenshots) is the artifact; the waits are the assertions. No
// launchd — only a throwaway home and one short-lived daemon process.
import { type ChildProcess, execFile, spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import net from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { _electron } from "playwright";

import { scenario } from "../src/scenario";
import { RunDir } from "../src/services";
import { waitForHttp } from "../setup/boot";

const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
const sidecarServer = join(appDir, "src/sidecar/server.ts");
const clientDir = join(repoRoot, "apps/local/dist");
const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string;

const freePort = (): Promise<number> =>
new Promise((resolve, reject) => {
const srv = net.createServer();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const port = (srv.address() as net.AddressInfo).port;
srv.close(() => resolve(port));
});
});
Comment on lines +32 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Duplicate freePort implementation

The identical freePort() function already exists verbatim in e2e/src/vm/tart.ts. Both files open a port-0 server, grab the address, close the listener, and return the number. e2e/AGENTS.md directs "extract shared logic only when the shared behavior is real" — here the behaviour is exactly the same. Extracting it to a small shared utility (e.g. e2e/src/vm/types.ts or a dedicated e2e/src/net.ts) would avoid the copy diverging if the timeout or bind-address ever needs updating.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


interface Manifest {
readonly kind: string;
readonly pid: number;
}

interface DaemonStart {
readonly child: ChildProcess;
readonly ready: boolean;
readonly stderr: string;
}

/** Spawn the supervised gateway; resolves once it announces EXECUTOR_READY (or
* times out / exits early, with `ready: false`). The caller asserts readiness,
* so the executor only ever resolves. */
const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise<DaemonStart> =>
new Promise((resolve) => {
const child = spawn("bun", ["run", sidecarServer], {
cwd: repoRoot,
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
const settle = (ready: boolean) => resolve({ child, ready, stderr });
const timer = setTimeout(() => settle(false), 60_000);
child.stdout.on("data", (chunk: Buffer) => {
if (chunk.toString().includes("EXECUTOR_READY:")) {
clearTimeout(timer);
settle(true);
}
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("exit", () => {
clearTimeout(timer);
settle(false);
});
});

scenario(
"Desktop · attaches to the OS-supervised daemon instead of spawning a sidecar",
{ timeout: 240_000 },
Effect.gen(function* () {
const runDir = yield* RunDir;
yield* Effect.promise(() => run(runDir));
}),
);

const run = async (runDir: string) => {
const home = mkdtempSync(join(tmpdir(), "executor-attach-e2e-"));
const dataDir = join(home, ".executor");
const manifestPath = join(dataDir, "server-control", "server.json");
const videoTmp = join(runDir, ".video-tmp");
const port = await freePort();

let daemon: ChildProcess | undefined;
let app: Awaited<ReturnType<typeof _electron.launch>> | undefined;
let stepIndex = 0;

try {
const started = await startSupervisedDaemon({
...process.env,
HOME: home,
EXECUTOR_SUPERVISED: "1",
EXECUTOR_DATA_DIR: dataDir,
EXECUTOR_PORT: String(port),
EXECUTOR_HOST: "127.0.0.1",
EXECUTOR_AUTH_TOKEN: "supervised-attach-film",
EXECUTOR_CLIENT_DIR: clientDir,
});
daemon = started.child;
expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true);
await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 });

const daemonManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;
expect(daemonManifest.kind, "the running daemon advertises itself as cli-daemon").toBe(
"cli-daemon",
);
const daemonPid = daemonManifest.pid;

app = await _electron.launch({
executablePath: electronBinary,
args: [appDir],
cwd: appDir,
env: { ...process.env, HOME: home },
recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } },
timeout: 120_000,
});

const page = await app.firstWindow({ timeout: 120_000 });
const step = async (label: string, body: () => Promise<void>) => {
await body();
stepIndex += 1;
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-");
await page.screenshot({
path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`),
});
};

// The window only loads the console once the app has a connection — and it
// attaches to the supervised daemon before it would ever spawn a sidecar.
await step("desktop boots into the console", async () => {
await page.getByText("Settings").first().waitFor({ timeout: 120_000 });
});

// The proof it ATTACHED rather than spawned: the manifest is untouched —
// same pid, still cli-daemon. A managed sidecar would have rewritten it to
// kind "desktop-sidecar" with a fresh child pid.
await step("server manifest still names the supervised daemon", async () => {
const after = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;
expect(after.kind, "still the supervised daemon (not a desktop sidecar)").toBe("cli-daemon");
expect(after.pid, "the desktop attached to our daemon, not a new sidecar").toBe(daemonPid);
});
} finally {
const page = app?.windows()[0];
const video = page?.video();
await app?.close().catch(() => {});
const recordedPath = await video?.path().catch(() => undefined);
if (recordedPath && existsSync(recordedPath)) {
await promisify(execFile)("ffmpeg", [
"-y",
"-i",
recordedPath,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"26",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
join(runDir, "session.mp4"),
]).catch(() => {});
}
daemon?.kill("SIGTERM");
rmSync(videoTmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}
};
Loading
Loading