test: update helper routing expectation

This commit is contained in:
Vincent Koc
2026-06-23 13:59:11 +08:00
committed by GitHub
parent d144388d56
commit 35abfe4ed2
36 changed files with 968 additions and 82 deletions
+3 -1
View File
@@ -1177,7 +1177,9 @@ jobs:
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
strategy:
fail-fast: false
max-parallel: 12
# The canonical main path waits for the admission debounce above, so
# modestly widen this large matrix without recreating registration bursts.
max-parallel: 16
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
+6 -12
View File
@@ -22,12 +22,6 @@ on:
push:
branches:
- main
paths:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "packages/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -55,32 +49,32 @@ jobs:
include:
- language: javascript-typescript
category: core-auth-secrets
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
- language: javascript-typescript
category: channel-runtime-boundary
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
- language: javascript-typescript
category: network-ssrf-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
- language: javascript-typescript
category: mcp-process-tool-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
- language: actions
category: actions
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 10
config_file: ./.github/codeql/codeql-actions-critical-security.yml
steps:
+15 -11
View File
@@ -15,13 +15,12 @@
import Foundation
import XCTest
var deviceLanguage = ""
var locale = ""
@MainActor
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
@MainActor
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
@@ -33,6 +32,7 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
@MainActor
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
@@ -52,6 +52,7 @@ enum SnapshotError: Error, CustomDebugStringConvertible {
}
@objcMembers
@MainActor
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
@@ -59,6 +60,8 @@ open class Snapshot: NSObject {
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
static var deviceLanguage = ""
static var currentLocale = ""
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
@@ -103,17 +106,17 @@ open class Snapshot: NSObject {
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if locale.isEmpty && !deviceLanguage.isEmpty {
locale = Locale(identifier: deviceLanguage).identifier
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
currentLocale = Locale(identifier: deviceLanguage).identifier
}
if !locale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
if !currentLocale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
}
}
@@ -165,7 +168,7 @@ open class Snapshot: NSObject {
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS)
#if os(iOS) && !targetEnvironment(macCatalyst)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
@@ -181,7 +184,7 @@ open class Snapshot: NSObject {
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
@@ -281,6 +284,7 @@ private extension XCUIElementQuery {
return self.containing(isNetworkLoadingIndicator)
}
@MainActor
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
@@ -306,4 +310,4 @@ private extension CGFloat {
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.27]
// SnapshotHelperVersion [1.30]
+77 -2
View File
@@ -10,7 +10,24 @@ default_platform(:ios)
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
{
label: "iPhone",
patterns: [
/\AiPhone .* Pro Max\z/,
/\AiPhone .* Plus\z/,
/\AiPhone .*\z/
]
},
{
label: "13-inch iPad",
patterns: [
/\AiPad Pro 13-inch/,
/\AiPad Air 13-inch/,
/\AiPad .*13-inch/
]
}
].freeze
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
@@ -77,11 +94,23 @@ end
def snapshot_devices
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
return default_snapshot_devices if raw.empty?
raw.split(",").map(&:strip).reject(&:empty?)
end
def default_snapshot_devices
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
match = family.fetch(:patterns).filter_map do |pattern|
names.find { |name| name.match?(pattern) }
end.first
UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
match
end
end
def watch_snapshot_device
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
@@ -113,6 +142,51 @@ def resolve_simulator_device(name)
fallback
end
def install_ready_for_review_edit_state_lookup!
require "spaceship"
app_class = Spaceship::ConnectAPI::App
app_class.class_eval do
unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
end
unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
end
def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
return version if version
# First public releases can leave the only version in READY_FOR_REVIEW.
# Fastlane 2.236.1 excludes that state and then tries to create an illegal
# second version; use the existing review-ready version as the edit target.
client ||= Spaceship::ConnectAPI
platform ||= Spaceship::ConnectAPI::Platform::IOS
filter = {
appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
platform: platform
}
get_app_store_versions(client: client, filter: filter, includes: includes)
.sort_by { |candidate| Gem::Version.new(candidate.version_string) }
.last
end
def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
return app_info if app_info
client ||= Spaceship::ConnectAPI
client
.get_app_infos(app_id: id, includes: includes)
.to_models
.find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
end
end
end
def bundle_identifier_for_product(product_path)
info_plist_path = File.join(product_path, "Info.plist")
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
@@ -961,6 +1035,7 @@ platform :ios do
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
install_ready_for_review_edit_state_lookup!
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = app_store_connect_api_key_config
+1 -1
View File
@@ -104,7 +104,7 @@ Generate deterministic App Store screenshots:
pnpm ios:screenshots
```
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it chooses one available large iPhone simulator and one available 13-inch iPad simulator from the installed Xcode runtime; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
Upload to App Store Connect:
+3 -4
View File
@@ -2,10 +2,9 @@ project("OpenClaw.xcodeproj")
scheme("OpenClawUITests")
configuration("Debug")
devices([
"iPhone 16 Pro Max",
"iPad Pro 13-inch (M4)",
])
# The Fastfile screenshot lane resolves concrete device names from the installed
# Xcode simulators. Fastlane validates Snapfile devices before lane overrides, so
# this file intentionally does not hardcode simulator model names.
languages([
"en-US",
@@ -1,4 +1,3 @@
// Bundled A2UI runtime resource embedded by OpenClawKit.
var __defProp$1 = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
let target = {};
@@ -11936,6 +11935,10 @@ var __runInitializers = function(thisArg, initializers, value) {
};
return _classThis;
})();
/**
* Canvas A2UI browser bootstrap that installs theme overrides and native bridge
* helpers.
*/
const modalStyles = i$10`
dialog {
position: fixed;
+6 -2
View File
@@ -15,8 +15,12 @@ import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rootDir = path.resolve(pluginDir, "../..");
const require = createRequire(import.meta.url);
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const hashFile =
process.env.OPENCLAW_A2UI_BUNDLE_HASH_FILE ??
path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile =
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
@@ -11,7 +11,9 @@ const repoRoot = path.resolve(here, "../../../../..");
const require = createRequire(import.meta.url);
const uiRoot = path.resolve(repoRoot, "ui");
const fromHere = (p) => path.resolve(here, p);
const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const a2uiLitIndex = require.resolve("@a2ui/lit");
const a2uiLitUi = require.resolve("@a2ui/lit/ui");
@@ -611,7 +611,7 @@ describe("qa scenario catalog", () => {
"this seeded scenario is mock-openai only",
);
expect(heartbeatFlow).toContain("sessionKey");
expect(heartbeatFlow).toContain("targetOutbound.length === 0");
expect(heartbeatFlow).toContain("commitmentOutbound.length === 0");
expect(heartbeatFlow).not.toContain("waitForNoOutbound");
});
+2
View File
@@ -1509,6 +1509,8 @@
"build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts",
"build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs",
"canvas:a2ui:native:check": "node scripts/sync-native-a2ui.mjs --check",
"canvas:a2ui:native:sync": "node scripts/sync-native-a2ui.mjs --write",
"changed:lanes": "node scripts/changed-lanes.mjs",
"check": "node scripts/check.mjs",
"check:architecture": "pnpm check:import-cycles && pnpm check:madge-import-cycles && pnpm check:deprecated-api-usage && pnpm check:deprecated-jsdoc && pnpm db:kysely:check && pnpm lint:kysely && pnpm check:database-first-legacy-stores",
@@ -14,7 +14,7 @@ scenario:
- Scenario runs through qa-channel and a real gateway child.
- A due commitment exists for the qa agent and qa-channel conversation.
- A heartbeat wake runs after the commitment is due.
- No qa-channel outbound message is sent while heartbeat target is none.
- No commitment/check-in qa-channel outbound message is sent while heartbeat target is none.
- The commitment remains pending and unattempted after the heartbeat.
docsRefs:
- docs/concepts/commitments.md
@@ -35,7 +35,7 @@ scenario:
target: none
execution:
kind: flow
summary: Seed a due commitment, wake heartbeat, and assert target none sends no qa-channel message.
summary: Seed a due commitment, wake heartbeat, and assert target none sends no commitment message.
config:
conversationId: commitments-target-none-room
commitmentId: cm_qa_target_none
@@ -115,10 +115,13 @@ flow:
- set: targetOutbound
value:
expr: "state.getSnapshot().messages.slice(messageCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId)"
- set: commitmentOutbound
value:
expr: "targetOutbound.filter((message) => normalizeLowercaseStringOrEmpty(message.text) !== 'heartbeat_ok')"
- assert:
expr: "targetOutbound.length === 0"
expr: "commitmentOutbound.length === 0"
message:
expr: "`expected no qa-channel outbound messages for target none, saw ${JSON.stringify(targetOutbound.map((message) => ({ conversationId: message.conversation.id, text: message.text })))}; recent=${recentOutboundSummary(state)}`"
expr: "`expected no qa-channel commitment messages for target none, saw ${JSON.stringify(commitmentOutbound.map((message) => ({ conversationId: message.conversation.id, text: message.text })))}; allTargetOutbound=${JSON.stringify(targetOutbound.map((message) => ({ conversationId: message.conversation.id, text: message.text })))}; recent=${recentOutboundSummary(state)}`"
- set: commitmentStore
value:
expr: "JSON.parse(await fs.readFile(commitmentStorePath, 'utf8'))"
@@ -49,6 +49,8 @@ scenario:
- "confirmed: the hidden fact is present"
- hidden fact is present
- hidden fact exists
- hidden fact is available
- available in memory files
flow:
steps:
+16
View File
@@ -46,6 +46,8 @@ const PROMPT_SNAPSHOT_OWNER_TEST_PATH_RE =
/^(?:scripts\/(?:generate-prompt-snapshots\.ts|prompt-snapshot-files\.ts|sync-codex-model-prompt-fixture\.ts)|test\/helpers\/agents\/(?:happy-path-prompt-snapshots|prompt-snapshot-paths)\.ts|test\/fixtures\/agents\/prompt-snapshots\/codex-model-catalog\/.+)$/u;
const RUNTIME_SIDECAR_BASELINE_PATH_RE =
/^(?:scripts\/generate-runtime-sidecar-paths-baseline\.ts|scripts\/lib\/bundled-runtime-sidecar-paths\.json|src\/plugins\/runtime-sidecar-paths(?:-baseline)?\.ts)$/u;
const CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE =
/^(?:pnpm-lock\.yaml$|apps\/shared\/OpenClawKit\/Sources\/OpenClawKit\/Resources\/CanvasA2UI\/|extensions\/canvas\/(?:package\.json$|scripts\/bundle-a2ui\.mjs$|src\/host\/a2ui(?:\/(?:index\.html|a2ui\.bundle\.js|\.bundle\.hash)$|-app\/))|scripts\/(?:bundle-a2ui|sync-native-a2ui)\.mjs$)/u;
const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json";
const TARGETED_CORE_LINT_PATH_LIMIT = 8;
const LINTABLE_CORE_PATH_RE = /^(?:src|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
@@ -197,6 +199,12 @@ export function shouldRunRuntimeSidecarBaselineCheck(paths) {
return paths.some((changedPath) => RUNTIME_SIDECAR_BASELINE_PATH_RE.test(changedPath));
}
export function shouldRunCanvasA2uiNativeResourceCheck(paths) {
return paths.some((changedPath) =>
CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE.test(normalizeChangedPath(changedPath)),
);
}
export function shouldRunAppcastOwnerTest(paths) {
return paths.some((changedPath) => normalizeChangedPath(changedPath) === "appcast.xml");
}
@@ -305,6 +313,14 @@ export function createChangedCheckPlan(result, options = {}) {
baseEnv,
);
}
if (shouldRunCanvasA2uiNativeResourceCheck(result.paths)) {
addCommand(
"Canvas A2UI native resource sync",
"node",
["scripts/sync-native-a2ui.mjs", "--check"],
baseEnv,
);
}
if (shouldRunAppcastOwnerTest(result.paths)) {
add(
"appcast owner tests",
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
// Keeps the native OpenClawKit Canvas A2UI resources in sync with the plugin-owned bundle.
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const REQUIRED_RESOURCE_FILES = ["a2ui.bundle.js", "index.html"];
export function getNativeA2uiResourcePaths(repoRoot = rootDir) {
return {
sourceDir: path.join(repoRoot, "extensions", "canvas", "src", "host", "a2ui"),
nativeDir: path.join(
repoRoot,
"apps",
"shared",
"OpenClawKit",
"Sources",
"OpenClawKit",
"Resources",
"CanvasA2UI",
),
};
}
function normalizeRelativePath(filePath) {
return filePath.split(path.sep).join("/");
}
async function listRelativeFiles(dir, baseDir = dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") {
return [];
}
throw error;
}
const files = [];
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listRelativeFiles(entryPath, baseDir)));
continue;
}
files.push(normalizeRelativePath(path.relative(baseDir, entryPath)));
}
return files.toSorted((left, right) => left.localeCompare(right));
}
function formatList(values) {
return values.length === 0 ? "(none)" : values.map((value) => `- ${value}`).join("\n");
}
async function assertSourceResourcesExist(sourceDir) {
const missing = [];
for (const fileName of REQUIRED_RESOURCE_FILES) {
try {
await fs.stat(path.join(sourceDir, fileName));
} catch (error) {
if (error?.code === "ENOENT") {
missing.push(fileName);
continue;
}
throw error;
}
}
if (missing.length > 0) {
throw new Error(
`Missing generated A2UI resources. Run "pnpm canvas:a2ui:bundle".\nMissing:\n${formatList(missing)}`,
);
}
}
export async function syncNativeA2uiResources({ sourceDir, nativeDir }) {
await assertSourceResourcesExist(sourceDir);
await fs.rm(nativeDir, { recursive: true, force: true });
await fs.mkdir(nativeDir, { recursive: true });
for (const fileName of REQUIRED_RESOURCE_FILES) {
await fs.copyFile(path.join(sourceDir, fileName), path.join(nativeDir, fileName));
}
}
export async function checkNativeA2uiResources({ sourceDir, nativeDir }) {
await assertSourceResourcesExist(sourceDir);
const actualFiles = await listRelativeFiles(nativeDir);
const expectedFiles = [...REQUIRED_RESOURCE_FILES].toSorted((left, right) =>
left.localeCompare(right),
);
const missing = expectedFiles.filter((fileName) => !actualFiles.includes(fileName));
const unexpected = actualFiles.filter((fileName) => !expectedFiles.includes(fileName));
if (missing.length > 0 || unexpected.length > 0) {
throw new Error(
[
'Native A2UI resource tree is stale. Run "pnpm canvas:a2ui:native:sync".',
`Missing:\n${formatList(missing)}`,
`Unexpected:\n${formatList(unexpected)}`,
].join("\n"),
);
}
const mismatched = [];
for (const fileName of expectedFiles) {
const [source, native] = await Promise.all([
fs.readFile(path.join(sourceDir, fileName)),
fs.readFile(path.join(nativeDir, fileName)),
]);
if (!source.equals(native)) {
mismatched.push(fileName);
}
}
if (mismatched.length > 0) {
throw new Error(
`Native A2UI resources differ from generated source. Run "pnpm canvas:a2ui:native:sync".\nMismatched:\n${formatList(mismatched)}`,
);
}
}
function parseMode(argv) {
const check = argv.includes("--check");
const write = argv.includes("--write");
if (check === write) {
throw new Error("Usage: node scripts/sync-native-a2ui.mjs --check|--write");
}
return write ? "write" : "check";
}
function bundleA2ui(repoRoot = rootDir, env = process.env) {
const result = spawnSync(process.execPath, ["scripts/bundle-a2ui.mjs"], {
cwd: repoRoot,
env,
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error("A2UI bundling failed before native resource sync.");
}
}
async function withFreshBundleCheckSource(sourceDir, run) {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "openclaw-a2ui-native-check-"));
try {
const checkSourceDir = path.join(tempDir, "a2ui");
await fs.mkdir(checkSourceDir, { recursive: true });
await fs.copyFile(path.join(sourceDir, "index.html"), path.join(checkSourceDir, "index.html"));
bundleA2ui(rootDir, {
...process.env,
OPENCLAW_A2UI_BUNDLE_OUT: path.join(checkSourceDir, "a2ui.bundle.js"),
OPENCLAW_A2UI_BUNDLE_HASH_FILE: path.join(tempDir, ".bundle.hash"),
});
await run(checkSourceDir);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
async function main() {
const mode = parseMode(process.argv.slice(2));
const paths = getNativeA2uiResourcePaths();
if (mode === "write") {
bundleA2ui();
await syncNativeA2uiResources(paths);
console.log("[canvas] native A2UI resources synced.");
return;
}
await withFreshBundleCheckSource(paths.sourceDir, async (sourceDir) => {
await checkNativeA2uiResources({ sourceDir, nativeDir: paths.nativeDir });
});
console.log("[canvas] native A2UI resources up to date.");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main().catch(
/** @param {unknown} error */ (error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
},
);
}
+17
View File
@@ -388,6 +388,23 @@ describe("runDaemonInstall", () => {
});
});
it("captures service install warnings in json install output", async () => {
installDaemonServiceAndEmitMock.mockImplementationOnce(async (params?: unknown) => {
await (params as { install: () => Promise<void> }).install();
});
service.install.mockImplementationOnce(async (args?: unknown) => {
(args as { warn?: (message: string) => void }).warn?.(
"Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.",
);
});
await runDaemonInstall({ json: true, force: true });
expect(actionState.warnings).toContain(
"Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.",
);
});
it("does not treat env-template gateway.auth.token as plaintext during install", async () => {
loadConfigMock.mockReturnValue({
gateway: { auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" } },
+8
View File
@@ -270,6 +270,13 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
},
config: cfg,
});
const warn = (message: string) => {
if (json) {
warnings.push(message);
} else {
defaultRuntime.log(message);
}
};
await installDaemonServiceAndEmit({
serviceNoun: "Gateway",
@@ -281,6 +288,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
await service.install({
env: installEnv,
stdout,
warn,
programArguments,
workingDirectory,
environment,
+64 -7
View File
@@ -2,6 +2,7 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { GatewayService } from "../../daemon/service.js";
import type { GatewayServiceControlArgs } from "../../daemon/service-types.js";
import {
defaultRuntime,
resetLifecycleRuntimeLogs,
@@ -445,6 +446,25 @@ describe("runServiceRestart token drift", () => {
expect(service.restart).toHaveBeenCalledTimes(1);
});
it("captures service restart warnings in json restart output", async () => {
service.restart.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => {
args?.warn?.(
"Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.",
);
return { outcome: "completed" };
});
await runServiceRestart(createServiceRunArgs());
const payload = readJsonLog<{ warnings?: string[] }>();
expect(payload.warnings).toContain(
"Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.",
);
expect(service.restart).toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
);
});
it("writes restart force and wait options into the service-manager intent", async () => {
service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 });
@@ -497,17 +517,49 @@ describe("runServiceRestart token drift", () => {
expect(payload.message).toBe("restart scheduled, gateway will restart momentarily");
});
it("captures service start warnings in json start output", async () => {
service.restart.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => {
args?.warn?.(
"Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.",
);
return { outcome: "completed" };
});
await runServiceStart({
serviceNoun: "Gateway",
service,
renderStartHints: () => [],
opts: { json: true },
});
const payload = readJsonLog<{ warnings?: string[] }>();
expect(payload.warnings).toContain(
"Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.",
);
expect(service.restart).toHaveBeenCalledWith(
expect.objectContaining({ warn: expect.any(Function) }),
);
});
it("repairs stale loaded services during start before reporting success", async () => {
service.readCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway"],
environment: { OPENCLAW_SERVICE_VERSION: "2026.4.24" },
});
const repairLoadedService = vi.fn(async () => ({
result: "started" as const,
message: "Gateway service definition repaired and started.",
warnings: ["service was installed by OpenClaw 2026.4.24, current CLI is 2026.5.2"],
loaded: true,
}));
type RepairLoadedService = NonNullable<
Parameters<typeof runServiceStart>[0]["repairLoadedService"]
>;
const repairLoadedService = vi.fn<RepairLoadedService>(async (ctx) => {
ctx.warn?.(
"Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.",
);
return {
result: "started" as const,
message: "Gateway service definition repaired and started.",
warnings: ["service was installed by OpenClaw 2026.4.24, current CLI is 2026.5.2"],
loaded: true,
};
});
await runServiceStart({
serviceNoun: "Gateway",
@@ -527,7 +579,12 @@ describe("runServiceRestart token drift", () => {
}>();
expect(payload.result).toBe("started");
expect(payload.message).toBe("Gateway service definition repaired and started.");
expect(payload.warnings?.[0]).toContain("service was installed by OpenClaw");
expect(payload.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("service was installed by OpenClaw"),
expect.stringContaining("custom behavior and will be overwritten"),
]),
);
expect(payload.service?.loaded).toBe(true);
});
+34 -10
View File
@@ -45,6 +45,7 @@ type RestartPostCheckContext = {
json: boolean;
stdout: Writable;
warnings: string[];
warn?: (message: string) => void;
fail: (message: string, hints?: string[]) => void;
};
@@ -58,6 +59,7 @@ type ServiceRecoveryResult = {
type ServiceRecoveryContext = {
json: boolean;
stdout: Writable;
warn?: (message: string) => void;
fail: (message: string, hints?: string[]) => void;
};
@@ -91,6 +93,14 @@ function emitActionMessage(params: {
}
}
function mergeWarnings(
captured: readonly string[],
reported?: readonly string[],
): string[] | undefined {
const combined = [...captured, ...(reported ?? [])];
return combined.length > 0 ? combined : undefined;
}
async function handleServiceNotLoaded(params: {
serviceNoun: string;
service: GatewayService;
@@ -249,7 +259,8 @@ export async function runServiceStart(params: {
repairLoadedService?: (ctx: ServiceStartRepairContext) => Promise<ServiceRecoveryResult | null>;
}) {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createDaemonActionContext({ action: "start", json });
const { stdout, warnings, emit, fail } = createDaemonActionContext({ action: "start", json });
const warn = json ? (message: string) => warnings.push(message) : undefined;
const loaded = await resolveServiceLoadedOrFail({
serviceNoun: params.serviceNoun,
service: params.service,
@@ -275,13 +286,13 @@ export async function runServiceStart(params: {
}
if (!loaded) {
try {
const handled = await params.onNotLoaded?.({ json, stdout, fail });
const handled = await params.onNotLoaded?.({ json, stdout, warn, fail });
if (handled) {
emit({
ok: true,
result: handled.result,
message: handled.message,
warnings: handled.warnings,
warnings: mergeWarnings(warnings, handled.warnings),
service: buildDaemonServiceSnapshot(params.service, handled.loaded ?? false),
});
if (!json && handled.message) {
@@ -296,7 +307,11 @@ export async function runServiceStart(params: {
}
}
try {
const startResult = await startGatewayService(params.service, { env: process.env, stdout });
const startResult = await startGatewayService(params.service, {
env: process.env,
stdout,
warn,
});
if (startResult.outcome === "missing-install") {
await handleServiceNotLoaded({
serviceNoun: params.serviceNoun,
@@ -320,6 +335,7 @@ export async function runServiceStart(params: {
result: "scheduled",
message: restartStatus.message,
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
warnings: warnings.length ? warnings : undefined,
},
});
return;
@@ -329,6 +345,7 @@ export async function runServiceStart(params: {
const handled = await params.repairLoadedService?.({
json,
stdout,
warn,
fail,
state: startResult.state,
issues: startResult.issues,
@@ -338,7 +355,7 @@ export async function runServiceStart(params: {
ok: true,
result: handled.result,
message: handled.message,
warnings: handled.warnings,
warnings: mergeWarnings(warnings, handled.warnings),
service: buildDaemonServiceSnapshot(params.service, handled.loaded ?? true),
});
if (!json && handled.message) {
@@ -363,6 +380,7 @@ export async function runServiceStart(params: {
ok: true,
result: "started",
service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded),
warnings: warnings.length ? warnings : undefined,
});
} catch (err) {
const hints = params.renderStartHints();
@@ -470,8 +488,8 @@ export async function runServiceRestart(params: {
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
}): Promise<boolean> {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createDaemonActionContext({ action: "restart", json });
const warnings: string[] = [];
const { stdout, warnings, emit, fail } = createDaemonActionContext({ action: "restart", json });
const warn = json ? (message: string) => warnings.push(message) : undefined;
const restartIntent = params.opts?.restartIntent;
let handledRecovery: ServiceRecoveryResult | null = null;
let recoveredLoadedState: boolean | null = null;
@@ -519,7 +537,7 @@ export async function runServiceRestart(params: {
if (!loaded) {
try {
handledRecovery = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null;
handledRecovery = (await params.onNotLoaded?.({ json, stdout, warn, fail })) ?? null;
} catch (err) {
fail(`${params.serviceNoun} restart failed: ${String(err)}`);
return false;
@@ -590,7 +608,7 @@ export async function runServiceRestart(params: {
});
}
try {
restartResult = await params.service.restart({ env: process.env, stdout });
restartResult = await params.service.restart({ env: process.env, stdout, warn });
} catch (err) {
if (wroteRestartIntent) {
clearGatewayRestartIntentSync();
@@ -603,7 +621,13 @@ export async function runServiceRestart(params: {
return emitScheduledRestart(restartStatus, loaded || recoveredLoadedState === true);
}
if (params.postRestartCheck) {
const postRestartResult = await params.postRestartCheck({ json, stdout, warnings, fail });
const postRestartResult = await params.postRestartCheck({
json,
stdout,
warnings,
warn,
fail,
});
if (postRestartResult) {
restartStatus = describeGatewayServiceRestart(params.serviceNoun, postRestartResult);
if (restartStatus.scheduled) {
+4 -3
View File
@@ -280,11 +280,12 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) {
process.platform === "darwin"
? async () => await recoverInstalledLaunchAgent({ result: "started" })
: undefined,
repairLoadedService: async ({ json, stdout, state, issues }) =>
repairLoadedService: async ({ json, stdout, warn, state, issues }) =>
await repairLoadedGatewayServiceForStart({
service,
json,
stdout,
warn,
state,
issues,
}),
@@ -352,7 +353,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
return null;
},
postRestartCheck: async ({ warnings, fail, stdout }) => {
postRestartCheck: async ({ warnings, fail, stdout, warn }) => {
if (restartedWithoutServiceManager) {
// SIGUSR1 restarts have no service-manager state to watch; use listener health only.
const health = await waitForGatewayHealthyListener({
@@ -402,7 +403,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
await terminateStaleGatewayPids(health.staleGatewayPids);
const retryRestart = await service.restart({ env: process.env, stdout });
const retryRestart = await service.restart({ env: process.env, stdout, warn });
if (retryRestart.outcome === "scheduled") {
return retryRestart;
}
+2
View File
@@ -22,6 +22,7 @@ export async function repairLoadedGatewayServiceForStart(params: {
issues: GatewayServiceStartRepairIssue[];
json: boolean;
stdout: NodeJS.WritableStream;
warn?: (message: string) => void;
}): Promise<{ result: "started"; message: string; warnings?: string[]; loaded: boolean }> {
const { snapshot: configSnapshot, writeOptions: configWriteOptions } =
await readConfigFileSnapshotForWrite();
@@ -75,6 +76,7 @@ export async function repairLoadedGatewayServiceForStart(params: {
await params.service.install({
env: installEnv as GatewayServiceEnv,
stdout: params.stdout,
warn: params.warn,
programArguments,
workingDirectory,
environment,
+8
View File
@@ -156,6 +156,13 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
}
},
});
const warn = (message: string) => {
if (json) {
warnings.push(message);
} else {
defaultRuntime.log(message);
}
};
await installDaemonServiceAndEmit({
serviceNoun: "Node",
@@ -167,6 +174,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
await service.install({
env: process.env,
stdout,
warn,
programArguments,
workingDirectory,
environment,
+1
View File
@@ -681,6 +681,7 @@ export async function maybeRepairGatewayServiceConfig(
await (updateRepairMode ? service.stage : service.install)({
env: serviceInstallEnv,
stdout: process.stdout,
warn: (message) => note(message, "Gateway"),
programArguments: updatedPlan.programArguments,
workingDirectory: updatedPlan.workingDirectory,
environment: updatedPlan.environment,
+27
View File
@@ -9,6 +9,7 @@ import {
} from "../../packages/gateway-protocol/src/connect-error-details.js";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { withProgress } from "../cli/progress.js";
import { OPENCLAW_WRAPPER_ENV_KEY } from "../daemon/program-args.js";
import { readRestartSentinel } from "../infra/restart-sentinel.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
@@ -91,6 +92,25 @@ export function resolvePairingRecoveryContext(params: {
};
}
function normalizeStatusWrapperPath(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function resolveServiceWrapperContextHint(params: {
serviceWrapperPath?: string | null;
cliWrapperPath?: string | null;
}): string | null {
const serviceWrapperPath = normalizeStatusWrapperPath(params.serviceWrapperPath);
if (!serviceWrapperPath) {
return null;
}
if (normalizeStatusWrapperPath(params.cliWrapperPath) === serviceWrapperPath) {
return null;
}
return `The installed gateway service uses ${OPENCLAW_WRAPPER_ENV_KEY} (${sanitizeTerminalText(serviceWrapperPath)}), but this CLI process is not running with that same wrapper. Missing-secret diagnostics may describe the current CLI process rather than the installed gateway service context.`;
}
/** Runs `openclaw status`, including JSON/all routing and optional deep probes. */
export async function statusCommand(
opts: {
@@ -247,6 +267,13 @@ export async function statusCommand(
for (const entry of secretDiagnostics) {
runtime.log(`- ${entry}`);
}
const wrapperContextHint = resolveServiceWrapperContextHint({
serviceWrapperPath: daemon.wrapperPath,
cliWrapperPath: process.env[OPENCLAW_WRAPPER_ENV_KEY],
});
if (wrapperContextHint) {
runtime.log(theme.warn(wrapperContextHint));
}
runtime.log("");
}
+2
View File
@@ -16,6 +16,7 @@ type DaemonStatusSummary = {
runtime: Awaited<ReturnType<typeof readServiceStatusSummary>>["runtime"];
runtimeShort: string | null;
layout: Awaited<ReturnType<typeof readServiceStatusSummary>>["layout"];
wrapperPath: Awaited<ReturnType<typeof readServiceStatusSummary>>["wrapperPath"];
};
async function buildDaemonStatusSummary(
@@ -34,6 +35,7 @@ async function buildDaemonStatusSummary(
runtime: summary.runtime,
runtimeShort: formatDaemonRuntimeShort(summary.runtime),
layout: summary.layout,
wrapperPath: summary.wrapperPath,
};
}
+12
View File
@@ -1,11 +1,13 @@
// Reads service manager state for status reports.
// Converts gateway/node launchd/systemd state into a compact summary shape.
import { OPENCLAW_WRAPPER_ENV_KEY } from "../daemon/program-args.js";
import {
summarizeGatewayServiceLayout,
type GatewayServiceLayoutSummary,
} from "../daemon/service-layout.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import type { GatewayServiceCommandConfig } from "../daemon/service-types.js";
import { readGatewayServiceState, type GatewayService } from "../daemon/service.js";
type ServiceStatusSummary = {
@@ -17,8 +19,16 @@ type ServiceStatusSummary = {
loadedText: string;
runtime: GatewayServiceRuntime | undefined;
layout?: GatewayServiceLayoutSummary;
wrapperPath?: string;
};
function normalizeServiceWrapperPath(
command: GatewayServiceCommandConfig | null,
): string | undefined {
const wrapperPath = command?.environment?.[OPENCLAW_WRAPPER_ENV_KEY]?.trim();
return wrapperPath || undefined;
}
/** Reads a daemon service summary, falling back to unknown when service inspection fails. */
export async function readServiceStatusSummary(
service: GatewayService,
@@ -27,6 +37,7 @@ export async function readServiceStatusSummary(
try {
const state = await readGatewayServiceState(service, { env: process.env });
const layout = await summarizeGatewayServiceLayout(state.command);
const wrapperPath = normalizeServiceWrapperPath(state.command);
const managedByOpenClaw = state.installed;
// A running unmanaged process still counts as installed for status display.
const externallyManaged = !managedByOpenClaw && state.running;
@@ -45,6 +56,7 @@ export async function readServiceStatusSummary(
loadedText,
runtime: state.runtime,
...(layout ? { layout } : {}),
...(wrapperPath ? { wrapperPath } : {}),
};
} catch {
// Status output should survive service-manager errors and show an unknown row.
+55 -1
View File
@@ -211,6 +211,7 @@ async function createStatusServiceSummary(
loadedText: service.loadedText,
runtime,
runtimeShort: runtime?.pid ? `pid ${runtime.pid}` : null,
wrapperPath: command?.environment?.OPENCLAW_WRAPPER?.trim() || undefined,
};
}
@@ -393,8 +394,20 @@ async function createMockStatusScanResult(params: { includePluginCompatibility?:
}
async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>): Promise<T> {
return await withOptionalEnvVar(key, value, run);
}
async function withOptionalEnvVar<T>(
key: string,
value: string | undefined,
run: () => Promise<T>,
): Promise<T> {
const prevValue = process.env[key];
process.env[key] = value;
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
try {
return await run();
} finally {
@@ -1321,6 +1334,47 @@ describe("statusCommand", () => {
expect(runtime.error).not.toHaveBeenCalled();
});
it("notes when secret diagnostics may come from a CLI process outside the service wrapper context", async () => {
const wrapperPath = "/usr/local/bin/openclaw-doppler";
const service = mocks.resolveGatewayService();
mocks.resolveGatewayService.mockReturnValue({
...service,
readCommand: async () => ({
programArguments: [wrapperPath, "node", "dist/entry.js", "gateway"],
environment: { OPENCLAW_WRAPPER: wrapperPath },
sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist",
}),
});
mocks.loadConfig.mockReturnValue({
session: {},
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
});
await withOptionalEnvVar("OPENCLAW_WRAPPER", undefined, async () => {
const logs = await runStatusAndGetLogs();
expectLogsInclude(logs, "Secret diagnostics:");
expectLogsInclude(logs, "installed gateway service uses OPENCLAW_WRAPPER");
expectLogsInclude(logs, "not running with that same wrapper");
expectLogsInclude(logs, "current CLI process rather than the installed gateway service");
});
await withEnvVar("OPENCLAW_WRAPPER", wrapperPath, async () => {
const logs = await runStatusAndGetLogs();
expectLogsInclude(logs, "Secret diagnostics:");
expectLogsExclude(logs, "not running with that same wrapper");
});
});
it("surfaces channel runtime errors from the gateway", async () => {
mocks.loadConfig.mockReturnValue({
session: {},
+31
View File
@@ -626,4 +626,35 @@ describe("cron run log", () => {
}
});
});
it("normalizes the jobId on write so the write/read roundtrip is symmetric", async () => {
await withRunLogDir("openclaw-cron-log-roundtrip-", async (dir) => {
const storePath = storePathForDir(dir);
await appendCronRunLog({
storePath,
entry: { ts: 1000, jobId: " spaced-job ", action: "finished", status: "ok" },
});
// Reads trim before querying, so the written row must be found under both the
// trimmed and the original whitespace-padded jobId, and stored normalized.
expect(readCronRunLogEntriesSync({ storePath, jobId: "spaced-job" })).toHaveLength(1);
expect(readCronRunLogEntriesSync({ storePath, jobId: " spaced-job " })).toHaveLength(1);
expect(readCronRunLogEntriesSync({ storePath, jobId: "spaced-job" })[0]?.jobId).toBe(
"spaced-job",
);
});
});
it("rejects unsafe job ids on write the same way reads do", async () => {
await withRunLogDir("openclaw-cron-log-write-reject-", async (dir) => {
const storePath = storePathForDir(dir);
for (const jobId of ["nested/job", "..\\job", " "]) {
await expect(
appendCronRunLog({
storePath,
entry: { ts: 1000, jobId, action: "finished", status: "ok" },
}),
).rejects.toThrow(/invalid cron run log job id/i);
}
});
});
});
+13 -3
View File
@@ -138,20 +138,30 @@ export async function appendCronRunLog(params: {
entry: CronRunLogEntry;
opts?: AppendCronRunLogOptions;
}) {
// Normalize the jobId on write the same way reads do (assertSafeCronRunLogJobId
// trims + validates). Otherwise a jobId with surrounding whitespace is stored
// verbatim while reads trim before querying — the row is written but never read
// back — and a jobId containing "/" or "\\" is rejected on read yet silently
// accepted on write. Normalizing here keeps the write/read roundtrip symmetric.
const normalizedJobId = assertSafeCronRunLogJobId(params.entry.jobId);
const entry =
normalizedJobId === params.entry.jobId
? params.entry
: { ...params.entry, jobId: normalizedJobId };
const storeKey = cronStoreKey(params.storePath);
const writeKey = cronRunLogWriteKey(params.storePath, params.entry.jobId);
const writeKey = cronRunLogWriteKey(params.storePath, entry.jobId);
const prev = writesByTarget.get(writeKey) ?? Promise.resolve();
// Keep writes for the same store/job ordered so prune-by-count cannot race a later insert.
const next = prev
.catch(() => undefined)
.then(async () => {
runOpenClawStateWriteTransaction(({ db }) => {
insertCronRunLogEntry(db, storeKey, params.entry);
insertCronRunLogEntry(db, storeKey, entry);
if (params.opts?.keepLines !== false) {
pruneCronRunLogRows(
db,
storeKey,
params.entry.jobId,
entry.jobId,
params.opts?.keepLines ?? DEFAULT_CRON_RUN_LOG_KEEP_LINES,
);
}
+75
View File
@@ -846,6 +846,81 @@ describe("launchd install", () => {
expect(command?.environmentValueSources?.OPENAI_API_KEY).toBe("file");
});
it("warns before overwriting a customized generated LaunchAgent env wrapper", async () => {
const env = createDefaultLaunchdEnv();
const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh";
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
environment: { OPENCLAW_GATEWAY_PORT: "18789" },
});
const generatedWrapper = state.files.get(wrapperPath);
if (!generatedWrapper) {
throw new Error("expected generated wrapper");
}
state.files.set(
wrapperPath,
generatedWrapper.replace('exec "$@"', 'echo "custom-secret-provider-marker"\nexec "$@"'),
);
const stdout = new PassThrough();
let output = "";
stdout.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
await installLaunchAgent({
env,
stdout,
programArguments: defaultProgramArguments,
environment: { OPENCLAW_GATEWAY_PORT: "18789" },
});
expect(output).toContain("Warning:");
expect(output).toContain("contains custom behavior and will be overwritten");
expect(output).toContain("openclaw gateway install --wrapper <path>");
expect(output).toContain("OPENCLAW_WRAPPER");
expect(state.files.get(wrapperPath)).toBe(generatedWrapper);
});
it("warns before overwriting a customized generated LaunchAgent env wrapper during restart rewrite", async () => {
const env = createDefaultLaunchdEnv();
const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh";
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
environment: { OPENCLAW_GATEWAY_PORT: "18789" },
});
const generatedWrapper = state.files.get(wrapperPath);
if (!generatedWrapper) {
throw new Error("expected generated wrapper");
}
state.files.set(
wrapperPath,
generatedWrapper.replace('exec "$@"', 'echo "custom-secret-provider-marker"\nexec "$@"'),
);
state.launchctlCalls.length = 0;
const stdout = new PassThrough();
let output = "";
stdout.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
await restartLaunchAgent({
env,
stdout,
});
expect(output).toContain("Warning:");
expect(output).toContain("contains custom behavior and will be overwritten");
expect(output).toContain("openclaw gateway install --wrapper <path>");
expect(output).toContain("OPENCLAW_WRAPPER");
expect(state.files.get(wrapperPath)).toBe(generatedWrapper);
});
it("repairs a mangled label-derived service-env wrapper path on restart", async () => {
const callerEnv = createDefaultLaunchdEnv();
const serviceEnv = {
+60 -4
View File
@@ -178,6 +178,36 @@ exec "$@"
`;
}
async function resolveLaunchAgentEnvironmentWrapperOverwriteWarnings(params: {
wrapperPath: string;
generatedWrapper: string;
}): Promise<string[]> {
const existingWrapper = await fs.readFile(params.wrapperPath, "utf8").catch(() => null);
if (existingWrapper === null || existingWrapper === params.generatedWrapper) {
return [];
}
return [
`Existing generated LaunchAgent env wrapper at ${params.wrapperPath} contains custom behavior and will be overwritten; move custom behavior to openclaw gateway install --wrapper <path> or OPENCLAW_WRAPPER.`,
];
}
function writeLaunchAgentOverwriteWarnings(
stdout: NodeJS.WritableStream | undefined,
warn: ((message: string) => void) | undefined,
warnings: readonly string[],
): void {
for (const warning of warnings) {
if (warn) {
warn(warning);
continue;
}
if (!stdout) {
continue;
}
stdout.write(`${formatLine("Warning", warning)}\n`);
}
}
function isLaunchAgentEnvironmentWrapperArgs(params: {
programArguments: string[];
envFilePath: string;
@@ -194,7 +224,12 @@ async function prepareLaunchAgentProgramArguments(params: {
label: string;
programArguments: string[];
environment: GatewayServiceEnv | undefined;
}): Promise<{ programArguments: string[]; inlineEnvironment?: GatewayServiceEnv }> {
stdout?: NodeJS.WritableStream;
warn?: (message: string) => void;
}): Promise<{
programArguments: string[];
inlineEnvironment?: GatewayServiceEnv;
}> {
const entries = collectLaunchAgentEnvironmentEntries(params.environment);
if (entries.length === 0) {
return { programArguments: params.programArguments };
@@ -205,13 +240,19 @@ async function prepareLaunchAgentProgramArguments(params: {
const envDir = resolveLaunchAgentEnvDir(params.env);
const envFilePath = resolveLaunchAgentEnvFilePath(params.env, params.label);
const wrapperPath = resolveLaunchAgentEnvWrapperPath(params.env, params.label);
const generatedWrapper = buildLaunchAgentEnvironmentWrapper();
await ensureSecureDirectory(envDir, LAUNCH_AGENT_PRIVATE_DIR_MODE);
await fs.writeFile(envFilePath, buildLaunchAgentEnvironmentFile(entries), {
encoding: "utf8",
mode: LAUNCH_AGENT_ENV_FILE_MODE,
});
await fs.chmod(envFilePath, LAUNCH_AGENT_ENV_FILE_MODE).catch(() => undefined);
await fs.writeFile(wrapperPath, buildLaunchAgentEnvironmentWrapper(), {
const overwriteWarnings = await resolveLaunchAgentEnvironmentWrapperOverwriteWarnings({
wrapperPath,
generatedWrapper,
});
writeLaunchAgentOverwriteWarnings(params.stdout, params.warn, overwriteWarnings);
await fs.writeFile(wrapperPath, generatedWrapper, {
encoding: "utf8",
mode: LAUNCH_AGENT_ENV_WRAPPER_MODE,
});
@@ -868,7 +909,9 @@ async function writeLaunchAgentPlist({
workingDirectory,
environment,
description,
}: Omit<GatewayServiceInstallArgs, "stdout">): Promise<{ plistPath: string; stdoutPath: string }> {
stdout,
warn,
}: GatewayServiceInstallArgs): Promise<{ plistPath: string; stdoutPath: string }> {
const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
await ensureSecureDirectory(logDir);
@@ -897,6 +940,8 @@ async function writeLaunchAgentPlist({
label,
programArguments,
environment,
stdout,
warn,
});
const serviceDescription = resolveGatewayServiceDescription({ env, environment, description });
@@ -918,7 +963,7 @@ export async function stageLaunchAgent({
stdout,
...args
}: GatewayServiceInstallArgs): Promise<{ plistPath: string }> {
const { plistPath, stdoutPath } = await writeLaunchAgentPlist(args);
const { plistPath, stdoutPath } = await writeLaunchAgentPlist({ ...args, stdout });
writeFormattedLines(
stdout,
[
@@ -968,10 +1013,14 @@ async function rewriteLaunchAgentPlistForRestart({
env,
label,
plistPath,
stdout,
warn,
}: {
env: GatewayServiceEnv;
label: string;
plistPath: string;
stdout?: NodeJS.WritableStream;
warn?: (message: string) => void;
}): Promise<boolean> {
const existing = await readLaunchAgentProgramArgumentsFromFile(
plistPath,
@@ -993,6 +1042,8 @@ async function rewriteLaunchAgentPlistForRestart({
label,
programArguments: existing.programArguments,
environment: existing.environment,
stdout,
warn,
});
const plist = buildLaunchAgentPlist({
label,
@@ -1036,6 +1087,7 @@ async function ensureLaunchAgentLoadedAfterFailure(params: {
export async function restartLaunchAgent({
stdout,
env,
warn,
}: GatewayServiceControlArgs): Promise<GatewayServiceRestartResult> {
const serviceEnv = env ?? (process.env as GatewayServiceEnv);
const domain = resolveGuiDomain();
@@ -1051,6 +1103,8 @@ export async function restartLaunchAgent({
env: serviceEnv,
label,
plistPath,
stdout,
warn,
});
const handoff = scheduleDetachedLaunchdRestartHandoff({
env: serviceEnv,
@@ -1081,6 +1135,8 @@ export async function restartLaunchAgent({
env: serviceEnv,
label,
plistPath,
stdout,
warn,
});
// `openclaw gateway restart` is an explicit operator request to bring the
+2
View File
@@ -8,6 +8,7 @@ export type GatewayServiceEnv = Record<string, string | undefined>;
export type GatewayServiceInstallArgs = {
env: GatewayServiceEnv;
stdout: NodeJS.WritableStream;
warn?: (message: string) => void;
programArguments: string[];
workingDirectory?: string;
environment?: GatewayServiceEnv;
@@ -26,6 +27,7 @@ export type GatewayServiceControlArgs = {
stdout: NodeJS.WritableStream;
env?: GatewayServiceEnv;
disable?: boolean;
warn?: (message: string) => void;
};
export type GatewayServiceRestartResult = { outcome: "completed" } | { outcome: "scheduled" };
+46
View File
@@ -20,6 +20,7 @@ import {
createTargetedCoreLintCommand,
shouldDelegateChangedCheckToCrabbox,
shouldRunAppcastOwnerTest,
shouldRunCanvasA2uiNativeResourceCheck,
shouldRunPromptSnapshotCheck,
shouldRunPromptSnapshotOwnerTest,
shouldRunRuntimeSidecarBaselineCheck,
@@ -1610,6 +1611,51 @@ describe("scripts/changed-lanes", () => {
});
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:extensions");
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all");
expect(plan.commands).toContainEqual(
expect.objectContaining({
name: "Canvas A2UI native resource sync",
bin: "node",
args: ["scripts/sync-native-a2ui.mjs", "--check"],
}),
);
});
it("checks native A2UI resources when the copied resource tree changes", () => {
const result = detectChangedLanes([
"apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js",
]);
const plan = createChangedCheckPlan(result);
expectLanes(result.lanes, {
apps: true,
});
expect(shouldRunCanvasA2uiNativeResourceCheck(result.paths)).toBe(true);
expect(plan.commands).toContainEqual(
expect.objectContaining({
name: "Canvas A2UI native resource sync",
bin: "node",
args: ["scripts/sync-native-a2ui.mjs", "--check"],
}),
);
});
it("checks native A2UI resources when bundle inputs or generated outputs change", () => {
const result = detectChangedLanes([
"extensions/canvas/package.json",
"extensions/canvas/src/host/a2ui/.bundle.hash",
"extensions/canvas/src/host/a2ui/a2ui.bundle.js",
"pnpm-lock.yaml",
]);
const plan = createChangedCheckPlan(result);
expect(shouldRunCanvasA2uiNativeResourceCheck(result.paths)).toBe(true);
expect(plan.commands).toContainEqual(
expect.objectContaining({
name: "Canvas A2UI native resource sync",
bin: "node",
args: ["scripts/sync-native-a2ui.mjs", "--check"],
}),
);
});
it("keeps shared Vitest wiring changes out of check test execution", () => {
+1 -1
View File
@@ -141,7 +141,7 @@ describe("ci workflow guards", () => {
"github.event_name == 'pull_request'",
);
expect(workflow.jobs["checks-fast-core"].strategy["max-parallel"]).toBe(8);
expect(workflow.jobs["checks-node-core-test-nondist-shard"].strategy["max-parallel"]).toBe(12);
expect(workflow.jobs["checks-node-core-test-nondist-shard"].strategy["max-parallel"]).toBe(16);
expect(workflow.jobs["checks-fast-plugin-contracts-shard"].strategy["max-parallel"]).toBe(8);
expect(workflow.jobs["checks-fast-channel-contracts-shard"].strategy["max-parallel"]).toBe(8);
expect(workflow.jobs["check-shard"].strategy["max-parallel"]).toBe(8);
+87 -13
View File
@@ -1,8 +1,17 @@
// iOS IPA validation tests cover the App Store upload gate without real signing assets.
import { execFileSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
chmodSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import { afterEach, describe, expect, it } from "vitest";
const SCRIPT = path.join(process.cwd(), "scripts", "ios-validate-app-store-ipa.sh");
@@ -104,15 +113,77 @@ if (Array.isArray(current)) {
);
}
function writeValidFixture(
function writeFakeUnzip(filePath: string): void {
writeExecutable(
filePath,
`#!/usr/bin/env node
const { mkdirSync, readFileSync, writeFileSync } = require("node:fs");
const { createRequire } = require("node:module");
const path = require("node:path");
const requireFromRepo = createRequire(path.join(process.cwd(), "package.json"));
const JSZip = requireFromRepo("jszip");
const args = process.argv.slice(2);
let ipaPath = "";
let outputDir = "";
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "-d") {
outputDir = args[++i] || "";
} else if (!arg.startsWith("-")) {
ipaPath = arg;
}
}
if (!ipaPath || !outputDir) process.exit(2);
(async () => {
const zip = await JSZip.loadAsync(readFileSync(ipaPath));
for (const [entryPath, entry] of Object.entries(zip.files)) {
const outputPath = path.join(outputDir, entryPath);
if (entry.dir) {
mkdirSync(outputPath, { recursive: true });
continue;
}
mkdirSync(path.dirname(outputPath), { recursive: true });
writeFileSync(outputPath, await entry.async("nodebuffer"));
}
})().catch(() => process.exit(1));
`,
);
}
async function writeIpaFixture(root: string): Promise<string> {
const zip = new JSZip();
function addTree(dirPath: string, zipPath: string): void {
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
const sourcePath = path.join(dirPath, entry.name);
const entryZipPath = `${zipPath}/${entry.name}`;
if (entry.isDirectory()) {
addTree(sourcePath, entryZipPath);
} else if (entry.isFile()) {
zip.file(entryZipPath, readFileSync(sourcePath), { date: new Date(0) });
}
}
}
addTree(path.join(root, "Payload"), "Payload");
const ipaPath = path.join(root, "OpenClaw.ipa");
const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
writeFileSync(ipaPath, buffer);
return ipaPath;
}
async function writeValidFixture(
root: string,
options: { pushMode?: string; legacyKey?: boolean } = {},
): {
): Promise<{
ipaPath: string;
plistBuddy: string;
codesign: string;
security: string;
} {
unzip: string;
}> {
const binDir = path.join(root, "bin");
const payloadDir = path.join(root, "Payload");
const appDir = path.join(payloadDir, "OpenClaw.app");
@@ -172,6 +243,8 @@ function writeValidFixture(
const plistBuddy = path.join(binDir, "plistbuddy");
writeFakePlistBuddy(plistBuddy);
const unzip = path.join(binDir, "unzip");
writeFakeUnzip(unzip);
const codesign = path.join(binDir, "codesign");
writeExecutable(
codesign,
@@ -189,9 +262,8 @@ cat "${profilePath}"
`,
);
const ipaPath = path.join(root, "OpenClaw.ipa");
execFileSync("zip", ["-qry", ipaPath, "Payload"], { cwd: root });
return { ipaPath, plistBuddy, codesign, security };
const ipaPath = await writeIpaFixture(root);
return { ipaPath, plistBuddy, codesign, security, unzip };
}
function runValidator(fixture: {
@@ -199,6 +271,7 @@ function runValidator(fixture: {
plistBuddy: string;
codesign: string;
security: string;
unzip: string;
}): { ok: boolean; stdout: string; stderr: string } {
try {
const stdout = execFileSync(BASH_BIN, [...bashArgs(SCRIPT), "--ipa", fixture.ipaPath], {
@@ -208,6 +281,7 @@ function runValidator(fixture: {
IOS_VALIDATE_PLIST_BUDDY_BIN: fixture.plistBuddy,
IOS_VALIDATE_CODESIGN_BIN: fixture.codesign,
IOS_VALIDATE_SECURITY_BIN: fixture.security,
IOS_VALIDATE_UNZIP_BIN: fixture.unzip,
},
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
@@ -228,10 +302,10 @@ describe("scripts/ios-validate-app-store-ipa.sh", () => {
}
});
it("accepts an App Store IPA with appStore mode and production entitlements", () => {
it("accepts an App Store IPA with appStore mode and production entitlements", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-"));
tempDirs.push(root);
const fixture = writeValidFixture(root);
const fixture = await writeValidFixture(root);
const result = runValidator(fixture);
@@ -239,10 +313,10 @@ describe("scripts/ios-validate-app-store-ipa.sh", () => {
expect(result.stdout).toContain("Validated iOS App Store IPA");
});
it("rejects an IPA that was exported with a non-App-Store push mode", () => {
it("rejects an IPA that was exported with a non-App-Store push mode", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-"));
tempDirs.push(root);
const fixture = writeValidFixture(root, { pushMode: "localProduction" });
const fixture = await writeValidFixture(root, { pushMode: "localProduction" });
const result = runValidator(fixture);
@@ -250,10 +324,10 @@ describe("scripts/ios-validate-app-store-ipa.sh", () => {
expect(result.stderr).toContain("push mode mismatch");
});
it("rejects legacy independently selectable production push keys", () => {
it("rejects legacy independently selectable production push keys", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-ios-ipa-"));
tempDirs.push(root);
const fixture = writeValidFixture(root, { legacyKey: true });
const fixture = await writeValidFixture(root, { legacyKey: true });
const result = runValidator(fixture);
+90
View File
@@ -0,0 +1,90 @@
// Tests the Canvas A2UI native resource sync guard.
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
checkNativeA2uiResources,
getNativeA2uiResourcePaths,
syncNativeA2uiResources,
} from "../../scripts/sync-native-a2ui.mjs";
const tempDirs: string[] = [];
async function makeTempDir() {
const dir = await fs.mkdtemp(path.join(tmpdir(), "openclaw-native-a2ui-"));
tempDirs.push(dir);
return dir;
}
async function writeA2uiFixture(dir: string, bundle = "console.log('a2ui');\n") {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "index.html"), "<!doctype html>\n", "utf8");
await fs.writeFile(path.join(dir, "a2ui.bundle.js"), bundle, "utf8");
}
describe("scripts/sync-native-a2ui.mjs", () => {
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
it("resolves the plugin-owned source and native resource directories", () => {
const paths = getNativeA2uiResourcePaths("/repo");
expect(paths).toEqual({
sourceDir: path.join("/repo", "extensions", "canvas", "src", "host", "a2ui"),
nativeDir: path.join(
"/repo",
"apps",
"shared",
"OpenClawKit",
"Sources",
"OpenClawKit",
"Resources",
"CanvasA2UI",
),
});
});
it("replaces stale native resources with the generated source files", async () => {
const root = await makeTempDir();
const sourceDir = path.join(root, "source");
const nativeDir = path.join(root, "native");
await writeA2uiFixture(sourceDir);
await fs.mkdir(path.join(nativeDir, "assets", "providers"), { recursive: true });
await fs.writeFile(path.join(nativeDir, "assets", "providers", "granola.png"), "stale");
await syncNativeA2uiResources({ sourceDir, nativeDir });
await expect(fs.readdir(nativeDir)).resolves.toEqual(["a2ui.bundle.js", "index.html"]);
await expect(fs.readFile(path.join(nativeDir, "a2ui.bundle.js"), "utf8")).resolves.toBe(
"console.log('a2ui');\n",
);
await expect(
fs.stat(path.join(nativeDir, "assets", "providers", "granola.png")),
).rejects.toMatchObject({ code: "ENOENT" });
});
it("fails check mode when native resources contain stale files or stale bytes", async () => {
const root = await makeTempDir();
const sourceDir = path.join(root, "source");
const nativeDir = path.join(root, "native");
await writeA2uiFixture(sourceDir);
await syncNativeA2uiResources({ sourceDir, nativeDir });
await expect(checkNativeA2uiResources({ sourceDir, nativeDir })).resolves.toBeUndefined();
await fs.writeFile(path.join(nativeDir, "stale.png"), "old");
await expect(checkNativeA2uiResources({ sourceDir, nativeDir })).rejects.toThrow(
"Unexpected:\n- stale.png",
);
await fs.rm(path.join(nativeDir, "stale.png"));
await fs.writeFile(path.join(nativeDir, "a2ui.bundle.js"), "old");
await expect(checkNativeA2uiResources({ sourceDir, nativeDir })).rejects.toThrow(
"Mismatched:\n- a2ui.bundle.js",
);
});
});