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
6 changes: 6 additions & 0 deletions .github/workflows/platform-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm -C packages/server-core build

# verification-isolation serves the production Platform Shell, so the
# Server reads the Next.js static export at packages/web/out. Without that
# export the app entry answers 404 and the check fails for a missing
# shell, not for a verification-boundary defect.
- run: pnpm -C packages/web build
- run: pnpm test:platform-regression
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ backend/
| 命令 | 说明 |
| --- | --- |
| `localapp server [start]` | 注册系统集成并启动当前用户 daemon |
| `localapp server run` | 以前台模式运行同一 Server,适合容器和服务管理器 |
| `localapp server run` | 以前台模式运行同一 Server,适合容器和服务管理器;Windows 上使用包内 native helper |
| `localapp server stop/restart/status/logs/uninstall` | 管理当前用户 daemon |
| `localapp init <name>` | 从 npm 包内置模板创建应用 |
| `localapp build --package` | 构建并生成不含本地数据的 `.localapp` |
Expand Down Expand Up @@ -270,6 +270,14 @@ docker load -i localapp-image.tar
仓库中的少量 Swift/Rust 代码仅用于编译这些按平台分发的 native adapter,不包含
Tauri、WebView、托盘 UI、CLI 或第二套 Server。

Windows 上 `localapp` 需要 native adapter 提供原子 Job Object 子树所有权(`server run`、
`dev` 与打开浏览器都经由它)。CLI 按以下顺序解析 helper,且只在文件确实存在时才采用,
否则以 `native_adapter_unsupported` 失败关闭:

1. `LOCALAPP_RELEASE_PATH` 指向的 release 根(daemon 经 bootstrap 启动时由它导出);
2. CLI 自身所在的发行 artifact 目录(`bin/localapp.mjs` 的上一级)。因此直接从 shell
执行 `localapp server run` 不需要任何额外环境变量。

Windows 的用户发行物仍是标准 npm tgz。完整的 native adapter 构建、签名、打包
检查和干净环境验收流程见:

Expand Down
7 changes: 7 additions & 0 deletions benchmarks/agent-first-run/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@ The catalog contains six stable requirements. Do not change an existing requirem
Run the deterministic platform suite without an Agent or paid API:

```bash
pnpm -C packages/server-core build
pnpm -C packages/web build
pnpm test:platform-regression
```

Both builds are prerequisites. `verification-isolation` drives the production
app entry, which serves the Platform Shell from the Next.js static export at
`packages/web/out`; without it the entry answers `404` and the check fails for a
missing shell instead of a verification-boundary defect.

It checks the capability contract, dev/production content behavior, production verification isolation, and the benchmark protocol. This is the only benchmark layer that blocks ordinary CI.

## Controlled Agent Run
Expand Down
13 changes: 13 additions & 0 deletions packages/localapp/scripts/native-adapter.node-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,19 @@ test("Windows helper preserves argv, has an explicit application path, and keeps
assert.match(source, /ShellExecuteW/);
});

test("Windows helper hands the shortcut property store a task-allocator buffer, never a Rust allocation", async () => {
// Break caught: `--register` wrote the Scheme registry key and then died with
// STATUS_HEAP_CORRUPTION before the CLI could install the daemon, because the
// PROPVARIANT drop cleared a Vec pointer through CoTaskMemFree.
const source = await fs.readFile(path.join(repositoryRoot, "packages/localapp/native/windows/src/main.rs"), "utf8");
assert.match(source, /CoTaskMemAlloc\(app_id_wide\.len\(\) \* std::mem::size_of::<u16>\(\)\)/);
assert.match(source, /pwszVal: PWSTR\(allocation\.cast::<u16>\(\)\)/);
assert.doesNotMatch(source, /pwszVal: PWSTR\(app_id_wide/);
assert.doesNotMatch(source, /let mut app_id_wide = wide\(/);
const copy = source.indexOf("copy_nonoverlapping(app_id_wide.as_ptr(), allocation.cast::<u16>()");
assert.ok(copy > 0 && copy < source.indexOf("SetValue(&APP_USER_MODEL_KEY"));
});

function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], shell: false });
Expand Down
31 changes: 31 additions & 0 deletions packages/localapp/src/artifact-directory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { realpathSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

/**
* The packaged artifact this CLI belongs to: the directory holding `runtime/`
* and `.localapp-artifact.json`. A published install exposes the entrypoint as
* `bin/localapp.mjs`, directly or through an npm bin shim, so the artifact root
* is always the entrypoint's parent directory.
*/
export function artifactDirectoryFromEntrypoint(entrypoint: string): string {
const canonical = realpathSync(entrypoint);
return path.resolve(path.dirname(canonical), "..");
}

/**
* The artifact directory this process runs from. `argv[1]` is the real
* entrypoint for every CLI invocation; the module-relative root keeps
* programmatic imports (and an unresolvable entrypoint) working instead of
* failing with a bare ENOENT.
*/
export function localAppArtifactDirectory(entrypoint: string | undefined = process.argv[1]): string {
if (entrypoint !== undefined && entrypoint.length > 0) {
try {
return artifactDirectoryFromEntrypoint(entrypoint);
} catch {
// Not a resolvable entrypoint; the module-relative root is authoritative.
}
}
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
}
12 changes: 2 additions & 10 deletions packages/localapp/src/commands/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from "node:fs/promises";
import { realpathSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { localAppArtifactDirectory } from "../artifact-directory.js";
import { lifecycleError } from "../errors.js";
import { createIpcClient, type IpcClient } from "../daemon/ipc-client.js";
import { publishRelease, readCurrentRelease, verifyReleaseArtifact, type CurrentRelease } from "../daemon/release-store.js";
Expand Down Expand Up @@ -186,14 +185,7 @@ function defaultServiceManager(layout: RuntimeLayout): ServiceManager {
}

function defaultArtifactDirectory(): string {
const fromEntry = process.argv[1] === undefined ? undefined : artifactDirectoryFromEntrypoint(process.argv[1]);
if (fromEntry !== undefined) return fromEntry;
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
}

export function artifactDirectoryFromEntrypoint(entrypoint: string): string {
const canonical = realpathSync(entrypoint);
return path.resolve(path.dirname(canonical), "..");
return localAppArtifactDirectory();
}

export function runtimeLayoutFromEnvironment(environment: NodeJS.ProcessEnv = process.env): RuntimeLayout {
Expand Down
38 changes: 30 additions & 8 deletions packages/localapp/src/native/native-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type { WindowsOwnedProcessHandle, WindowsProcessTreeAdapter } from "../process/process-tree.js";
import { lifecycleError } from "../errors.js";
import { localAppArtifactDirectory } from "../artifact-directory.js";
import { ACTIVATION_URL_LIMIT_BYTES } from "../activation/activation-url.js";
import { selectNativeAdapter } from "./adapter-selection.js";

Expand Down Expand Up @@ -309,7 +311,7 @@ export function createWindowsProcessTreeAdapter(helper: WindowsNativeHelper): Wi

export function createWindowsProcessTreeAdapterFromEnvironment(): WindowsProcessTreeAdapter | undefined {
if (process.platform !== "win32") return undefined;
const executable = windowsNativeExecutableFromEnvironment();
const executable = resolveWindowsNativeExecutable();
if (executable === undefined) return undefined;
return createWindowsProcessTreeAdapter({
spawn(command, args, options) {
Expand All @@ -323,6 +325,32 @@ export function createWindowsProcessTreeAdapterFromEnvironment(): WindowsProcess
});
}

export interface WindowsNativeExecutableOptions {
env?: NodeJS.ProcessEnv;
arch?: string;
artifactDirectory?: string;
}

/**
* The Windows helper is a build product of a release artifact, so it is
* resolved from a release root and never from PATH. The daemon bootstrap
* exports LOCALAPP_RELEASE_PATH; the CLI's own artifact directory covers every
* direct invocation (`localapp server run`, browser opening) that never passes
* through that bootstrap. A candidate that is not on disk is not an adapter, so
* callers still fail closed instead of spawning an unowned process tree.
*/
export function resolveWindowsNativeExecutable(options: WindowsNativeExecutableOptions = {}): string | undefined {
const env = options.env ?? process.env;
const arch = options.arch ?? process.arch;
const roots = [env.LOCALAPP_RELEASE_PATH, options.artifactDirectory ?? localAppArtifactDirectory()];
for (const root of roots) {
if (root === undefined || root.length === 0) continue;
const candidate = path.join(root, "runtime", "native", `win32-${arch}`, "localapp-native.exe");
if (existsSync(candidate)) return candidate;
}
return undefined;
}

export async function openValidatedExternalUrl(url: string): Promise<void> {
let parsed: URL;
try { parsed = new URL(url); } catch { throw lifecycleError("browser_open_invalid", "The validated browser destination is invalid"); }
Expand All @@ -332,20 +360,14 @@ export async function openValidatedExternalUrl(url: string): Promise<void> {
if (process.platform === "darwin") return waitForChild(spawn("/usr/bin/open", [url], { shell: false, stdio: "ignore" }));
if (process.platform === "linux") return waitForChild(spawn("xdg-open", [url], { shell: false, stdio: "ignore" }));
if (process.platform === "win32") {
const executable = windowsNativeExecutableFromEnvironment();
const executable = resolveWindowsNativeExecutable();
if (executable === undefined) throw lifecycleError("native_adapter_unsupported", "NATIVE_ADAPTER_UNSUPPORTED: the Windows opener is unavailable");
await waitForChild(spawn(executable, ["--open-url", url], { shell: false, stdio: "ignore", windowsHide: true }));
return;
}
throw unsupported(process.platform);
}

function windowsNativeExecutableFromEnvironment(): string | undefined {
const release = process.env.LOCALAPP_RELEASE_PATH;
if (!release) return undefined;
return path.join(release, "runtime", "native", `win32-${process.arch}`, "localapp-native.exe");
}

async function runCommand(command: string, args: readonly string[], timeoutMs: number, environment: NodeJS.ProcessEnv = process.env): Promise<string> {
const child = spawn(command, [...args], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: environment });
let stdout = "";
Expand Down
5 changes: 4 additions & 1 deletion packages/localapp/src/process/process-tree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn, type ChildProcess, type SpawnOptions, type StdioOptions } from "node:child_process";
import { createWindowsProcessTreeAdapterFromEnvironment } from "../native/native-adapter.js";
import { lifecycleError } from "../errors.js";

export interface OwnedProcessExit {
code: number | null;
Expand Down Expand Up @@ -73,7 +74,9 @@ export function spawnOwnedProcess(
if (platform === "win32") {
const windowsAdapter = options.windowsAdapter ?? createWindowsProcessTreeAdapterFromEnvironment();
if (windowsAdapter === undefined) {
throw new Error("Windows process-tree adapter is unavailable; refusing to spawn without atomic Job Object ownership");
// A structured code keeps the cause visible; a bare Error is flattened to
// command_failed by the CLI entrypoint.
throw lifecycleError("native_adapter_unsupported", "NATIVE_ADAPTER_UNSUPPORTED: the Windows process-tree adapter is unavailable without the packaged LocalApp native helper");
}
return ownedWindowsProcess(windowsAdapter.spawnOwned(command, args, spawnOptions), options);
}
Expand Down
31 changes: 31 additions & 0 deletions packages/localapp/tests/native-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createWindowsSchemeForwardInvocation,
installLinuxScheme,
performWindowsAtomicOwnership,
resolveWindowsNativeExecutable,
validateNativeNotificationEnvelope,
} from "../src/native/native-adapter.js";

Expand Down Expand Up @@ -305,6 +306,36 @@ function notificationEnvelope(iconPath: string) {
};
}

describe("Windows native helper resolution", () => {
it("prefers the bootstrap release root, then the CLI's own artifact directory", async () => {
const artifactRoot = await windowsHelperFixture(true);
const releaseRoot = await windowsHelperFixture(true);
expect(resolveWindowsNativeExecutable({ env: { LOCALAPP_RELEASE_PATH: releaseRoot }, arch: "x64", artifactDirectory: artifactRoot }))
.toBe(path.join(releaseRoot, "runtime", "native", "win32-x64", "localapp-native.exe"));
// Break caught: `localapp server run` and browser opening never pass through
// the daemon bootstrap, so LOCALAPP_RELEASE_PATH is absent for a user shell.
expect(resolveWindowsNativeExecutable({ env: {}, arch: "x64", artifactDirectory: artifactRoot }))
.toBe(path.join(artifactRoot, "runtime", "native", "win32-x64", "localapp-native.exe"));
});

it("yields no adapter when neither release root holds an installed helper", async () => {
const artifactRoot = await windowsHelperFixture(false);
expect(resolveWindowsNativeExecutable({ env: { LOCALAPP_RELEASE_PATH: path.join(artifactRoot, "releases/0.0.0-absent") }, arch: "x64", artifactDirectory: artifactRoot })).toBeUndefined();
expect(resolveWindowsNativeExecutable({ env: {}, arch: "x64", artifactDirectory: artifactRoot })).toBeUndefined();
});
});

async function windowsHelperFixture(writeExecutable: boolean): Promise<string> {
const root = await fs.mkdtemp(path.resolve(process.cwd(), "../../tmp/task-11-native-resolution-"));
fixtureDirectories.push(root);
if (writeExecutable) {
const executable = path.join(root, "runtime", "native", "win32-x64", "localapp-native.exe");
await fs.mkdir(path.dirname(executable), { recursive: true });
await fs.writeFile(executable, "");
}
return root;
}

async function nativeFixture(platform: "darwin" | "win32" | "linux") {
const root = await fs.mkdtemp(path.resolve(process.cwd(), "../../tmp/task-11-native-adapter-"));
fixtureDirectories.push(root);
Expand Down
8 changes: 6 additions & 2 deletions packages/localapp/tests/process-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,14 @@ describe("owned process trees", () => {

it("fails closed on Windows when the suspended-root Job Object adapter is unavailable", () => {
// Break caught: ordinary Windows spawn races descendants before Task 8 can assign the root to a Job Object.
expect(() => spawnOwnedProcess(process.execPath, ["-e", "process.exit(0)"], {
const spawn = () => spawnOwnedProcess(process.execPath, ["-e", "process.exit(0)"], {
platform: "win32",
stdio: "ignore",
})).toThrow(/Windows process-tree adapter.*unavailable/i);
});
expect(spawn).toThrow(/Windows process-tree adapter.*unavailable/i);
// Break caught: an untyped Error is flattened to command_failed at the CLI
// entrypoint, hiding the real cause from `localapp server run`.
expect(spawn).toThrow(expect.objectContaining({ code: "native_adapter_unsupported" }));
});
});

Expand Down
3 changes: 2 additions & 1 deletion packages/localapp/tests/server-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { artifactDirectoryFromEntrypoint, runServerCommand, runtimeLayoutFromEnvironment } from "../src/commands/server.js";
import { artifactDirectoryFromEntrypoint } from "../src/artifact-directory.js";
import { runServerCommand, runtimeLayoutFromEnvironment } from "../src/commands/server.js";
import type { RuntimeLayout } from "../src/daemon/runtime-layout.js";

const layout = {
Expand Down
15 changes: 15 additions & 0 deletions scripts/export-public-source.node-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,18 @@ test("default Server tests exclude generated-app acceptance while preserving its
assert.match(serverConfig, /tests\/e2e-unified\/real-apps\.spec\.ts/);
assert.match(rootManifest.scripts["test:real-apps"], /real-apps\.spec\.ts/);
});

test("every reviewed baseline exception still matches the file it pins", () => {
// Break caught: editing a baselined file leaves its reviewed digest stale, and
// the export gate only reports that after a full CI round trip.
const baseline = JSON.parse(fs.readFileSync(new URL("./public-source-scan-baseline.json", import.meta.url), "utf8"));
const stale = [];
for (const exception of baseline.exceptions) {
const bytes = fs.readFileSync(new URL(`../${exception.path}`, import.meta.url));
// The export hashes committed content, which is LF on every platform, while a
// Windows checkout materializes the same file with CRLF.
const digest = createHash("sha256").update(bytes.toString("binary").replace(/\r\n/g, "\n"), "binary").digest("hex");
if (digest !== exception.sha256) stale.push(`${exception.rule} ${exception.path}`);
}
assert.deepEqual(stale, [], `refresh the recorded sha256 for: ${stale.join(", ")}`);
});
8 changes: 4 additions & 4 deletions scripts/public-source-scan-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
{
"path": "packages/localapp/tests/native-adapter.test.ts",
"rule": "POSSIBLE_CREDENTIAL",
"sha256": "4f1c411c9d690c1b35443883380a874d162a87b2612285d3cc5efbc064abe56f"
"sha256": "8f93accbef08d81cce76efbd35d724883a52174776d50eeac3efe3d308ff4229"
},
{
"path": "packages/localapp/tests/notification-manager.test.ts",
Expand Down Expand Up @@ -134,17 +134,17 @@
{
"path": "scripts/export-public-source.node-test.mjs",
"rule": "PRIVATE_TEST_IDENTITY",
"sha256": "4962a560d89bb0dcf2f5128f0657b3e44bb325c4c69c283ba962056a03afbb29"
"sha256": "fe915223346cc448802b60ca80e0533e3b116e5661b8a73b2d1b0b2c5187dade"
},
{
"path": "scripts/export-public-source.node-test.mjs",
"rule": "POSSIBLE_CREDENTIAL",
"sha256": "4962a560d89bb0dcf2f5128f0657b3e44bb325c4c69c283ba962056a03afbb29"
"sha256": "fe915223346cc448802b60ca80e0533e3b116e5661b8a73b2d1b0b2c5187dade"
},
{
"path": "scripts/release-workflow.node-test.mjs",
"rule": "PRIVATE_TEST_IDENTITY",
"sha256": "2efd147a93e8b6142fbd31f9cd1bdf11376821b4ea613e62441851878bedbf1b"
"sha256": "e1f74009acb91907a2442db90d8ac343f3e9eddb02e365600c310d1c68604f63"
}
]
}
11 changes: 11 additions & 0 deletions scripts/release-workflow.node-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import test from "node:test";
const release = fs.readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8");
const ci = fs.readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8");
const windows = fs.readFileSync(new URL("../.github/workflows/native-windows.yml", import.meta.url), "utf8");
const platformRegression = fs.readFileSync(new URL("../.github/workflows/platform-regression.yml", import.meta.url), "utf8");
const dockerfile = fs.readFileSync(new URL("../Dockerfile", import.meta.url), "utf8");
const dockerSmoke = fs.readFileSync(new URL("./docker-release-smoke.sh", import.meta.url), "utf8");
const readme = fs.readFileSync(new URL("../README.md", import.meta.url), "utf8");
Expand Down Expand Up @@ -48,6 +49,16 @@ test("Windows workflow builds only the localapp native adapter", () => {
assert.doesNotMatch(windows, /packages\/cli|packages\/desktop|@localapp\/desktop|tauri|nsis/i);
});

test("platform regression builds the Platform Shell before the deterministic suite", () => {
// Break caught: without packages/web/out the Server answers 404 for the app
// entry, so verification-isolation reported a missing shell as a failed
// verification boundary on every run of this workflow.
const shellBuild = platformRegression.indexOf("pnpm -C packages/web build");
const suite = platformRegression.indexOf("pnpm test:platform-regression");
assert.ok(shellBuild >= 0, "the Platform Shell must be built for the deterministic suite");
assert.ok(suite > shellBuild, "the Platform Shell must be built before the deterministic suite runs");
});

test("Docker installs the packed npm product and runs its public server command", () => {
assert.match(dockerfile, /^FROM node:24-slim AS runtime$/m);
assert.match(dockerfile, /COPY tmp\/localapp-package\/localapp-\*\.tgz \/dist\/localapp\.tgz/);
Expand Down
Loading