mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
test: update helper routing expectation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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}" } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user