mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(packaged): let a macOS headless runtime become the desktop in place
When Codex starts Open Design headlessly through MCP, macOS treats that
windowless process as the running app. Opening the app from the Dock,
Finder, Launchpad or `open -a` then only reactivates it, so no launcher
runs and no window ever appears (OPEND-3426).
On macOS the headless runtime now runs the desktop path with its window
deferred, and restores the window in the same process when the user
opens the app: through `activate`/`open-url` paired with the app becoming
active, or through SHOW from a newer launcher that sees a `restorable`
owner. The daemon, its port and connected MCP clients stay. A new outer
hands headless launches to the active payload and marks the launch as
managed; payloads started by older outers keep today's behavior.
Managed MCP registrations carry absolute sidecar endpoints for every mode
of the namespace, so the MCP process finds the daemon whichever mode owns
it, waits for a running owner instead of reopening it, and cold-starts
only when nothing runs. The daemon refreshes an existing Codex
registration on start and never installs one. A write interrupted by a
service restart now says it may or may not have been applied.
* fix(daemon): scope Codex refresh to its managed install
Require the existing registration's managed discovery marker to match before refreshing it, so local, stable, and prerelease installs cannot take over the global name from one another. Cover the unrelated-owner path without invoking a config mutation.\n\nGenerated-By: looper 0.11.2 (runner=fixer, agent=codex)
* fix(daemon): prove Codex registration ownership by data root and app
Tighten the ownership check from d2d47a5f8e. Matching the discovery
endpoints alone still let a copy of the app that reuses the installed
namespace take over the registration (the endpoints depend only on
channel, namespace and source), and it could never refresh a
registration written before the managed contract, which has no
discovery value and still names a payload version that cleanup removes.
Read the existing registration and rewrite it only when it serves the
same daemon data root and bootstraps the same installed app; the managed
marker is ignored so a pre-managed registration of this install still
counts. Anything else, including a registration that cannot be read, is
left untouched.
---------
Co-authored-by: Looper <looper@noreply.github.com>
243 lines
8.9 KiB
TypeScript
243 lines
8.9 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import { parseLauncherAfterQuitArgs, parseLauncherDelegatedArgs } from "@open-design/launcher-proto";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { PackagedLauncherRuntime } from "../src/launcher-runtime.js";
|
|
import {
|
|
findPackagedDeeplinkArg,
|
|
launchPackagedPayloadDesktop,
|
|
planPackagedPayloadDesktopDelegation,
|
|
} from "../src/payload-desktop-launch.js";
|
|
|
|
function fakeRuntime(payloadDesktopProcess: boolean): PackagedLauncherRuntime {
|
|
return {
|
|
config: {} as PackagedLauncherRuntime["config"],
|
|
desktopExecutablePath: "/tmp/payload/Open Design Beta.app/Contents/MacOS/Open Design Beta",
|
|
descriptor: {} as PackagedLauncherRuntime["descriptor"],
|
|
electronNodeCommand: null,
|
|
installedLaunchPath: "/Applications/Open Design Beta.app",
|
|
launcherPaths: {} as PackagedLauncherRuntime["launcherPaths"],
|
|
paths: { dataRoot: "/tmp/data", runtimeRoot: "/tmp/runtime" } as PackagedLauncherRuntime["paths"],
|
|
payloadDesktopProcess,
|
|
selection: {
|
|
pointer: { generation: 1, version: "1.2.3-beta.5" },
|
|
reason: "active",
|
|
selected: true,
|
|
},
|
|
source: "payload",
|
|
targetVersion: "1.2.3-beta.5",
|
|
};
|
|
}
|
|
|
|
describe("payload desktop delegation", () => {
|
|
it("plans the launcher handoff while leaving stamp serialization to sidecar", () => {
|
|
const runtime = fakeRuntime(false);
|
|
const plan = planPackagedPayloadDesktopDelegation(runtime, {
|
|
currentPid: 4321,
|
|
timeoutMs: 60_000,
|
|
});
|
|
|
|
expect(plan).toEqual(expect.objectContaining({
|
|
command: runtime.desktopExecutablePath,
|
|
cwd: dirname(runtime.desktopExecutablePath ?? ""),
|
|
}));
|
|
expect(parseLauncherAfterQuitArgs(plan?.args ?? [])).toEqual({
|
|
targetPid: 4321,
|
|
timeoutMs: 60_000,
|
|
});
|
|
expect(plan?.args.some((arg) => arg.startsWith("--od-sidecar-"))).toBe(false);
|
|
});
|
|
|
|
it("carries the delegated pointer for a normal active delegation", () => {
|
|
const plan = planPackagedPayloadDesktopDelegation(fakeRuntime(false), {
|
|
currentPid: 4321,
|
|
timeoutMs: 60_000,
|
|
});
|
|
expect(parseLauncherDelegatedArgs(plan?.args ?? [])).toEqual({
|
|
generation: 1,
|
|
version: "1.2.3-beta.5",
|
|
});
|
|
});
|
|
|
|
it("forwards only the OS invite URL across an outer-to-payload cold start", () => {
|
|
const deeplink = "opendesign://workspace/invite/continue?nonce=payload-cold-start";
|
|
expect(findPackagedDeeplinkArg(["Open Design.exe", "--unrelated", deeplink])).toBe(deeplink);
|
|
expect(findPackagedDeeplinkArg(["Open Design.exe", "--unrelated"])).toBeNull();
|
|
const plan = planPackagedPayloadDesktopDelegation(fakeRuntime(false), {
|
|
currentPid: 4321,
|
|
forwardedArgs: ["Open Design.exe", "--unrelated", deeplink],
|
|
timeoutMs: 60_000,
|
|
});
|
|
|
|
expect(plan?.args).toContain(deeplink);
|
|
expect(plan?.args).not.toContain("--unrelated");
|
|
});
|
|
|
|
it("carries launch-mode arguments so a delegated headless launch stays headless", () => {
|
|
const plan = planPackagedPayloadDesktopDelegation(fakeRuntime(false), {
|
|
currentPid: 4321,
|
|
extraArgs: ["--headless"],
|
|
forwardedArgs: ["Open Design", "--headless", "--unrelated"],
|
|
timeoutMs: 60_000,
|
|
});
|
|
// The payload re-derives its sidecar mode from its own argv, so the mode flag must arrive
|
|
// together with the delegated pointer that keeps its pre-armed attempt from reading as a failure.
|
|
expect(plan?.args.filter((arg) => arg === "--headless")).toEqual(["--headless"]);
|
|
expect(plan?.args).not.toContain("--unrelated");
|
|
expect(parseLauncherDelegatedArgs(plan?.args ?? [])).toEqual({ generation: 1, version: "1.2.3-beta.5" });
|
|
});
|
|
|
|
it("omits the delegated pointer for a rollback delegation", () => {
|
|
// A last-successful delegation is driven by rollback evidence in
|
|
// attempt.json; marking it delegated (or re-arming) would let the spawned
|
|
// payload re-select the broken active generation.
|
|
const runtime: PackagedLauncherRuntime = {
|
|
...fakeRuntime(false),
|
|
selection: {
|
|
pointer: { generation: 0, version: "1.2.3-beta.4" },
|
|
reason: "last-successful",
|
|
selected: true,
|
|
},
|
|
};
|
|
const plan = planPackagedPayloadDesktopDelegation(runtime, {
|
|
currentPid: 4321,
|
|
timeoutMs: 60_000,
|
|
});
|
|
expect(plan).not.toBeNull();
|
|
expect(parseLauncherDelegatedArgs(plan?.args ?? [])).toBeNull();
|
|
});
|
|
|
|
it("pre-arms the launch attempt before spawning the delegated payload", async () => {
|
|
// A payload that dies before reaching its own bookkeeping must still
|
|
// leave rollback evidence — the parent arms attempt.json BEFORE spawn so
|
|
// the next cold start rolls back instead of retrying the broken payload
|
|
// forever.
|
|
const root = await mkdtemp(join(tmpdir(), "od-delegated-arm-"));
|
|
try {
|
|
const attemptsPath = join(root, "state", "attempt.json");
|
|
const runtime: PackagedLauncherRuntime = {
|
|
...fakeRuntime(false),
|
|
launcherPaths: {
|
|
attemptsPath,
|
|
channel: "beta",
|
|
namespace: "release-beta",
|
|
} as PackagedLauncherRuntime["launcherPaths"],
|
|
};
|
|
let spawnSawAttempt: boolean | null = null;
|
|
const handoff = vi.fn(async () => {
|
|
spawnSawAttempt = existsSync(attemptsPath);
|
|
});
|
|
|
|
const launched = await launchPackagedPayloadDesktop(runtime, { handoff });
|
|
|
|
expect(launched).toBe(true);
|
|
expect(spawnSawAttempt).toBe(true);
|
|
const attempt = JSON.parse(await readFile(attemptsPath, "utf8")) as Record<string, unknown>;
|
|
expect(attempt).toMatchObject({
|
|
channel: "beta",
|
|
generation: 1,
|
|
namespace: "release-beta",
|
|
version: "1.2.3-beta.5",
|
|
});
|
|
expect(handoff).toHaveBeenCalledWith(expect.objectContaining({
|
|
command: runtime.desktopExecutablePath,
|
|
}), { timeoutMs: 60_000 });
|
|
} finally {
|
|
await rm(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("does not pre-arm a rollback delegation", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "od-delegated-rollback-"));
|
|
try {
|
|
const attemptsPath = join(root, "state", "attempt.json");
|
|
const runtime: PackagedLauncherRuntime = {
|
|
...fakeRuntime(false),
|
|
launcherPaths: {
|
|
attemptsPath,
|
|
channel: "beta",
|
|
namespace: "release-beta",
|
|
} as PackagedLauncherRuntime["launcherPaths"],
|
|
selection: {
|
|
pointer: { generation: 0, version: "1.2.3-beta.4" },
|
|
reason: "last-successful",
|
|
selected: true,
|
|
},
|
|
};
|
|
await launchPackagedPayloadDesktop(runtime, {
|
|
handoff: vi.fn(async () => undefined),
|
|
});
|
|
|
|
expect(existsSync(attemptsPath)).toBe(false);
|
|
} finally {
|
|
await rm(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("does not delegate once the current process already is the payload desktop", () => {
|
|
expect(planPackagedPayloadDesktopDelegation(fakeRuntime(true), {
|
|
currentPid: 4321,
|
|
timeoutMs: 60_000,
|
|
})).toBeNull();
|
|
});
|
|
|
|
it("delegates the next child to the current sidecar generation", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "od-delegated-spawn-"));
|
|
try {
|
|
const handoff = vi.fn(async () => undefined);
|
|
const runtime: PackagedLauncherRuntime = {
|
|
...fakeRuntime(false),
|
|
launcherPaths: {
|
|
attemptsPath: join(root, "state", "attempt.json"),
|
|
channel: "beta",
|
|
namespace: "release-beta",
|
|
} as PackagedLauncherRuntime["launcherPaths"],
|
|
};
|
|
|
|
const delegated = await launchPackagedPayloadDesktop(runtime, {
|
|
currentPid: 4321,
|
|
handoff,
|
|
timeoutMs: 60_000,
|
|
});
|
|
|
|
expect(delegated).toBe(true);
|
|
expect(handoff).toHaveBeenCalledWith(expect.objectContaining({
|
|
args: expect.any(Array),
|
|
command: runtime.desktopExecutablePath,
|
|
cwd: dirname(runtime.desktopExecutablePath ?? ""),
|
|
}), { timeoutMs: 60_000 });
|
|
} finally {
|
|
await rm(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("records a failed attempt when the supervisor rejects the handoff", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "od-payload-desktop-spawn-failure-"));
|
|
const runtime = fakeRuntime(false);
|
|
runtime.launcherPaths = {
|
|
attemptsPath: join(root, "state", "attempt.json"),
|
|
channel: "beta",
|
|
namespace: "release-beta",
|
|
} as PackagedLauncherRuntime["launcherPaths"];
|
|
try {
|
|
await expect(launchPackagedPayloadDesktop(runtime, {
|
|
handoff: vi.fn(async () => { throw new Error("handoff rejected"); }),
|
|
})).rejects.toThrow("handoff rejected");
|
|
|
|
expect(JSON.parse(await readFile(runtime.launcherPaths.attemptsPath, "utf8"))).toMatchObject({
|
|
channel: "beta",
|
|
generation: 1,
|
|
namespace: "release-beta",
|
|
schemaVersion: 1,
|
|
version: "1.2.3-beta.5",
|
|
});
|
|
} finally {
|
|
await rm(root, { force: true, recursive: true });
|
|
}
|
|
});
|
|
});
|