Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/ohos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: OpenHarmony

on:
push:
branches: [main, master]
paths:
- '.github/workflows/ohos.yml'
- 'Cargo.lock'
- 'Cargo.toml'
- 'crates/**'
- 'scripts/ohos/**'
- 'scripts/ohos-env.sh'
- 'scripts/release/check-ohos-deps.sh'
pull_request:
branches: [main, master]
paths:
- '.github/workflows/ohos.yml'
- 'Cargo.lock'
- 'Cargo.toml'
- 'crates/**'
- 'scripts/ohos/**'
- 'scripts/ohos-env.sh'
- 'scripts/release/check-ohos-deps.sh'
schedule:
- cron: '47 7 * * 1'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ohos-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings

jobs:
cargo-check:
name: cargo check (aarch64-unknown-linux-ohos)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- name: Install Rust target
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-ohos
- name: Install OpenHarmony native SDK
id: ohos-sdk
# Pin the action: it downloads a published OpenHarmony SDK and verifies
# the archive checksum. This job must never substitute host headers or
# a stub sysroot and report that as target support.
uses: openharmony-rs/setup-ohos-sdk@b312caf8a43bb836aa24c08f65f97e1f09a89cad
with:
version: '6.1'
components: native
cache: true
mirror: true
- name: Check Codewhale TUI with the real SDK/sysroot
shell: bash
env:
OHOS_NATIVE_SDK: ${{ steps.ohos-sdk.outputs.ohos_sdk_native }}
run: |
set -euo pipefail
test -x "${OHOS_NATIVE_SDK}/llvm/bin/clang"
test -d "${OHOS_NATIVE_SDK}/sysroot"
. ./scripts/ohos-env.sh

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: RUSTFLAGS: -Dwarnings is set job-wide (line 38), but scripts/ohos-env.sh (sourced here) exports CARGO_ENCODED_RUSTFLAGS for the target-specific linker flags. Cargo's rustflags sources are mutually exclusive with CARGO_ENCODED_RUSTFLAGS taking priority over RUSTFLAGS — it does not merge them. So for this job's cargo check (line 71), -Dwarnings is silently dropped and warnings won't fail the build, even though the job is meant to be "a real tier-2 check."

This directly undercuts the PR's own stated principle for this doc/CI addition (docs/HarmonyOS.md: "fails if the SDK, Clang, or sysroot is unavailable rather than substituting host headers ... that could report false success") — the check can currently pass with warnings present.

Fix: append -Dwarnings into the CARGO_ENCODED_RUSTFLAGS value built in scripts/ohos-env.sh (joined with the \037 separator already used there) instead of relying on the job-level RUSTFLAGS env var.

[Fix this →](https://claude.ai/code?q=In%20scripts%2Fohos-env.sh%2C%20the%20script%20exports%20CARGO_ENCODED_RUSTFLAGS%20for%20the%20aarch64-unknown-linux-ohos%20target%2C%20which%20takes%20priority%20over%20and%20silently%20discards%20the%20job-level%20RUSTFLAGS%3D-Dwarnings%20set%20in%20.github%2Fworkflows%2Fohos.yml.%20This%20means%20the%20OpenHarmony%20CI%20check%20does%20not%20actually%20deny%20warnings.%20Fix%20by%20appending%20-Dwarnings%20(joined%20with%20the%20existing%20%5Cx1f%20separator)%20into%20the%20CARGO_ENCODED_RUSTFLAGS%20value%20constructed%20in%20ohos-env.sh%2C%20so%20warn-as-error%20is%20preserved%20for%20the%20OpenHarmony%20cross-check.&repo=Hmbown/CodeWhale

cargo check --target aarch64-unknown-linux-ohos -p codewhale-tui --locked
3 changes: 3 additions & 0 deletions crates/tui/src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
//! - **Linux**: Uses bubblewrap only when the user opts in and `/usr/bin/bwrap`
//! is executable. Landlock and seccomp helpers are not wired into child
//! execution yet and therefore are not advertised.
//! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap,
//! Landlock, seccomp, and Linux `prctl` hardening are gated out under
//! `target_env = "ohos"`.
//! - **Windows**: No OS sandbox is advertised yet. The planned first helper
//! contract is process-tree containment only via a Windows Job Object; it
//! must not claim filesystem, network, registry, or AppContainer isolation.
Expand Down
28 changes: 28 additions & 0 deletions crates/tui/src/tools/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ use crate::work_graph::{
use crate::worker_profile::ShellPolicy;
use output::{tail_from_buffer, take_delta_from_buffer};

fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) -> Result<()> {
let metadata = std::fs::metadata(path).with_context(|| {
let source = if inherited_session_workspace {
"saved session workspace"
} else {
"requested working directory"
};
format!(
"{source} is unavailable: {}. Restore or remap that directory, resume/fork the session from an existing workspace, or pass an explicit `working_dir`/`cwd` to exec_shell",
path.display()
)
})?;
if !metadata.is_dir() {
let source = if inherited_session_workspace {
"saved session workspace"
} else {
"requested working directory"
};
return Err(anyhow!(
"{source} is not a directory: {}. Resume/fork from an existing workspace or pass an explicit `working_dir`/`cwd`",
path.display()
));
}
Ok(())
}

/// Status of a shell process
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ShellStatus {
Expand Down Expand Up @@ -1267,6 +1293,7 @@ impl ShellManager {
crate::shell_dispatcher::ShellDispatcher::log_exec(command);

let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
validate_shell_working_dir(&work_dir, working_dir.is_none())?;

// Clamp timeout to max 10 minutes (600000ms)
let timeout_ms = timeout_ms.clamp(1000, 600_000);
Expand Down Expand Up @@ -1342,6 +1369,7 @@ impl ShellManager {
crate::shell_dispatcher::ShellDispatcher::log_exec(command);

let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
validate_shell_working_dir(&work_dir, working_dir.is_none())?;

let timeout_ms = timeout_ms.clamp(1000, 600_000);
let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
Expand Down
31 changes: 31 additions & 0 deletions crates/tui/src/tools/shell/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,37 @@ fn env_lock() -> &'static Mutex<()> {

const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000;

#[test]
fn deleted_saved_workspace_reports_path_and_recovery_before_spawn() {
let workspace = tempdir().expect("workspace");
let stale = workspace.path().join("deleted-session-workspace");
let mut manager = ShellManager::new(stale.clone());

let error = manager
.execute("echo should-not-run", None, 1_000, false)
.expect_err("missing saved workspace must fail before shell spawn");
let message = error.to_string();
assert!(message.contains("saved session workspace is unavailable"));
assert!(message.contains(&stale.display().to_string()));
assert!(message.contains("working_dir") || message.contains("cwd"));
assert!(message.contains("resume/fork"));
}

#[test]
fn explicit_missing_working_dir_is_not_misreported_as_session_corruption() {
let workspace = tempdir().expect("workspace");
let missing = workspace.path().join("explicit-missing");
let mut manager = ShellManager::new(workspace.path().to_path_buf());

let error = manager
.execute("echo should-not-run", missing.to_str(), 1_000, false)
.expect_err("missing explicit cwd must fail before shell spawn");
let message = error.to_string();
assert!(message.contains("requested working directory is unavailable"));
assert!(message.contains(&missing.display().to_string()));
assert!(!message.contains("saved session workspace"));
}

#[cfg(not(target_env = "ohos"))]
#[test]
fn pty_exit_status_preserves_high_windows_code_losslessly() {
Expand Down
4 changes: 4 additions & 0 deletions crates/tui/src/tui/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
//! V4 does not currently accept inline image input on its Chat Completions
//! endpoint, so we materialize the bytes to disk instead of base64-embedding
//! them in the request).
//!
//! OpenHarmony deliberately excludes native desktop/Wayland clipboard APIs.
//! Copy falls back to OSC 52 (or tmux `load-buffer -w`), paste arrives through
//! terminal input, and image clipboard reads are unavailable.
use std::ffi::OsStr;
#[cfg(any(not(test), all(test, unix)))]
Expand Down
64 changes: 51 additions & 13 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2352,7 +2352,7 @@ async fn run_event_loop(
// Fire-and-forget version check — runs once per session in the
// background. On success, a short status toast advertises the update
// without replacing the user's configured footer/status-line chips.
let mut version_check: Option<tokio::task::JoinHandle<Option<String>>> =
let mut version_check: Option<tokio::task::JoinHandle<Option<UpdateNotice>>> =
spawn_startup_version_check(config.update_config());

// Fire a one-shot initial balance fetch for DeepSeek providers
Expand Down Expand Up @@ -2390,12 +2390,18 @@ async fn run_event_loop(
if let Some(ref handle) = version_check {
done = handle.is_finished();
}
if done && let Ok(Some(hint)) = version_check.take().unwrap().await {
if done && let Ok(Some(notice)) = version_check.take().unwrap().await {
// Transient toast for immediate visibility, plus a durable
// in-transcript notice so the prompt survives the toast TTL and
// stays actionable during a busy session (#3961).
app.push_status_toast(
hint,
notice.toast_line(),
StatusToastLevel::Info,
Some(VERSION_HINT_TOAST_TTL_MS),
);
app.add_message(HistoryCell::System {
content: notice.notice_block(),
});
}

if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
Expand Down Expand Up @@ -16167,7 +16173,7 @@ fn startup_version_check_source(config: &UpdateConfig) -> StartupVersionCheckSou

fn spawn_startup_version_check(
config: UpdateConfig,
) -> Option<tokio::task::JoinHandle<Option<String>>> {
) -> Option<tokio::task::JoinHandle<Option<UpdateNotice>>> {
let source = startup_version_check_source(&config);
if source == StartupVersionCheckSource::Disabled {
return None;
Expand All @@ -16182,7 +16188,7 @@ fn spawn_startup_version_check(
async fn version_hint_from_startup_source(
source: StartupVersionCheckSource,
current: &str,
) -> Option<String> {
) -> Option<UpdateNotice> {
match source {
StartupVersionCheckSource::Disabled => None,
StartupVersionCheckSource::ConfiguredUrl(url) => {
Expand All @@ -16208,7 +16214,7 @@ async fn version_hint_from_startup_source(
}
}

async fn version_hint_from_release_mirror_env(current: &str) -> Option<String> {
async fn version_hint_from_release_mirror_env(current: &str) -> Option<UpdateNotice> {
if !release_mirror_env_configured() {
return None;
}
Expand All @@ -16228,7 +16234,7 @@ fn release_mirror_env_configured() -> bool {
async fn version_hint_from_configured_update_uri(
update_uri: &str,
current: &str,
) -> Result<Option<String>> {
) -> Result<Option<UpdateNotice>> {
let body = codewhale_release::fetch_release_json_async(update_uri, "configured latest release")
.await?;
let json: serde_json::Value = serde_json::from_str(&body).with_context(|| {
Expand All @@ -16237,7 +16243,7 @@ async fn version_hint_from_configured_update_uri(
Ok(version_hint_from_custom_release_json(&json, current))
}

fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Option<String> {
fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Option<UpdateNotice> {
if !release_has_required_assets(json) {
return None;
}
Expand All @@ -16249,7 +16255,7 @@ fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Op
fn version_hint_from_custom_release_json(
json: &serde_json::Value,
current: &str,
) -> Option<String> {
) -> Option<UpdateNotice> {
if !release_is_publishable(json) {
return None;
}
Expand All @@ -16260,15 +16266,47 @@ fn version_hint_from_custom_release_json(
version_hint_from_latest_tag(tag, current)
}

fn version_hint_from_latest_tag(tag: &str, current: &str) -> Option<String> {
/// A newer-stable-release notice, carrying enough context to render both the
/// short transient toast and the durable in-transcript update prompt (#3961).
#[derive(Debug, Clone, PartialEq, Eq)]
struct UpdateNotice {
current: String,
latest: String,
}

impl UpdateNotice {
/// Short line for the transient status toast (unchanged wording).
fn toast_line(&self) -> String {
format!(
"v{latest} available - run `codewhale update` and restart",
latest = self.latest
)
}

/// Durable, actionable notice pushed into the transcript so it survives the
/// toast TTL. Includes current/latest versions, release notes, the exact
/// update command, and restart guidance.
fn notice_block(&self) -> String {
format!(
"Update available: v{current} -> v{latest}\n\
Release notes: https://github.com/Hmbown/CodeWhale/releases/tag/v{latest}\n\

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: the "Release notes" link is hardcoded to github.com/Hmbown/CodeWhale, but version_hint_from_latest_tag is the shared path for both the direct GitHub check and the mirror paths (version_hint_from_release_mirror_env → CNB mirror via CODEWHALE_USE_CNB_MIRROR/RELEASE_BASE_URL_ENV, and version_hint_from_configured_update_uri). For a user running CodeWhale specifically because they're on a mirror (e.g. GitHub is unreachable for them), the new durable transcript notice will now advertise a github.com URL — which directly contradicts this PR's stated goal of "preserving all release-asset, mirror, ... guardrails" (the old toast never included a URL, so this is a new regression, not pre-existing behavior).

Consider omitting the release-notes line (or making it conditional/generic) when the notice originates from a mirror/custom-URI source, since UpdateNotice currently has no way to know which source produced it.

Fix this →

Run `codewhale update` (preview with `codewhale update --check`), then restart CodeWhale.",
current = self.current,
latest = self.latest
)
}
}

fn version_hint_from_latest_tag(tag: &str, current: &str) -> Option<UpdateNotice> {
let latest = tag.trim_start_matches('v');
if !is_newer_version(latest, current) {
return None;
}

Some(format!(
"v{latest} available - run `codewhale update` and restart, then /change for what's new"
))
Some(UpdateNotice {
current: current.to_string(),
latest: latest.to_string(),
})
}

fn release_has_required_assets(json: &serde_json::Value) -> bool {
Expand Down
Loading
Loading