From 35abfe4ed2f903f4641d29dd0c7fd3b9ed84f098 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 23 Jun 2026 13:59:11 +0800 Subject: [PATCH] test: update helper routing expectation --- .github/workflows/ci.yml | 4 +- .github/workflows/codeql.yml | 18 +- apps/ios/UITests/SnapshotHelper.swift | 26 +-- apps/ios/fastlane/Fastfile | 79 +++++++- apps/ios/fastlane/SETUP.md | 2 +- apps/ios/fastlane/Snapfile | 7 +- .../Resources/CanvasA2UI/a2ui.bundle.js | 5 +- extensions/canvas/scripts/bundle-a2ui.mjs | 8 +- .../src/host/a2ui-app/rolldown.config.mjs | 4 +- .../qa-lab/src/scenario-catalog.test.ts | 2 +- package.json | 2 + .../commitments-heartbeat-target-none.yaml | 11 +- .../memory/memory-failure-fallback.yaml | 2 + scripts/check-changed.mjs | 16 ++ scripts/sync-native-a2ui.mjs | 183 ++++++++++++++++++ src/cli/daemon-cli/install.test.ts | 17 ++ src/cli/daemon-cli/install.ts | 8 + src/cli/daemon-cli/lifecycle-core.test.ts | 71 ++++++- src/cli/daemon-cli/lifecycle-core.ts | 44 ++++- src/cli/daemon-cli/lifecycle.ts | 7 +- src/cli/daemon-cli/start-repair.ts | 2 + src/cli/node-cli/daemon.ts | 8 + src/commands/doctor-gateway-services.ts | 1 + src/commands/status.command.ts | 27 +++ src/commands/status.daemon.ts | 2 + src/commands/status.service-summary.ts | 12 ++ src/commands/status.test.ts | 56 +++++- src/cron/run-log.test.ts | 31 +++ src/cron/run-log.ts | 16 +- src/daemon/launchd.test.ts | 75 +++++++ src/daemon/launchd.ts | 64 +++++- src/daemon/service-types.ts | 2 + test/scripts/changed-lanes.test.ts | 46 +++++ test/scripts/ci-workflow-guards.test.ts | 2 +- .../ios-validate-app-store-ipa.test.ts | 100 ++++++++-- test/scripts/sync-native-a2ui.test.ts | 90 +++++++++ 36 files changed, 968 insertions(+), 82 deletions(-) create mode 100644 scripts/sync-native-a2ui.mjs create mode 100644 test/scripts/sync-native-a2ui.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b595b65d780..dac2c24d2c24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0c2aaae03b7c..027e3a64a293 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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: diff --git a/apps/ios/UITests/SnapshotHelper.swift b/apps/ios/UITests/SnapshotHelper.swift index 015cfeaa5532..6dec13020bed 100644 --- a/apps/ios/UITests/SnapshotHelper.swift +++ b/apps/ios/UITests/SnapshotHelper.swift @@ -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] diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 37acb4e40648..c4cb48e73a84 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -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 diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index 30abf9daddbe..c94b815c92ba 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -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: diff --git a/apps/ios/fastlane/Snapfile b/apps/ios/fastlane/Snapfile index 3cb6236c9762..a748161bbffb 100644 --- a/apps/ios/fastlane/Snapfile +++ b/apps/ios/fastlane/Snapfile @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js index 424d9ca0c65f..8c536026b15f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js @@ -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; diff --git a/extensions/canvas/scripts/bundle-a2ui.mjs b/extensions/canvas/scripts/bundle-a2ui.mjs index b0f86ea61686..cdfeca2b42a5 100644 --- a/extensions/canvas/scripts/bundle-a2ui.mjs +++ b/extensions/canvas/scripts/bundle-a2ui.mjs @@ -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) => diff --git a/extensions/canvas/src/host/a2ui-app/rolldown.config.mjs b/extensions/canvas/src/host/a2ui-app/rolldown.config.mjs index 38a0b2d73a96..435e4b93e60e 100644 --- a/extensions/canvas/src/host/a2ui-app/rolldown.config.mjs +++ b/extensions/canvas/src/host/a2ui-app/rolldown.config.mjs @@ -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"); diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 04a3ddf1eeea..8b5e1c8ca33a 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -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"); }); diff --git a/package.json b/package.json index 7c17d1e4881d..ac72e0d96b9b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/qa/scenarios/memory/commitments-heartbeat-target-none.yaml b/qa/scenarios/memory/commitments-heartbeat-target-none.yaml index a4afa17803c3..02ebdabe437f 100644 --- a/qa/scenarios/memory/commitments-heartbeat-target-none.yaml +++ b/qa/scenarios/memory/commitments-heartbeat-target-none.yaml @@ -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'))" diff --git a/qa/scenarios/memory/memory-failure-fallback.yaml b/qa/scenarios/memory/memory-failure-fallback.yaml index b5b70305085f..fa3e24722ce2 100644 --- a/qa/scenarios/memory/memory-failure-fallback.yaml +++ b/qa/scenarios/memory/memory-failure-fallback.yaml @@ -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: diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index de297874196b..6a353cea03e7 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -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", diff --git a/scripts/sync-native-a2ui.mjs b/scripts/sync-native-a2ui.mjs new file mode 100644 index 000000000000..35dd9dc0ff4e --- /dev/null +++ b/scripts/sync-native-a2ui.mjs @@ -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); + }, + ); +} diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 36eacd1157df..23462c893e94 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -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 }).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}" } }, diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 1d8382c5d3e5..08d7850e9817 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -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, diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 749ee0192b5d..8f77c9f0453d 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -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[0]["repairLoadedService"] + >; + const repairLoadedService = vi.fn(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); }); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 021f2c3eca08..e820ded93202 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -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; }) { 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; }): Promise { 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) { diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 78f213e00132..7b7a8f8a60a5 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -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; } diff --git a/src/cli/daemon-cli/start-repair.ts b/src/cli/daemon-cli/start-repair.ts index f1d80b3b623f..9edc91fcc5f3 100644 --- a/src/cli/daemon-cli/start-repair.ts +++ b/src/cli/daemon-cli/start-repair.ts @@ -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, diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 2b86adf03e0d..03e743247847 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -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, diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 3e556cd23b83..756cbcd0424d 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -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, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 73f2236aa27e..e2f6ccadf64b 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -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(""); } diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts index 0f55575ace6b..bc8631cd8fad 100644 --- a/src/commands/status.daemon.ts +++ b/src/commands/status.daemon.ts @@ -16,6 +16,7 @@ type DaemonStatusSummary = { runtime: Awaited>["runtime"]; runtimeShort: string | null; layout: Awaited>["layout"]; + wrapperPath: Awaited>["wrapperPath"]; }; async function buildDaemonStatusSummary( @@ -34,6 +35,7 @@ async function buildDaemonStatusSummary( runtime: summary.runtime, runtimeShort: formatDaemonRuntimeShort(summary.runtime), layout: summary.layout, + wrapperPath: summary.wrapperPath, }; } diff --git a/src/commands/status.service-summary.ts b/src/commands/status.service-summary.ts index 8ef99ae61b57..18c02e1e235d 100644 --- a/src/commands/status.service-summary.ts +++ b/src/commands/status.service-summary.ts @@ -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. diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index d1036fc27bc0..5ca2268054d4 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -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(key: string, value: string, run: () => Promise): Promise { + return await withOptionalEnvVar(key, value, run); +} + +async function withOptionalEnvVar( + key: string, + value: string | undefined, + run: () => Promise, +): Promise { 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: {}, diff --git a/src/cron/run-log.test.ts b/src/cron/run-log.test.ts index aa242ea56180..eaa639439242 100644 --- a/src/cron/run-log.test.ts +++ b/src/cron/run-log.test.ts @@ -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); + } + }); + }); }); diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index 880453dcb3be..40d5dddc7905 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -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, ); } diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 8565b78ceddf..0538b21b783f 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -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 "); + 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 "); + 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 = { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 4b001f76710b..436c20d8977c 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -178,6 +178,36 @@ exec "$@" `; } +async function resolveLaunchAgentEnvironmentWrapperOverwriteWarnings(params: { + wrapperPath: string; + generatedWrapper: string; +}): Promise { + 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 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): 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 { 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 { 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 diff --git a/src/daemon/service-types.ts b/src/daemon/service-types.ts index a6df025f0043..b7846fbf3ad0 100644 --- a/src/daemon/service-types.ts +++ b/src/daemon/service-types.ts @@ -8,6 +8,7 @@ export type GatewayServiceEnv = Record; 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" }; diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 56d995cdde8f..1336644189f0 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -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", () => { diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index e951f90dee4f..ce0fa97216a5 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -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); diff --git a/test/scripts/ios-validate-app-store-ipa.test.ts b/test/scripts/ios-validate-app-store-ipa.test.ts index 4898170ebaef..2c842e5f060f 100644 --- a/test/scripts/ios-validate-app-store-ipa.test.ts +++ b/test/scripts/ios-validate-app-store-ipa.test.ts @@ -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 { + 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); diff --git a/test/scripts/sync-native-a2ui.test.ts b/test/scripts/sync-native-a2ui.test.ts new file mode 100644 index 000000000000..fa67cf6771d0 --- /dev/null +++ b/test/scripts/sync-native-a2ui.test.ts @@ -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"), "\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", + ); + }); +});