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
40 changes: 40 additions & 0 deletions docs/adr/0008-ci-workspace-and-windows-test-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 源码工作区的平台验证与 Windows 测试隔离

日期:2026-10-12。状态:实施。

## 问题与证据

主分支 [CI 38132448422](https://github.com/starsstreaming/beautiCode/actions/runs/38132448422)
在 Ubuntu 安装阶段因 `beauticode-desktop` 工作区的 `os: win32` 声明退出,
Windows 两个 Node 版本均在快捷方式测试中只报告被截断的 CLIXML 进度输出。
此前同一提交的 PR 检查还出现 DSH 目录替换竞态和多项 Windows 子进程超时。

## 决策

- 源码工作区允许在 CI 的 Linux、Windows 上安装;聚合打包器负责写入正式产物的
`os: ["win32"]`,保留用户安装契约。源码及产物契约分别验证,避免使用 `--force`
绕过 npm 对平台及引擎的检查。
- Desktop 测试采用现有长路径临时目录运行器,并串行执行测试文件,避免多个
PowerShell、复制 Node 校验和快捷启动用例同时争用 hosted runner 的资源。
原有用例与超时边界保留,不通过跳过测试或无边界重试变绿。
- 快捷方式进程禁用 PowerShell 进度输出,明确文本格式,保留有界输出缓冲;
优先报告 ENOBUFS、ETIMEDOUT 等真实进程错误码,不让 CLIXML 掩盖失败原因。
对正常启动后的非零退出保留有界、移除路径与 URL 的诊断文本。
完整诊断进一步定位到 Windows 英文镜像中 WScript 无法保存中文 `.lnk` 名称;
COM 只操作私有 ASCII 临时别名,再由 .NET 文件 API 以不覆盖方式发布原中文名称。
读取既有快捷方式也通过该别名进行,始终保留原有归属校验及 finally 清理。
父进程另行清理本次 UUID 别名,覆盖超时或输出超限终止 PowerShell 后其 finally
无法执行的情形;清理失败保留原操作与清理错误码,避免静默遗留开始菜单项。
- DSH 写事务持有 busy 状态时,监视器不得读取活动清单。该读取会初始化
`active` 目录,可能在目录原子替换的中间阶段重新创建目标目录。已有读任务
仍由事务入口等待完成,后续 tick 则在入口处退出。

## 验证与回滚

回归覆盖 busy 期间零读取、空闲后恢复读取、48 KiB 子进程诊断输出及错误码、
源码与发布产物的不同平台契约,并保留真实 Windows 快捷方式端到端验证。
Linux 夹具统一采用本机绝对路径;日志失败夹具使用真实空字符,不把 Windows
非法文件名误当成跨平台非法文件名。Unicode 持久化回归以受限 COM 夹具执行真实
PowerShell 和 .NET 文件发布,核验最终中文名称及临时文件清理。
Windows 注册表夹具显式使用 `path.win32` 归一化,避免 Linux 把反斜杠当作文件名字符。
合并前要求四个操作系统/Node 组合均通过 CI;变更没有数据迁移,可回退本提交。
4 changes: 1 addition & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/adapter-codex/test/codex-lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { acquireCodexGuardianLease, startCodexHelperWatchdog } from "../../../integrations/codex-desktop/codex-watchdog.mjs";
import { classifyCodexStartupProcess } from "../dist/launch.js";

const HELPER_HOME = "C:\\Users\\me\\AppData\\Local\\beautiCode\\codex-plugin";
const HELPER_HOME = path.resolve(os.tmpdir(), "beautiCode", "codex-plugin");

test("second outer guardian cannot acquire a live lease", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "bc-guardian-"));
Expand Down
3 changes: 2 additions & 1 deletion packages/adapter-dsh/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,8 @@ export class DshSession implements HostSession {
}

private async watchOnce(): Promise<void> {
if (this.closed || !this.host || this.watchTask) return;
// readActiveManifest can initialize activeDir. A tick during promotion must not recreate that rename target.
if (this.closed || !this.host || this.watchTask || this.userBusy) return;
const task = (async () => {
try {
const [status, manifest] = await Promise.all([
Expand Down
24 changes: 24 additions & 0 deletions packages/adapter-dsh/test/watch-serialization.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { DshSession } from "../dist/index.js";

test("watch ticks never open active state while a user transaction owns it", async () => {
const session = new DshSession({ dataRoot: path.join(os.tmpdir(), "unused-dsh-watch-test"), bundledGallery: false });
let statusReads = 0, manifestReads = 0;
// Hold the domain transaction's busy state deterministically, without relying on filesystem timing.
session.host = { status: async () => {
statusReads++;
return { connectedClients: 0, current: { generation: 0, media: "clear" } };
} };
session.store.readActiveManifest = async () => { manifestReads++; return { generation: 0, background: null }; };
session.userBusy = true;
await session.watchOnce();
assert.equal(statusReads, 0);
assert.equal(manifestReads, 0);
session.userBusy = false;
await session.watchOnce();
assert.equal(statusReads, 1);
assert.equal(manifestReads, 1);
});
2 changes: 1 addition & 1 deletion packages/adapter-workbuddy/test/wb-runner-log.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test("runner logs without inherited stdio and rotates within a bound", async ()

test("log failure is nonfatal and reports once to foreground stderr", () => {
const errors = [];
const logger = createWorkBuddyLogger({ file: "\\0invalid", stderr: (message) => errors.push(message) });
const logger = createWorkBuddyLogger({ file: "\0invalid", stderr: (message) => errors.push(message) });
logger.info("one"); logger.info("two");
assert.equal(errors.length, 1);
});
4 changes: 4 additions & 0 deletions packages/beauticode-desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Windows-only aggregate package for the beautiCode desktop hosts. Requires Node.js 22 or newer.

The source workspace installs on Linux for CI validation. `npm run desktop:pack`
adds the Windows-only npm platform restriction to the distributable; running
Windows installation commands on another platform remains unsupported.

Codex MSIX safety notice: direct launching an executable inside WindowsApps
loses its package identity and can prevent Codex from starting. This package
uses package-aware activation and refuses the unsafe direct-launch route, but
Expand Down
3 changes: 1 addition & 2 deletions packages/beauticode-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"private": false,
"description": "beautiCode desktop host aggregate for DSH, Codex, WorkBuddy, Cursor, and Doubao.",
"type": "module",
"os": ["win32"],
"bin": {
"beauticode-desktop": "./bin/beauticode-desktop.mjs"
},
Expand All @@ -22,7 +21,7 @@
],
"scripts": {
"postinstall": "node ./bin/postinstall.mjs",
"test": "node --test test/*.test.mjs"
"test": "node ../../scripts/test-runner.mjs --test --test-concurrency=1 test/*.test.mjs"
},
"engines": {
"node": ">=22"
Expand Down
57 changes: 47 additions & 10 deletions packages/beauticode-desktop/src/quick-shortcuts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';

const NAMES = Object.freeze({ codex: 'Codex', workbuddy: 'WorkBuddy', cursor: 'Cursor', doubao: 'Doubao' });

Expand All @@ -27,35 +28,71 @@ export function installHostQuickShortcut(host, options = {}) {
const programsDir = options.programsDir || path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'beautiCode');
fs.mkdirSync(programsDir, { recursive: true });
const shortcutPath = path.join(programsDir, `beautiCode ${NAMES[host]} (背景).lnk`);
// WScript shortcut persistence can reject Unicode filenames on an English Windows image.
// Use a private ASCII alias for COM reads/writes; the filesystem publishes the unchanged display name without overwrite.
const temporaryPath = path.join(programsDir, `.beauticode-${randomUUID()}.lnk`);

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 Badge Keep the entire COM alias path ASCII

When %APPDATA% or the Windows profile directory contains non-ASCII characters, joining the ASCII basename to programsDir still produces a non-ASCII full path. The new fixture in quick-shortcuts.test.mjs models COM failure for any non-ASCII character in FullName, so in that same environment CreateShortcut($temporaryPath) still fails and the shortcut cannot be created or inspected. Use a guaranteed ASCII/short path for the alias's entire path, not only its basename.

Useful? React with 👍 / 👎.

const description = `beautiCode quick launch for ${host}`;
const script = [
"$ErrorActionPreference='Stop'",
"$shell=New-Object -ComObject WScript.Shell",
"$ProgressPreference='SilentlyContinue'",
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)",
`$shortcutPath=${psLiteral(shortcutPath)}`,
`$temporaryPath=${psLiteral(temporaryPath)}`,
`$launcher=${psLiteral(launcher)}`,
`$description=${psLiteral(description)}`,
"$target=(Join-Path $env:WINDIR 'System32\\WindowsPowerShell\\v1.0\\powershell.exe')",
" $arguments='-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File " + '"' + "' + $launcher + '" + '"' + " --quick-launch'",
'try {',
"$shell=New-Object -ComObject WScript.Shell",
"if(Test-Path -LiteralPath $shortcutPath){",
" $existing=$shell.CreateShortcut($shortcutPath)",
" [System.IO.File]::Copy($shortcutPath,$temporaryPath)",
" $existing=$shell.CreateShortcut($temporaryPath)",
" if($existing.Description -ne $description -or $existing.Arguments -ne $arguments -or $existing.TargetPath -ine $target){throw 'shortcut-conflict'}",
" [Console]::Out.WriteLine('present')",
'}else{',
" $link=$shell.CreateShortcut($shortcutPath)",
" $link=$shell.CreateShortcut($temporaryPath)",
" $link.TargetPath=$target",
" $link.Arguments=$arguments",
" $link.WorkingDirectory=(Split-Path -Parent $launcher)",
" $link.WindowStyle=7",
" $link.Description=$description",
" $link.Save()",
" [System.IO.File]::Move($temporaryPath,$shortcutPath)",
" [Console]::Out.WriteLine('created')",
'}',
'} catch {',
" [Console]::Error.WriteLine($_.Exception.Message)",
' exit 1',
'} finally {',
" [System.IO.File]::Delete($temporaryPath)",
'}',
Comment thread
starsstreaming marked this conversation as resolved.
Resolved
].join('\n');
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], {
encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 16 * 1024,
});
if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || 'shortcut-create-failed').trim().slice(0, 200));
const outcome = String(result.stdout).trim();
if (outcome !== 'created' && outcome !== 'present') throw new Error('shortcut-result-invalid');
return { outcome, path: shortcutPath };
let operationError;
try {
const result = (options.spawnSync || spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], {
encoding: 'utf8', windowsHide: true, timeout: 8_000, maxBuffer: 64 * 1024,
});
if (result.error || result.status !== 0) {
const code = result.error?.code || (/shortcut-conflict/.test(String(result.stderr)) ? 'shortcut-conflict' : `exit-${result.status ?? 'unknown'}`);
const detail = result.error ? '' : String(result.stderr || result.stdout || '')
.replaceAll(launcher, '[path]').replaceAll(shortcutPath, '[path]').replaceAll(temporaryPath, '[path]').replaceAll(programsDir, '[path]')
.replace(/(?:https?|file):\/\/[^\s"'<>]+/gi, '[url]')
.replace(/(?:[A-Za-z]:[\\/]|\\\\)[^"'<>\r\n]+/g, '[path]')
.replace(/[\x00-\x1f]+/g, ' ').trim().slice(0, 500);
throw Object.assign(new Error(`shortcut-create-failed: ${code}${detail ? `: ${detail}` : ''}`), { code });
}
const outcome = String(result.stdout).trim();
if (outcome !== 'created' && outcome !== 'present') throw new Error('shortcut-result-invalid');
return { outcome, path: shortcutPath };
} catch (error) {
operationError = error;
throw error;
} finally {
try { fs.rmSync(temporaryPath, { force: true }); }
catch (error) {
const code = error.code || "unknown";
const detail = operationError ? `${operationError.message}; ` : "";
throw Object.assign(new Error(`${detail}shortcut-temp-cleanup-failed: ${code}`), { code });
}
}
}
5 changes: 3 additions & 2 deletions packages/beauticode-desktop/test/all.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ const env = {
};

function fakeOptions(hostFiles, spawnSync) {
const normalized = new Set(hostFiles.map((file) => path.normalize(file)));
// This fixture models Windows install records even when the test runner itself is Linux.
const normalized = new Set(hostFiles.map((file) => path.win32.normalize(file)));
return {
home,
runtimeRoot,
env,
platform: "win32",
exists: (file) => path.normalize(file).startsWith(path.normalize(runtimeRoot)) || normalized.has(path.normalize(file)),
exists: (file) => path.win32.normalize(file).startsWith(path.win32.normalize(runtimeRoot)) || normalized.has(path.win32.normalize(file)),
probeAppxCodex: () => false,
queryRegistered: () => [],
verifyHostInstall: () => ({ ready: true, state: "guardian-ready" }),
Expand Down
4 changes: 2 additions & 2 deletions packages/beauticode-desktop/test/contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { fileURLToPath, pathToFileURL } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");

test("desktop aggregate manifest is a public test package with one CLI", () => {
test("desktop source workspace can be installed for cross-platform validation", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
assert.equal(manifest.name, "beauticode-desktop");
assert.equal(manifest.version, "0.1.0-test.12");
assert.equal(manifest.private, false);
assert.deepEqual(manifest.os, ["win32"]);
assert.equal(manifest.os, undefined);
assert.equal(manifest.bin["beauticode-desktop"], "./bin/beauticode-desktop.mjs");
assert.ok(manifest.files.includes("runtime"));
});
Expand Down
1 change: 1 addition & 0 deletions packages/beauticode-desktop/test/pack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ test("staging produces one self-contained runtime for all five hosts", async ()
assert.equal(pkg.name, "beauticode-desktop");
assert.equal(pkg.version, "0.1.0-test.12");
assert.equal(pkg.private, false);
assert.deepEqual(pkg.os, ["win32"]);
assert.equal(pkg.bin["beauticode-desktop"], "./bin/beauticode-desktop.mjs");
for (const [host, config] of Object.entries(HOST_RUNTIME)) {
for (const relative of config.required) {
Expand Down
Loading
Loading