diff --git a/docs/EDITORS.md b/docs/EDITORS.md index 3ab927c2..f6bf6547 100644 --- a/docs/EDITORS.md +++ b/docs/EDITORS.md @@ -11,6 +11,7 @@ configure by hand: wally claude-code -m qwen3-4b-instruct-2507 wally codex -m qwen3-4b-instruct-2507 wally chatgpt -m qwen3-4b-instruct-2507 +wally openwork -m qwen3-4b-instruct-2507 wally hermes -m qwen3-4b-instruct-2507 wally deepseek -m qwen3-4b-instruct-2507 wally openclaw -m qwen3-4b-instruct-2507 @@ -24,6 +25,7 @@ tool starts the way you already have it configured, and wally wires nothing. | `claude-code` | `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` in the process | | `codex` | `-c` overrides on its command line naming a `wally` provider at the translator, plus a model catalog file for the run; the loopback token is in `WALLY_CODEX_API_KEY`. It runs in a Codex home of wally's own, so its threads and settings stay apart from yours; nothing is copied in and `~/.codex` is never written | | `chatgpt` | the ChatGPT app for macOS started as a second instance with its own app data and its own Codex home, whose `config.toml` holds the same settings `codex` passes as `-c` (one block wally rewrites per launch; the app's own entries are kept) and an API-key `auth.json` so no ChatGPT sign-in is asked for. Your own ChatGPT app and `~/.codex` are never touched. Quitting the app ends the run, and Ctrl-C quits the app | +| `openwork` | the OpenWork desktop app (public edition, `brew install --cask openwork` on macOS) started as a second instance on a profile of wally's own under the state directory (`openwork/`): its Electron data, runtime database, server settings, opencode history and a `RunAnywhere` workspace all live there, so your own OpenWork, your opencode history and `~/OpenWork Chat` are never written. Its opencode 1.x engine gets the provider in `OPENCODE_CONFIG_CONTENT`: the hosted catalog under `runanywhere` and, for a local model, `runanywhere-local` beside it, with the launched model selected. Chats stay on that engine (`OPENWORK_ENGINE_V2_PREVIEW=off`, `OPENWORK_ENGINE_AUTO_UPGRADE=0`), because OpenWork starts its opencode 2 engine with an allowlisted environment that never sees the provider. Your global `~/.config/opencode` is still read, but opencode's log and saved logins go to the profile too (`XDG_DATA_HOME`). The app's console output goes to `openwork/openwork.log`. The profile is kept between runs. Quitting the app ends the run, and Ctrl-C quits the app. On Linux only `OpenWork.AppImage` on PATH is found, never a bare `openwork` (the npm package of that name is another program); `WALLY_OPENWORK_APP` points at any other install. The Windows location (`%LOCALAPPDATA%\Programs\OpenWork\OpenWork.exe`) has not been checked against a real install yet | | `opencode` | a temporary provider in `OPENCODE_CONFIG_CONTENT`, with the local or hosted endpoint and context limits | | `claude-desktop` | a gateway profile in Claude Desktop's third party mode, covering the chat and Cowork tabs | | `hermes` | `CUSTOM_BASE_URL`, `HERMES_INFERENCE_PROVIDER=custom`, the model in `HERMES_INFERENCE_MODEL`, and the key under the name its host gates on | diff --git a/scripts/test/e2e-openwork.sh b/scripts/test/e2e-openwork.sh new file mode 100755 index 00000000..be678b9c --- /dev/null +++ b/scripts/test/e2e-openwork.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Real-app round trip for `wally openwork` on macOS: launches the installed +# OpenWork on wally's private profile, asks its engine which models it offers, +# sends one prompt per provider, quits the app and checks nothing is left +# running. Opt-in: needs WALLY_E2E_OPENWORK=1, OpenWork.app, and a model +# (WALLY_E2E_OPENWORK_MODEL, a local id or a hosted one on a signed-in +# account). WALLY_E2E_OPENWORK_HOSTED names a hosted model to prompt as well. +# +# WALLY_E2E_OPENWORK=1 WALLY_E2E_OPENWORK_MODEL=qwen3-4b-instruct-2507 \ +# bash scripts/test/e2e-openwork.sh ./build/wally +set -euo pipefail + +WALLY="${1:?usage: e2e-openwork.sh }" +if [[ "${WALLY_E2E_OPENWORK:-0}" != "1" ]]; then + echo " skip openwork (WALLY_E2E_OPENWORK is not 1)" + exit 0 +fi +if [[ "$(uname -s)" != "Darwin" ]]; then + echo " skip openwork (macOS only)" + exit 0 +fi +MODEL="${WALLY_E2E_OPENWORK_MODEL:?set WALLY_E2E_OPENWORK_MODEL}" +HOSTED="${WALLY_E2E_OPENWORK_HOSTED:-}" +STATE="${XDG_STATE_HOME:-${HOME}/.local/state}/runanywhere/openwork" +ENGINES="${STATE}/config/engine-instances.json" +LOG="$(mktemp -t wally-openwork-e2e)" + +if pgrep -f "OpenWork.app/Contents/MacOS/OpenWork" >/dev/null; then + echo " FAIL openwork: quit every OpenWork window first, so the instance this run starts can be told apart" + exit 1 +fi + +# Only a folder this run creates counts against it. +HOME_WORKSPACE_EXISTED=0 +[[ -e "${HOME}/OpenWork Chat" ]] && HOME_WORKSPACE_EXISTED=1 + +rm -f "${ENGINES}" +"${WALLY}" openwork -m "${MODEL}" >"${LOG}" 2>&1 & +WALLY_PID=$! +cleanup() { + osascript -e 'tell application "OpenWork" to quit' >/dev/null 2>&1 || true + for _ in $(seq 1 30); do + kill -0 "${WALLY_PID}" 2>/dev/null || return 0 + sleep 1 + done + echo " FAIL openwork: wally was still running 30 s after the app was asked to quit" + kill "${WALLY_PID}" 2>/dev/null || true + return 1 +} +trap cleanup EXIT + +for _ in $(seq 1 120); do + [[ -s "${ENGINES}" ]] && break + sleep 1 +done +if [[ ! -s "${ENGINES}" ]]; then + echo " FAIL openwork: the engine never started" + cat "${LOG}" + exit 1 +fi + +python3 - "${ENGINES}" "${STATE}/workspace" "${MODEL}" "${HOSTED}" <<'PY' +import json, sys, time, urllib.parse, urllib.request + +engines, workspace, model, hosted = sys.argv[1:5] +# The file is written before the engine is registered in it. +for _ in range(120): + entries = json.load(open(engines)).get("entries") or [] + if entries: + break + time.sleep(1) +assert entries, "the engine never registered" +entry = entries[0] +base, auth = entry["url"], entry["authProbe"] +query = "?directory=" + urllib.parse.quote(workspace) + +def call(path, body=None, timeout=30): + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request(base + path + query, data=data, + headers={"Authorization": auth, "Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.load(response) + +for _ in range(60): + try: + providers = call("/config/providers") + break + except OSError: + time.sleep(1) +by_id = {p["id"]: p for p in providers["providers"]} +connected = call("/provider")["connected"] +selected = call("/config").get("model", "") +ours = [pid for pid in ("runanywhere", "runanywhere-local") if pid in by_id] +assert ours, f"no RunAnywhere provider in {sorted(by_id)}" +for pid in ours: + assert pid in connected, f"{pid} is not connected: {connected}" + print(f" ok openwork lists {len(by_id[pid]['models'])} models under {pid}") +assert selected.endswith("/" + model), f"selected {selected!r}, launched {model!r}" +print(f" ok openwork selects {selected}") + +targets = [(selected.split("/", 1)[0], model)] +if hosted: + targets.append(("runanywhere", hosted)) +for provider, model_id in targets: + session = call("/session", {})["id"] + reply = call(f"/session/{session}/message", { + "model": {"providerID": provider, "modelID": model_id}, + "parts": [{"type": "text", "text": "Reply with exactly the word pong and nothing else."}], + }, timeout=300) + error = reply.get("info", {}).get("error") + text = " ".join(p.get("text", "") for p in reply.get("parts", []) if p.get("type") == "text") + assert not error and text.strip().strip(".").lower() == "pong", f"{provider}/{model_id}: error={error} text={text!r}" + print(f" ok openwork answered on {provider}/{model_id}: {text.strip()[:40]!r}") +PY + +trap - EXIT +cleanup || exit 1 +# The engine is stopped by the app on its way out, which takes a few seconds. +for _ in $(seq 1 20); do + pgrep -f "OpenWork.app/Contents/Resources/sidecars/opencode" >/dev/null || break + sleep 1 +done +if pgrep -f "OpenWork.app/Contents/Resources/sidecars/opencode" >/dev/null; then + echo " FAIL openwork: its engine is still running after the app quit" + exit 1 +fi +if [[ "${HOME_WORKSPACE_EXISTED}" == 0 && -e "${HOME}/OpenWork Chat" ]]; then + echo " FAIL openwork: the default workspace landed in the real home" + exit 1 +fi +echo " ok openwork quit cleanly" diff --git a/scripts/test/e2e.sh b/scripts/test/e2e.sh index c96d047c..0757ce89 100755 --- a/scripts/test/e2e.sh +++ b/scripts/test/e2e.sh @@ -174,4 +174,12 @@ else echo " skip modalities (WALLY_E2E_MODALITIES=0)" fi +# The OpenWork desktop app on wally's private profile; opt-in, macOS only. +if bash "${ROOT}/scripts/test/e2e-openwork.sh" "${WALLY}"; then + : +else + echo " FAIL openwork" + fail=1 +fi + exit "${fail}" diff --git a/skills/runanywhere/SKILL.md b/skills/runanywhere/SKILL.md index 8b6c75c4..6b0d40b7 100644 --- a/skills/runanywhere/SKILL.md +++ b/skills/runanywhere/SKILL.md @@ -58,6 +58,11 @@ If opencode is not installed, `wally` says so and prints the install command (`npm i -g opencode-ai`) rather than failing. Install it, then run the same line again. +`wally openwork -m ` opens the OpenWork desktop app on a private profile, +with your account's hosted models in its model picker and, for a local `-m`, that +local model beside them. On macOS it offers `brew install --cask openwork` when +the app is missing. The app flow has been checked on macOS only. + Offer to explain a harness before running it. Someone who has just signed up does not yet know what opencode is, and "would you like me to explain how the opencode harness works?" is a better second message than a launched TUI. diff --git a/src/app.rs b/src/app.rs index c69ddf42..2c8350f1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -75,6 +75,7 @@ pub fn configure_app(app: &mut App) { crate::commands::register_editors(app); crate::commands::register_codex(app); crate::commands::register_chatgpt(app); + crate::commands::register_openwork(app); crate::commands::register_harness(app); // coding agents crate::commands::register_default_models(app); @@ -129,6 +130,7 @@ pub fn configure_app(app: &mut App) { "claude-desktop", "codex", "chatgpt", + "openwork", "hermes", "openclaw", "deepseek", @@ -193,7 +195,7 @@ fn is_passthrough_command(token: &str) -> bool { /// the help page except `update` (it is the update) and `uninstall` (there is /// nothing left to update). Help pages show it too, from run(). fn shows_update_notice(path: &[String]) -> bool { - const NAMES: &[&str] = &["run", "serve", "models", "account", "about"]; + const NAMES: &[&str] = &["run", "serve", "models", "account", "about", "openwork"]; path.first() .is_some_and(|name| NAMES.contains(&name.as_str()) || is_passthrough_command(name)) } diff --git a/src/cli_formatter.rs b/src/cli_formatter.rs index 78c5b7e6..367b1068 100644 --- a/src/cli_formatter.rs +++ b/src/cli_formatter.rs @@ -1195,6 +1195,11 @@ mod tests { "Open ChatGPT's Codex with a model", "Coding tools", ), + leaf( + "openwork", + "Open OpenWork with a local or cloud model", + "Coding tools", + ), leaf( "opencode", "Open OpenCode with a local or cloud model", diff --git a/src/commands/cmd_openwork.rs b/src/commands/cmd_openwork.rs new file mode 100644 index 00000000..f8d1acd6 --- /dev/null +++ b/src/commands/cmd_openwork.rs @@ -0,0 +1,262 @@ +//! `wally openwork`: the OpenWork desktop app with every RunAnywhere model in +//! its picker, on a private profile of wally's (see harness::openwork). + +use std::path::Path; + +use crate::account::{self, ConsoleClient, IdentityResult}; +use crate::bootstrap::GlobalOptions; +use crate::cli::{App, ValueType}; +use crate::cli_formatter::{examples_footer, Example}; +use crate::commands::cmd_editors::write_private; +use crate::commands::resolve_default_model; +use crate::config::cli_paths; +use crate::harness::{ + self, account_models_with, build_openwork_config, catalog_models_for, CatalogModel, Endpoint, + OpenWorkApp, Profile, Providers, +}; +use crate::io::output as out; + +const COMMAND: &str = "openwork"; +const CONFIG_VARIABLE: &str = "OPENCODE_CONFIG_CONTENT"; + +pub fn register_openwork(app: &mut App) { + let command = app.add_subcommand(COMMAND, "Open OpenWork with a local or cloud model"); + command.footer(&examples_footer(&[ + Example::new( + "wally openwork -m qwen3-4b-instruct-2507", + "The certified local model, with your hosted models beside it", + ), + Example::new( + "wally openwork -m glm-5.3-flash", + "A hosted model (needs `wally account login`)", + ), + ])); + command.add_option( + "-m,--model", + ValueType::Text, + "A model on this machine, or a hosted one from your account", + ); + command.callback(|p, g| { + if harness::refuse_decisions_model(p.get_str("--model").as_deref()) { + return 2; + } + // Before resolving a model: a person without the app should see only + // that it is missing and how to get it. + let Some(app) = installed_app() else { + return 127; + }; + run( + &app, + &resolve_default_model(&p.get_str("--model").unwrap_or_default(), g.no_color), + g, + ) + }); +} + +/// The installed OpenWork, or None after saying how to get it (and, on macOS +/// at a terminal with Homebrew, offering to install it). +fn installed_app() -> Option { + if let Some(app) = harness::locate_app() { + return Some(app); + } + if let Some(path) = + crate::util::getenv(harness::APP_OVERRIDE_VARIABLE).filter(|p| !p.is_empty()) + { + out::error_line(&format!( + "{} names {path}, which is not an OpenWork app", + harness::APP_OVERRIDE_VARIABLE + )); + return None; + } + out::error_line("OpenWork is not installed on this machine"); + if cfg!(target_os = "macos") + && harness::on_path("brew") + && crate::util::term::stdin_is_tty() + && crate::util::term::stderr_is_tty() + && harness::confirm(&format!( + "Install it now with `{}`? [y/N] ", + harness::BREW_INSTALL + )) + { + let installed = std::process::Command::new("bash") + .args(["-c", harness::BREW_INSTALL]) + .status() + .is_ok_and(|status| status.success()); + if !installed { + out::error_line("the OpenWork install did not finish"); + return None; + } + return harness::locate_app().or_else(|| { + out::error_line("OpenWork installed, but wally could not find OpenWork.app"); + None + }); + } + if cfg!(target_os = "macos") { + out::status_line(&format!( + "install it with `{}`, or from {}, then run this again", + harness::BREW_INSTALL, + harness::DOWNLOAD + )); + } else { + out::status_line(&format!( + "download it from {}, then run this again", + harness::DOWNLOAD + )); + } + None +} + +/// The account's hosted catalog for a local launch, so it shows beside the +/// local model, behind the same image gate a hosted launch gets. An expired +/// token is refreshed first, as for a hosted launch. None when signed out, +/// when the session is refused, or when the list cannot be read: a local +/// launch never fails for want of the network. +fn hosted_catalog() -> Option<(String, String, Vec)> { + let mut credentials = account::load().ok().filter(|c| c.signed_in())?; + let console = ConsoleClient::default(); + if let Err(error) = harness::verify_cloud_session(&console, &mut credentials) { + if !error.unverified { + return None; + } + } + let (result, models, _) = account_models_with( + &console, + &credentials.console_url, + &credentials.access_token, + ); + if !matches!(result, IdentityResult::Ok) || models.is_empty() { + return None; + } + let base_url = harness::image_gate::start(&credentials.console_url, &credentials.access_token) + .unwrap_or_else(|| format!("{}/v1", credentials.console_url)); + Some((base_url, credentials.access_token, models)) +} + +/// The provider config for a launch on `endpoint`: everything the person can +/// use, with `model` selected. +fn openwork_config(endpoint: &Endpoint, model: &str) -> String { + let launched = catalog_models_for(endpoint, model); + if endpoint.serving { + let hosted = hosted_catalog(); + let providers = Providers { + local: Some((&endpoint.base_url, &launched)), + hosted: hosted + .as_ref() + .map(|(url, key, models)| (url.as_str(), key.as_str(), models.as_slice())), + }; + if let Some((_, _, models)) = &hosted { + out::status_line(&format!( + "your {} hosted models are listed too", + models.len() + )); + } + return build_openwork_config(model, &providers); + } + out::status_line(&format!("{} models in the picker", launched.len())); + build_openwork_config( + model, + &Providers { + local: None, + hosted: Some((&endpoint.base_url, &endpoint.api_key, &launched)), + }, + ) +} + +/// Readies the private profile and, before its first launch, its workspace. +fn prepare(profile: &Profile) -> Result<(), String> { + profile.create()?; + let list = profile.workspace_list(); + if !list.exists() { + write_private(&list, &harness::workspace_list(&profile.workspace_path())) + .map_err(|error| format!("could not write OpenWork's workspace list ({error})"))?; + } + Ok(()) +} + +fn run(app: &OpenWorkApp, model: &str, options: &GlobalOptions) -> i32 { + let state = cli_paths::state_dir(); + if state.is_empty() { + out::error_line("could not find a state directory for OpenWork's profile"); + return 1; + } + let profile = Profile::under(Path::new(&state)); + if let Err(problem) = prepare(&profile) { + out::error_line(&problem); + return 1; + } + let mut environment = profile.environment(); + let endpoint = if model.is_empty() { + // No model named and no default set: the app opens on the profile + // with no RunAnywhere provider wired. + None + } else { + let Some(endpoint) = harness::resolve(model, options, COMMAND) else { + return 1; + }; + environment.push(( + CONFIG_VARIABLE.to_string(), + openwork_config(&endpoint, model), + )); + Some(endpoint) + }; + out::status_line(&format!( + "opening OpenWork on wally's profile in {}", + profile.root.to_string_lossy() + )); + let log = profile.root.join("openwork.log"); + let status = start(app, &environment, &log); + if status != 0 { + out::status_line(&format!( + "OpenWork exited with {status}; its output is in {}", + log.to_string_lossy() + )); + } + if let Some(endpoint) = &endpoint { + harness::release(endpoint); + } + status +} + +/// Starts `app` on `environment` and waits for it. Only the child gets the +/// environment; this process's own is left as it was. The app's console +/// output goes to `log`, so it does not flood the terminal and a failed +/// start can still be diagnosed. +fn start(app: &OpenWorkApp, environment: &[(String, String)], log: &Path) -> i32 { + if matches!(app, OpenWorkApp::Bundle(_)) && !cfg!(target_os = "macos") { + out::error_line("an OpenWork .app bundle only runs on macOS"); + return 1; + } + let output = match std::fs::File::create(log) { + Ok(file) => file, + Err(error) => { + out::error_line(&format!( + "could not create {} ({error})", + log.to_string_lossy() + )); + return 1; + } + }; + let errors = match output.try_clone() { + Ok(file) => file, + Err(error) => { + out::error_line(&format!("could not open OpenWork's log ({error})")); + return 1; + } + }; + // Ctrl-C reaches the app from the terminal and it quits; wally waits for + // that, so the local server outlives it. + let _wait = crate::util::interrupt::on_interrupt(|| {}); + match std::process::Command::new(harness::program(app)) + .envs(environment.iter().map(|(k, v)| (k, v))) + .stdin(std::process::Stdio::null()) + .stdout(output) + .stderr(errors) + .status() + { + Ok(status) => status.code().unwrap_or(1), + Err(error) => { + out::error_line(&format!("could not start OpenWork ({error})")); + 127 + } + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 98e89a81..b54f0529 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -36,6 +36,7 @@ pub mod cmd_list; pub mod cmd_lora; pub mod cmd_maintenance; pub mod cmd_models; +pub mod cmd_openwork; pub mod cmd_pull; pub mod cmd_rag; pub mod cmd_rerank; @@ -79,6 +80,7 @@ pub use cmd_list::configure_models_list; pub use cmd_lora::register_lora; pub use cmd_maintenance::{register_help, register_uninstall, run_uninstall}; pub use cmd_models::{register_models, register_models_aliases}; +pub use cmd_openwork::register_openwork; pub use cmd_pull::{configure_models_download, pull_model_flow}; pub use cmd_rag::register_rag; pub use cmd_rerank::register_rerank; diff --git a/src/harness/harness.rs b/src/harness/harness.rs index b60544ea..fed5e175 100644 --- a/src/harness/harness.rs +++ b/src/harness/harness.rs @@ -599,7 +599,7 @@ fn dir_has_tool(dir: &Path, tool: &str) -> bool { false } -fn on_path(tool: &str) -> bool { +pub(crate) fn on_path(tool: &str) -> bool { let path = match std::env::var("PATH") { Ok(p) => p, Err(_) => return false, diff --git a/src/harness/mod.rs b/src/harness/mod.rs index d4750265..98813404 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -1,4 +1,4 @@ -//! Launching coding tools (Claude Code, Codex, opencode, Hermes, OpenClaw, DeepSeek, +//! Launching coding tools (Claude Code, Codex, opencode, OpenWork, Hermes, OpenClaw, DeepSeek, //! Prime Agent, Command Code) against a hosted or local model, and the hosted //! security harnesses (Shannon, DeepSec, VVAH), which run only against hosted //! account models. One namespace, as C++ `wally::harness` was. @@ -17,6 +17,7 @@ pub mod hosted; pub mod image_gate; pub mod local_models; pub mod opencode; +pub mod openwork; pub mod path_reload; pub mod shannon; pub mod vvah; @@ -27,6 +28,7 @@ pub use declared_harness::*; pub use harness::*; pub use local_models::*; pub use opencode::*; +pub use openwork::*; /// Hosted decision models expose probabilities rather than generated text and /// must never be routed through chat/completions or a coding harness. The diff --git a/src/harness/opencode.rs b/src/harness/opencode.rs index 626c3166..9d148184 100644 --- a/src/harness/opencode.rs +++ b/src/harness/opencode.rs @@ -279,6 +279,22 @@ pub fn build_open_code_config( access_token: &str, models: &[CatalogModel], ) -> String { + let config = json!({ + "provider": { "runanywhere": open_code_provider("RunAnywhere", base_url, access_token, models) }, + "model": format!("runanywhere/{primary}"), + }); + dump(&config) +} + +/// One `@ai-sdk/openai-compatible` provider entry, shown as `name`, listing +/// every model in `models`; what `build_open_code_config` puts under +/// `provider.runanywhere`. +pub fn open_code_provider( + name: &str, + base_url: &str, + access_token: &str, + models: &[CatalogModel], +) -> serde_json::Value { let mut entries = json!({}); for model in models { let mut entry = json!({ "name": model.id }); @@ -327,9 +343,9 @@ pub fn build_open_code_config( } else { access_token }; - let provider = json!({ + json!({ "npm": "@ai-sdk/openai-compatible", - "name": "RunAnywhere", + "name": name, // `options.headers` rides on every request (checked against opencode // 1.18.31); the declaration makes attribution independent of // opencode's own User-Agent. @@ -339,12 +355,7 @@ pub fn build_open_code_config( "headers": { HARNESS_HEADER: harness_header_value(DeclaredHarness::KOpencode) }, }, "models": entries, - }); - let config = json!({ - "provider": { "runanywhere": provider }, - "model": format!("runanywhere/{primary}"), - }); - dump(&config) + }) } /// OpenCode's reasoning controls for `model`, from the model list: one diff --git a/src/harness/openwork.rs b/src/harness/openwork.rs new file mode 100644 index 00000000..2d8c0a7e --- /dev/null +++ b/src/harness/openwork.rs @@ -0,0 +1,377 @@ +//! The OpenWork desktop app (different-ai/openwork) against RunAnywhere +//! models. +//! +//! OpenWork is an Electron app that runs opencode 1.x as its engine and hands +//! that engine its own environment, so wally's provider rides in on +//! `OPENCODE_CONFIG_CONTENT`, which opencode merges after OpenWork's own +//! `OPENCODE_CONFIG` file (opencode 1.18.33 `config/config.ts`, the +//! CONFIG_CONTENT merge comes last). The app runs as a private instance on +//! wally's own profile under `state_dir()/openwork/`: its own Electron user +//! data, runtime database, server settings, opencode history and workspace, +//! so the person's OpenWork, opencode history and home folder are never +//! written. The profile is kept between runs, so chats carry over. +//! +//! Two OpenWork defaults are turned off for that profile, both verified on +//! OpenWork 0.18.63: +//! - A fresh profile routes chats to its opencode 2 engine, which is started +//! with an allowlisted environment and never sees `OPENCODE_CONFIG_CONTENT`, +//! so the RunAnywhere models would be missing from the picker. Any +//! `OPENWORK_ENGINE_V2_PREVIEW` value other than `1`/`chat`/`sidecar` keeps +//! the fresh-install upgrade off (`resolveFreshInstallEngine`). +//! - A profile with no workspace creates `~/OpenWork Chat` in the real home. +//! A workspace list naming wally's own folder, written before the first +//! launch, stops that and opens straight into a chat. + +use std::path::{Path, PathBuf}; + +use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::io::json::dump; + +use super::catalog_models::CatalogModel; +use super::opencode::open_code_provider; + +/// The Electron app id every OpenWork edition ships under. The Cloud and +/// Enterprise editions share it but install as their own bundle name and +/// force a Den sign-in, so only the public `OpenWork.app` is launched. +pub const OPENWORK_BUNDLE_ID: &str = "com.differentai.openwork"; + +/// Points wally at an OpenWork executable or `.app` bundle somewhere other +/// than the standard install location. +pub const APP_OVERRIDE_VARIABLE: &str = "WALLY_OPENWORK_APP"; + +pub const DOWNLOAD: &str = "https://openworklabs.com/download"; + +/// macOS installs it with `brew install --cask openwork`. The npm package +/// named `openwork` is a different project and is never offered. +pub const BREW_INSTALL: &str = "brew install --cask openwork"; + +/// The hosted catalog's provider id; a local model gets its own, so both +/// lists show together without one id shadowing the other. +pub const HOSTED_PROVIDER: &str = "runanywhere"; +pub const LOCAL_PROVIDER: &str = "runanywhere-local"; + +/// Where an installed OpenWork lives: a macOS bundle (launched through +/// `open`) or an executable started directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenWorkApp { + Bundle(PathBuf), + Executable(PathBuf), +} + +/// wally's private OpenWork profile, all of it under one root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub root: PathBuf, + pub user_data: PathBuf, + pub config: PathBuf, + pub data: PathBuf, + pub workspace: PathBuf, +} + +impl Profile { + pub fn under(state_dir: &Path) -> Profile { + let root = state_dir.join("openwork"); + Profile { + user_data: root.join("user-data"), + config: root.join("config"), + data: root.join("data"), + workspace: root.join("workspace"), + root, + } + } + + /// Creates the profile's folders. The error is for the person. + pub fn create(&self) -> Result<(), String> { + for dir in [&self.user_data, &self.config, &self.data, &self.workspace] { + std::fs::create_dir_all(dir) + .map_err(|error| format!("could not create {} ({error})", dir.to_string_lossy()))?; + } + Ok(()) + } + + /// The desktop app's workspace list (`openwork-workspaces.json` in its + /// Electron user data). + pub fn workspace_list(&self) -> PathBuf { + self.user_data.join("openwork-workspaces.json") + } + + /// The workspace folder as OpenWork resolves it (`path.resolve` then + /// `realpath` in `normalizeLocalWorkspacePath`): native separators and + /// long names. Its id is a hash of this exact text, so a spelling with + /// mixed separators or a Windows 8.3 short name would not be selected. + /// The folder must already exist. + pub fn workspace_path(&self) -> String { + let resolved = std::fs::canonicalize(&self.workspace) + .unwrap_or_else(|_| self.workspace.clone()) + .to_string_lossy() + .into_owned(); + without_verbatim_prefix(&resolved) + } + + /// Every variable that moves OpenWork and its engine onto this profile: + /// OpenWork's own isolation set (`BLANK_SLATE_PATH_ENV_KEYS` in + /// `apps/desktop/electron/blank-slate-profile.mjs`) minus the home and + /// config folders, so the person's shell tools and opencode config still + /// work inside the app, plus the two engine switches the module doc + /// explains. `XDG_DATA_HOME` is moved because opencode writes its log + /// (prompts and session metadata) and its saved logins there. + pub fn environment(&self) -> Vec<(String, String)> { + let path = |p: PathBuf| p.to_string_lossy().into_owned(); + vec![ + ( + "OPENWORK_ELECTRON_USERDATA".into(), + path(self.user_data.clone()), + ), + ( + "OPENWORK_DESKTOP_BOOTSTRAP_PATH".into(), + path(self.config.join("desktop-bootstrap.json")), + ), + ( + "OPENWORK_SERVER_CONFIG".into(), + path(self.config.join("server.json")), + ), + ( + "OPENWORK_ENV_STORE".into(), + path(self.config.join("env.json")), + ), + ( + "OPENWORK_TOKEN_STORE".into(), + path(self.config.join("tokens.json")), + ), + ( + "OPENWORK_RUNTIME_DB".into(), + path(self.config.join("runtime.sqlite")), + ), + ("OPENWORK_DATA_DIR".into(), path(self.data.clone())), + ("OPENCODE_DB".into(), path(self.data.join("opencode.db"))), + ("XDG_DATA_HOME".into(), path(self.data.join("xdg"))), + ( + "OPENWORK_ELECTRON_DISABLE_PROTOCOL_REGISTRATION".into(), + "1".into(), + ), + ("OPENWORK_ENGINE_V2_PREVIEW".into(), "off".into()), + ("OPENWORK_ENGINE_AUTO_UPGRADE".into(), "0".into()), + ] + } +} + +/// `path` spelled as Node's `realpath` spells it on Windows: without the +/// `\\?\` that `canonicalize` puts on a drive path, and with `\\?\UNC\` +/// back to `\\`. Anything else is unchanged. +pub fn without_verbatim_prefix(path: &str) -> String { + if let Some(share) = path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{share}"); + } + match path.strip_prefix(r"\\?\") { + Some(rest) if rest.as_bytes().get(1) == Some(&b':') => rest.to_string(), + _ => path.to_string(), + } +} + +/// The id OpenWork's desktop store gives a local workspace at `path` +/// (`stableWorkspaceId` in `apps/desktop/electron/workspace-store.mjs`). A +/// list whose selection does not match it is re-selected by the app. +pub fn workspace_id(path: &str) -> String { + let digest = hex::encode(Sha256::digest(path.as_bytes())); + format!("ws_{}", &digest[..12]) +} + +/// A workspace list holding only wally's workspace at `path`, selected. +pub fn workspace_list(path: &str) -> String { + let id = workspace_id(path); + dump(&json!({ + "selectedId": id, + "watchedId": id, + "activeId": id, + "workspaces": [{ + "id": id, + "name": "RunAnywhere", + "displayName": "RunAnywhere", + "path": path, + "preset": "starter", + "workspaceType": "local", + "baseUrl": null, + "directory": null, + }], + })) +} + +/// Where the provider config for one launch points. +pub struct Providers<'a> { + /// The local server and the one model it holds, when the launch is local. + pub local: Option<(&'a str, &'a [CatalogModel])>, + /// The hosted endpoint, its key and the account's catalog, when the person + /// is signed in and the catalog could be read. + pub hosted: Option<(&'a str, &'a str, &'a [CatalogModel])>, +} + +/// The opencode config OpenWork's engine gets: the hosted catalog under +/// `runanywhere`, the local model under `runanywhere-local`, and `primary` +/// selected on whichever of them the launch is on (local when there is one). +pub fn build_openwork_config(primary: &str, providers: &Providers) -> String { + let mut provider = serde_json::Map::new(); + if let Some((base_url, access_token, models)) = providers.hosted { + provider.insert( + HOSTED_PROVIDER.into(), + open_code_provider("RunAnywhere", base_url, access_token, models), + ); + } + if let Some((base_url, models)) = providers.local { + provider.insert( + LOCAL_PROVIDER.into(), + open_code_provider("RunAnywhere (this machine)", base_url, "", models), + ); + } + let selected = if providers.local.is_some() { + LOCAL_PROVIDER + } else { + HOSTED_PROVIDER + }; + dump(&json!({ + "provider": provider, + "model": format!("{selected}/{primary}"), + })) +} + +/// The OpenWork to launch: `WALLY_OPENWORK_APP` when set, else the public +/// edition in its standard location for this platform. +pub fn locate_app() -> Option { + if let Some(path) = crate::util::getenv(APP_OVERRIDE_VARIABLE).filter(|p| !p.is_empty()) { + return app_at(Path::new(&path)); + } + standard_locations().iter().find_map(|path| app_at(path)) +} + +/// What is at `path`, if it is an OpenWork wally can launch: a bundle with +/// an Info.plist, or a file it may execute. +pub fn app_at(path: &Path) -> Option { + if path.extension().is_some_and(|e| e == "app") { + return (path.join("Contents/Info.plist").is_file()) + .then(|| OpenWorkApp::Bundle(path.to_path_buf())); + } + is_executable(path).then(|| OpenWorkApp::Executable(path.to_path_buf())) +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + path.is_file() +} + +#[cfg(target_os = "macos")] +fn standard_locations() -> Vec { + let mut roots = vec![PathBuf::from("/Applications")]; + if let Some(home) = crate::util::getenv("HOME").filter(|h| !h.is_empty()) { + roots.push(Path::new(&home).join("Applications")); + } + roots + .into_iter() + .map(|root| root.join("OpenWork.app")) + .filter(|bundle| bundle_id(bundle).as_deref() == Some(OPENWORK_BUNDLE_ID)) + .collect() +} + +#[cfg(target_os = "macos")] +fn bundle_id(bundle: &Path) -> Option { + let output = std::process::Command::new("/usr/libexec/PlistBuddy") + .args(["-c", "Print :CFBundleIdentifier"]) + .arg(bundle.join("Contents/Info.plist")) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// electron-builder's per-user NSIS install folder for productName OpenWork. +#[cfg(windows)] +fn standard_locations() -> Vec { + crate::util::getenv("LOCALAPPDATA") + .filter(|dir| !dir.is_empty()) + .map(|dir| vec![Path::new(&dir).join(r"Programs\OpenWork\OpenWork.exe")]) + .unwrap_or_default() +} + +/// The AppImage on PATH. A bare `openwork` is not looked for: the npm +/// package of that name is a different program, and it would be handed the +/// account token. Any other install is named with `WALLY_OPENWORK_APP`. +#[cfg(not(any(target_os = "macos", windows)))] +fn standard_locations() -> Vec { + let path = crate::util::getenv("PATH").unwrap_or_default(); + std::env::split_paths(&path) + .map(|dir| dir.join("OpenWork.AppImage")) + .collect() +} + +/// The bundle's main executable when its Info.plist cannot be read: +/// `CFBundleExecutable` in OpenWork 0.18.63. +pub const BUNDLE_EXECUTABLE: &str = "OpenWork"; + +/// The bundle's `CFBundleExecutable`, so a `WALLY_OPENWORK_APP` bundle with +/// another executable name still starts. +#[cfg(target_os = "macos")] +fn bundle_executable(bundle: &Path) -> String { + std::process::Command::new("/usr/libexec/PlistBuddy") + .args(["-c", "Print :CFBundleExecutable"]) + .arg(bundle.join("Contents/Info.plist")) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|name| !name.is_empty() && !name.contains('/')) + .unwrap_or_else(|| BUNDLE_EXECUTABLE.to_string()) +} + +#[cfg(not(target_os = "macos"))] +fn bundle_executable(_bundle: &Path) -> String { + BUNDLE_EXECUTABLE.to_string() +} + +/// The program that starts `app`. A bundle is started from its own +/// executable rather than through `open`, so the profile environment, which +/// carries the account token, is handed to the child directly instead of +/// sitting in `open`'s arguments, and the app is wally's child: Ctrl-C reaches +/// it from the terminal and its exit ends the run. +pub fn program(app: &OpenWorkApp) -> PathBuf { + match app { + OpenWorkApp::Bundle(bundle) => bundle + .join("Contents/MacOS") + .join(bundle_executable(bundle)), + OpenWorkApp::Executable(path) => path.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_workspace_id_matches_openwork_s_own() { + // Read back from OpenWork 0.18.63, which re-selected this id for a + // workspace at that path. + assert_eq!( + workspace_id( + "/Users/home/.claude/projects/-Users-home-RustroverProjects-wally/openwork-probe/ws" + ), + "ws_b1d2a3b5c6b2" + ); + } + + #[test] + fn a_bundle_starts_from_its_own_executable() { + let app = OpenWorkApp::Bundle(PathBuf::from("/Applications/OpenWork.app")); + assert_eq!( + program(&app), + PathBuf::from("/Applications/OpenWork.app/Contents/MacOS/OpenWork") + ); + } +} diff --git a/tests/golden/README.md b/tests/golden/README.md index 9fa99306..bce80906 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -1,6 +1,6 @@ # Golden CLI corpus -Byte-exact stdout, stderr and exit code for 379 invocations of `wally`, +Byte-exact stdout, stderr and exit code for 381 invocations of `wally`, captured from the C++ build of `origin/main` at `d1e9c0b` (the last C++ release line) on macOS arm64 with kit 0.20.37. `tests/cli_golden.rs` replays every case against the Rust binary in the same isolated environment and fails @@ -117,6 +117,10 @@ each. `help__command-code` and `help__help__command-code` are new cases. re-captured from the Rust binary; that line is the only difference in each. `help__shannon` and `help__help__shannon` are new cases. +`wally openwork` added a line to every command list, so the same 31 cases were +re-captured from the Rust binary; that line is the only difference in each. +`help__openwork` and `help__help__openwork` are new cases. + `wally deepsec` and `wally vvah` added a **Security tools** group to every command list, so the heading, the `deepsec` and `vvah` entries and the blank separator line (four lines in all) are the only extra difference in each. diff --git a/tests/golden/cases.json b/tests/golden/cases.json index 55c13b24..180b2043 100644 --- a/tests/golden/cases.json +++ b/tests/golden/cases.json @@ -334,6 +334,13 @@ "chatgpt" ] }, + { + "name": "help__openwork", + "args": [ + "help", + "openwork" + ] + }, { "name": "help__deepseek", "args": [ @@ -543,6 +550,14 @@ "--help" ] }, + { + "name": "help__help__openwork", + "args": [ + "help", + "openwork", + "--help" + ] + }, { "name": "help__help__deepseek", "args": [ diff --git a/tests/golden/expected/alt_help__--no-color__--help.stdout b/tests/golden/expected/alt_help__--no-color__--help.stdout index 86d30bfa..57272895 100644 --- a/tests/golden/expected/alt_help__--no-color__--help.stdout +++ b/tests/golden/expected/alt_help__--no-color__--help.stdout @@ -19,6 +19,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/alt_help__-h.stdout b/tests/golden/expected/alt_help__-h.stdout index 86d30bfa..57272895 100644 --- a/tests/golden/expected/alt_help__-h.stdout +++ b/tests/golden/expected/alt_help__-h.stdout @@ -19,6 +19,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/alt_help__help.stdout b/tests/golden/expected/alt_help__help.stdout index 86d30bfa..57272895 100644 --- a/tests/golden/expected/alt_help__help.stdout +++ b/tests/golden/expected/alt_help__help.stdout @@ -19,6 +19,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/alt_help__help__nope.stdout b/tests/golden/expected/alt_help__help__nope.stdout index 86d30bfa..57272895 100644 --- a/tests/golden/expected/alt_help__help__nope.stdout +++ b/tests/golden/expected/alt_help__help__nope.stdout @@ -19,6 +19,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/help__ROOT.stdout b/tests/golden/expected/help__ROOT.stdout index 86d30bfa..57272895 100644 --- a/tests/golden/expected/help__ROOT.stdout +++ b/tests/golden/expected/help__ROOT.stdout @@ -19,6 +19,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/help__help.stdout b/tests/golden/expected/help__help.stdout index 86d30bfa..57272895 100644 --- a/tests/golden/expected/help__help.stdout +++ b/tests/golden/expected/help__help.stdout @@ -19,6 +19,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/help__help__openwork.code b/tests/golden/expected/help__help__openwork.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__help__openwork.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__help__openwork.stderr b/tests/golden/expected/help__help__openwork.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__help__openwork.stdout b/tests/golden/expected/help__help__openwork.stdout new file mode 100644 index 00000000..9a41c790 --- /dev/null +++ b/tests/golden/expected/help__help__openwork.stdout @@ -0,0 +1,15 @@ +Open OpenWork with a local or cloud model + +Usage: wally openwork [OPTIONS] + +Options: + -h, --help Show help + -m, --model TEXT A model on this machine, or a hosted one from your + account + +Examples: + # The certified local model, with your hosted models beside it + wally openwork -m qwen3-4b-instruct-2507 + + # A hosted model (needs `wally account login`) + wally openwork -m glm-5.3-flash diff --git a/tests/golden/expected/help__openwork.code b/tests/golden/expected/help__openwork.code new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/tests/golden/expected/help__openwork.code @@ -0,0 +1 @@ +0 diff --git a/tests/golden/expected/help__openwork.stderr b/tests/golden/expected/help__openwork.stderr new file mode 100644 index 00000000..e69de29b diff --git a/tests/golden/expected/help__openwork.stdout b/tests/golden/expected/help__openwork.stdout new file mode 100644 index 00000000..9a41c790 --- /dev/null +++ b/tests/golden/expected/help__openwork.stdout @@ -0,0 +1,15 @@ +Open OpenWork with a local or cloud model + +Usage: wally openwork [OPTIONS] + +Options: + -h, --help Show help + -m, --model TEXT A model on this machine, or a hosted one from your + account + +Examples: + # The certified local model, with your hosted models beside it + wally openwork -m qwen3-4b-instruct-2507 + + # A hosted model (needs `wally account login`) + wally openwork -m glm-5.3-flash diff --git a/tests/golden/expected/parse__--bogus.stderr b/tests/golden/expected/parse__--bogus.stderr index f731714e..7390490f 100644 --- a/tests/golden/expected/parse__--bogus.stderr +++ b/tests/golden/expected/parse__--bogus.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/parse__-U__--json.stderr b/tests/golden/expected/parse__-U__--json.stderr index 3663d7b6..1629cca1 100644 --- a/tests/golden/expected/parse__-U__--json.stderr +++ b/tests/golden/expected/parse__-U__--json.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/parse__-u__-v.stderr b/tests/golden/expected/parse__-u__-v.stderr index 98f76374..9344b159 100644 --- a/tests/golden/expected/parse__-u__-v.stderr +++ b/tests/golden/expected/parse__-u__-v.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/parse__-z.stderr b/tests/golden/expected/parse__-z.stderr index a6474b94..c12ba787 100644 --- a/tests/golden/expected/parse__-z.stderr +++ b/tests/golden/expected/parse__-z.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__auth.stderr b/tests/golden/expected/unknown__auth.stderr index fb62c456..a7be9036 100644 --- a/tests/golden/expected/unknown__auth.stderr +++ b/tests/golden/expected/unknown__auth.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__chat.stderr b/tests/golden/expected/unknown__chat.stderr index 49a0f9d1..4206293b 100644 --- a/tests/golden/expected/unknown__chat.stderr +++ b/tests/golden/expected/unknown__chat.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__diarize.stderr b/tests/golden/expected/unknown__diarize.stderr index b1195cde..9a778f7d 100644 --- a/tests/golden/expected/unknown__diarize.stderr +++ b/tests/golden/expected/unknown__diarize.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__embed.stderr b/tests/golden/expected/unknown__embed.stderr index 6e82a6c7..98560370 100644 --- a/tests/golden/expected/unknown__embed.stderr +++ b/tests/golden/expected/unknown__embed.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__foo.stderr b/tests/golden/expected/unknown__foo.stderr index ea9dbfaa..c7ffa4f4 100644 --- a/tests/golden/expected/unknown__foo.stderr +++ b/tests/golden/expected/unknown__foo.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__image.stderr b/tests/golden/expected/unknown__image.stderr index c3ab761b..93daa8cf 100644 --- a/tests/golden/expected/unknown__image.stderr +++ b/tests/golden/expected/unknown__image.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__list.stderr b/tests/golden/expected/unknown__list.stderr index c60a4375..94484f0b 100644 --- a/tests/golden/expected/unknown__list.stderr +++ b/tests/golden/expected/unknown__list.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__lora.stderr b/tests/golden/expected/unknown__lora.stderr index db3511ee..f3ee83dd 100644 --- a/tests/golden/expected/unknown__lora.stderr +++ b/tests/golden/expected/unknown__lora.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__ls.stderr b/tests/golden/expected/unknown__ls.stderr index a7825820..ebf1e1f7 100644 --- a/tests/golden/expected/unknown__ls.stderr +++ b/tests/golden/expected/unknown__ls.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__pull.stderr b/tests/golden/expected/unknown__pull.stderr index 1696bd68..807130ef 100644 --- a/tests/golden/expected/unknown__pull.stderr +++ b/tests/golden/expected/unknown__pull.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__rag.stderr b/tests/golden/expected/unknown__rag.stderr index 81b854be..3ff6004c 100644 --- a/tests/golden/expected/unknown__rag.stderr +++ b/tests/golden/expected/unknown__rag.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__rerank.stderr b/tests/golden/expected/unknown__rerank.stderr index 3f1e6ead..c5b0d45e 100644 --- a/tests/golden/expected/unknown__rerank.stderr +++ b/tests/golden/expected/unknown__rerank.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__rm.stderr b/tests/golden/expected/unknown__rm.stderr index 2959a857..38a9fd2b 100644 --- a/tests/golden/expected/unknown__rm.stderr +++ b/tests/golden/expected/unknown__rm.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__segment.stderr b/tests/golden/expected/unknown__segment.stderr index 9bbc9105..983214d4 100644 --- a/tests/golden/expected/unknown__segment.stderr +++ b/tests/golden/expected/unknown__segment.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__show.stderr b/tests/golden/expected/unknown__show.stderr index 298dfaf4..7fa9554f 100644 --- a/tests/golden/expected/unknown__show.stderr +++ b/tests/golden/expected/unknown__show.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__stt.stderr b/tests/golden/expected/unknown__stt.stderr index fb1be01b..9e995645 100644 --- a/tests/golden/expected/unknown__stt.stderr +++ b/tests/golden/expected/unknown__stt.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__tts.stderr b/tests/golden/expected/unknown__tts.stderr index 96f8d1e7..86ff91df 100644 --- a/tests/golden/expected/unknown__tts.stderr +++ b/tests/golden/expected/unknown__tts.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__vad.stderr b/tests/golden/expected/unknown__vad.stderr index 08dc300c..30df74f3 100644 --- a/tests/golden/expected/unknown__vad.stderr +++ b/tests/golden/expected/unknown__vad.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__vlm.stderr b/tests/golden/expected/unknown__vlm.stderr index ac9f7974..0aea178b 100644 --- a/tests/golden/expected/unknown__vlm.stderr +++ b/tests/golden/expected/unknown__vlm.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/unknown__voice.stderr b/tests/golden/expected/unknown__voice.stderr index dac8ab22..24e8c47e 100644 --- a/tests/golden/expected/unknown__voice.stderr +++ b/tests/golden/expected/unknown__voice.stderr @@ -20,6 +20,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/golden/expected/version__bare.stderr b/tests/golden/expected/version__bare.stderr index 65c952ab..a53de92e 100644 --- a/tests/golden/expected/version__bare.stderr +++ b/tests/golden/expected/version__bare.stderr @@ -19,6 +19,7 @@ Coding tools: claude-desktop Open Claude Desktop with a model codex Open Codex with a model chatgpt Open ChatGPT's Codex with a model + openwork Open OpenWork with a local or cloud model opencode Open OpenCode with a local or cloud model hermes Open Hermes with a model openclaw Open OpenClaw with a model diff --git a/tests/local_harness/harness_child.rs b/tests/local_harness/harness_child.rs index 786bf2f7..7f910030 100644 --- a/tests/local_harness/harness_child.rs +++ b/tests/local_harness/harness_child.rs @@ -158,6 +158,39 @@ fn run() -> Result { .get("limit") .cloned() .unwrap_or(Value::Null); + } else if tool == "openwork" { + // OpenWork's engine reads the same variable; the local model is its + // own provider so a hosted catalog can sit beside it. + let config: Value = serde_json::from_str(&env_str("OPENCODE_CONFIG_CONTENT")) + .map_err(|error| format!("invalid OPENCODE_CONFIG_CONTENT: {error}"))?; + let providers = field(&config, "provider")?; + let provider = field(providers, "runanywhere-local")?; + url = text_field(field(provider, "options")?, "baseURL")?; + let selected = text_field(&config, "model")?; + model = selected + .strip_prefix("runanywhere-local/") + .ok_or_else(|| format!("the local model is not selected: {selected}"))? + .to_string(); + report["limits"] = field(field(provider, "models")?, &model)? + .get("limit") + .cloned() + .unwrap_or(Value::Null); + report["providers"] = json!(providers + .as_object() + .map(|map| map.keys().cloned().collect::>()) + .unwrap_or_default()); + report["hosted_models"] = json!(providers + .get("runanywhere") + .and_then(|hosted| hosted.get("models")) + .and_then(Value::as_object) + .map(|models| models.keys().cloned().collect::>()) + .unwrap_or_default()); + report["profile"] = json!({ + "user_data": env_str("OPENWORK_ELECTRON_USERDATA"), + "runtime_db": env_str("OPENWORK_RUNTIME_DB"), + "opencode_db": env_str("OPENCODE_DB"), + "engine_v2": env_str("OPENWORK_ENGINE_V2_PREVIEW"), + }); } else if tool == "dsh" { let mut patch = String::new(); let mut index = 1; diff --git a/tests/local_harness/test_local_harness.py b/tests/local_harness/test_local_harness.py index 00f4daeb..336bf78d 100644 --- a/tests/local_harness/test_local_harness.py +++ b/tests/local_harness/test_local_harness.py @@ -62,7 +62,7 @@ def main(): # PATH alone can select an incompatible System32 runtime. for runtime in sorted(child.parent.glob("*.dll")): shutil.copy2(runtime, bins / runtime.name) - for name in ("opencode", "dsh", "hermes", "openclaw", "claude"): + for name in ("opencode", "dsh", "hermes", "openclaw", "claude", "openwork"): target = bins / (name + (".exe" if os.name == "nt" else "")) shutil.copy2(child, target) weights_dir = models_home / "Models" / "LlamaCpp" / "qwen3-4b-instruct-2507" @@ -120,6 +120,62 @@ def invoke(args, expected=0, extra=None): assert connection.connect_ex((address.hostname, address.port)) != 0, captured print(f"PASS local {tool} ({model}): HTTP, launch arguments, teardown") + # OpenWork: the app is the stand-in (WALLY_OPENWORK_APP), on a + # private profile under the state dir, with the local model + # selected and, once signed in, the hosted catalog beside it. + state = root / "state" + openwork = {"WALLY_OPENWORK_APP": str(bins / ("openwork" + (".exe" if os.name == "nt" else ""))), + "XDG_STATE_HOME": str(state)} + profile_root = state / "runanywhere" / "openwork" + for signed_in in (False, True): + extra = dict(openwork, **({"WALLY_TEST_SEED_CLOUD": "test-cloud-token"} if signed_in else {})) + _, backend, captured = invoke(["openwork", "-m", "qwen3-4b-instruct"], extra=extra) + assert captured["model"] == "qwen3-4b-instruct", captured + assert pathlib.Path(backend["model_path"]) == weights, backend + assert backend["created"] == backend["destroyed"] == 1, backend + limits = captured["limits"] + assert backend.get("config", {}).get("context_length") == limits["context"], backend + if signed_in: + assert captured["providers"] == ["runanywhere", "runanywhere-local"], captured + assert captured["hosted_models"] == ["qwen3-4b-instruct-2507"], captured + else: + assert captured["providers"] == ["runanywhere-local"], captured + profile_env = captured["profile"] + for key in ("user_data", "runtime_db", "opencode_db"): + assert pathlib.Path(profile_env[key]).is_relative_to(profile_root), profile_env + assert profile_env["engine_v2"] not in ("", "1", "chat", "sidecar"), profile_env + workspaces = json.loads((profile_root / "user-data" / "openwork-workspaces.json").read_text()) + # Resolved on both sides: the runner's temp dir is an 8.3 + # short name, and wally writes the long, native spelling. + written = workspaces["workspaces"][0]["path"] + assert pathlib.Path(written).resolve() == (profile_root / "workspace").resolve(), workspaces + assert "/" not in written or os.name != "nt", workspaces + assert not (home / "OpenWork Chat").exists() + assert not (home / ".config" / "opencode").exists() + address = urllib.parse.urlsplit(captured["url"]) + with socket.socket() as connection: + connection.settimeout(1) + assert connection.connect_ex((address.hostname, address.port)) != 0, captured + print(f"PASS local openwork ({'signed in' if signed_in else 'signed out'}): " + "private profile, providers, teardown") + + # The seeded sign-in must not carry into the scenarios below. + shutil.rmtree(profile) + profile.mkdir() + + for report in (root / "child.json", root / "backend.json"): + report.unlink(missing_ok=True) + missing = subprocess.run( + [str(binary), "--home", str(models_home), "openwork", "-m", "qwen3-4b-instruct"], + env=dict(env, WALLY_OPENWORK_APP=str(root / "no-such-openwork")), + input="", text=True, capture_output=True, timeout=20) + assert missing.returncode == 127, missing + assert "not an OpenWork app" in missing.stderr, missing.stderr + if (root / "backend.json").exists(): + assert json.loads((root / "backend.json").read_text())["created"] == 0 + assert not (root / "child.json").exists() + print("PASS a missing OpenWork stops before any model loads") + uncertified_dir = models_home / "Models" / "LlamaCpp" / "qwen3-0.6b" uncertified_dir.mkdir(parents=True) (uncertified_dir / "Qwen3-0.6B-Q8_0.gguf").write_bytes(b"GGUF fixture") diff --git a/tests/test_help_cli.py b/tests/test_help_cli.py index 3b9fffbb..93c2b760 100644 --- a/tests/test_help_cli.py +++ b/tests/test_help_cli.py @@ -42,7 +42,7 @@ def run_help(self, *args, env=None): def test_harness_help_does_not_launch_or_require_tool(self): env = dict(self.env, PATH=self.directory.name) - for command in ("opencode", "claude-code", "claude-desktop", "hermes", "openclaw", "deepseek"): + for command in ("opencode", "openwork", "claude-code", "claude-desktop", "hermes", "openclaw", "deepseek"): with self.subTest(command=command): help_text = self.run_help(command, "--help", env=env) self.assertIn(f"Usage: wally {command}", help_text) diff --git a/tests/test_wally_openwork.rs b/tests/test_wally_openwork.rs new file mode 100644 index 00000000..3db78d92 --- /dev/null +++ b/tests/test_wally_openwork.rs @@ -0,0 +1,249 @@ +//! `wally openwork`: the provider config OpenWork's engine gets, the private +//! profile it runs on, and how the app is found and started. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use wally::harness::{ + app_at, build_openwork_config, program, without_verbatim_prefix, workspace_id, workspace_list, + CatalogModel, OpenWorkApp, Profile, Providers, HOSTED_PROVIDER, LOCAL_PROVIDER, +}; + +fn model(id: &str, context: i64) -> CatalogModel { + CatalogModel { + id: id.to_string(), + context_window: context, + ..Default::default() + } +} + +fn parse(config: &str) -> Value { + serde_json::from_str(config).expect("the config is JSON") +} + +#[test] +fn a_hosted_launch_lists_every_catalog_model_and_selects_the_launched_one() { + let catalog = [ + model("glm-5.3-flash", 200_000), + model("kimi-k3", 256_000), + model("minimax-m3", 1_000_000), + ]; + let doc = parse(&build_openwork_config( + "glm-5.3-flash", + &Providers { + local: None, + hosted: Some(("https://console.example.test/v1", "token", &catalog)), + }, + )); + let provider = &doc["provider"][HOSTED_PROVIDER]; + let ids: Vec<&str> = provider["models"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(ids.len(), 3); + for entry in &catalog { + assert!(ids.contains(&entry.id.as_str()), "{} is missing", entry.id); + } + assert_eq!(doc["model"], "runanywhere/glm-5.3-flash"); + assert_eq!( + provider["options"]["baseURL"], + "https://console.example.test/v1" + ); + assert_eq!(provider["options"]["apiKey"], "token"); + assert!(doc["provider"].get(LOCAL_PROVIDER).is_none()); +} + +#[test] +fn a_local_launch_lists_the_hosted_catalog_beside_it_and_selects_the_local_model() { + let local = [model("qwen3-4b-instruct-2507", 65_536)]; + let catalog = [model("glm-5.3-flash", 0), model("kimi-k3", 0)]; + let doc = parse(&build_openwork_config( + "qwen3-4b-instruct-2507", + &Providers { + local: Some(("http://127.0.0.1:5000/v1", &local)), + hosted: Some(("https://console.example.test/v1", "token", &catalog)), + }, + )); + assert_eq!(doc["model"], "runanywhere-local/qwen3-4b-instruct-2507"); + let on_machine = &doc["provider"][LOCAL_PROVIDER]; + assert_eq!(on_machine["options"]["baseURL"], "http://127.0.0.1:5000/v1"); + // A local server ignores the key, but opencode always sends one. + assert_eq!(on_machine["options"]["apiKey"], "local"); + assert_eq!( + on_machine["models"]["qwen3-4b-instruct-2507"]["limit"]["context"], + 65_536 + ); + assert_eq!( + doc["provider"][HOSTED_PROVIDER]["models"] + .as_object() + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn a_local_launch_signed_out_has_only_the_local_provider() { + let local = [model("qwen3-4b-instruct-2507", 65_536)]; + let doc = parse(&build_openwork_config( + "qwen3-4b-instruct-2507", + &Providers { + local: Some(("http://127.0.0.1:5000/v1", &local)), + hosted: None, + }, + )); + let providers: Vec<&String> = doc["provider"].as_object().unwrap().keys().collect(); + assert_eq!(providers, [LOCAL_PROVIDER]); +} + +#[test] +fn every_provider_declares_the_opencode_harness() { + let local = [model("a", 0)]; + let catalog = [model("b", 0)]; + let doc = parse(&build_openwork_config( + "a", + &Providers { + local: Some(("http://127.0.0.1:1/v1", &local)), + hosted: Some(("https://console.example.test/v1", "t", &catalog)), + }, + )); + for id in [LOCAL_PROVIDER, HOSTED_PROVIDER] { + assert_eq!( + doc["provider"][id]["options"]["headers"]["X-RA-Harness"], "opencode", + "{id}" + ); + } +} + +#[test] +fn the_profile_keeps_every_path_it_sets_under_its_own_root() { + let dir = tempfile::tempdir().expect("temp dir"); + let profile = Profile::under(dir.path()); + let root = dir.path().join("openwork"); + assert_eq!(profile.root, root); + let mut paths = 0; + for (name, value) in profile.environment() { + // Every value is a switch (no separator at all) or a path. + if !Path::new(&value).is_absolute() { + assert!( + !value.contains('/') && !value.contains('\\'), + "{name}={value} is neither a switch nor an absolute path" + ); + continue; + } + paths += 1; + assert!( + Path::new(&value).starts_with(&root), + "{name}={value} is outside the profile" + ); + } + assert!(paths >= 9, "only {paths} paths were checked"); +} + +#[test] +fn the_profile_keeps_chats_on_the_engine_that_reads_wallys_provider() { + let environment = Profile::under(Path::new("/s")).environment(); + let value = |name: &str| { + environment + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + }; + // Anything but 1, chat or sidecar turns the fresh-install v2 switch off. + let preview = value("OPENWORK_ENGINE_V2_PREVIEW").expect("set"); + assert!(!["", "1", "chat", "sidecar"].contains(&preview)); + assert_eq!(value("OPENWORK_ENGINE_AUTO_UPGRADE"), Some("0")); + assert_eq!( + value("OPENWORK_ELECTRON_DISABLE_PROTOCOL_REGISTRATION"), + Some("1") + ); + // opencode's log and saved logins move into the profile. + assert!(value("XDG_DATA_HOME").is_some()); + // The person's real home and opencode config stay in reach of the app. + for kept in ["HOME", "XDG_CONFIG_HOME", "USERPROFILE", "APPDATA"] { + assert_eq!(value(kept), None, "{kept} must not be overridden"); + } +} + +#[test] +fn the_workspace_list_selects_its_one_workspace_by_openworks_id() { + let path = "/state/runanywhere/openwork/workspace"; + let doc = parse(&workspace_list(path)); + let id = workspace_id(path); + assert!(id.starts_with("ws_") && id.len() == 15); + assert_eq!(doc["selectedId"], id.as_str()); + assert_eq!(doc["activeId"], id.as_str()); + assert_eq!(doc["workspaces"][0]["id"], id.as_str()); + assert_eq!(doc["workspaces"][0]["path"], path); + assert_eq!(doc["workspaces"][0]["workspaceType"], "local"); +} + +#[test] +fn verbatim_prefixes_are_spelled_the_way_node_spells_them() { + assert_eq!( + without_verbatim_prefix(r"\\?\C:\Users\me\openwork\workspace"), + r"C:\Users\me\openwork\workspace" + ); + assert_eq!( + without_verbatim_prefix(r"\\?\UNC\server\share\openwork"), + r"\\server\share\openwork" + ); + assert_eq!( + without_verbatim_prefix(r"\\?\Volume{0}\openwork"), + r"\\?\Volume{0}\openwork" + ); + assert_eq!( + without_verbatim_prefix("/state/openwork"), + "/state/openwork" + ); +} + +#[test] +fn the_workspace_path_is_the_resolved_folder() { + let dir = tempfile::tempdir().expect("temp dir"); + let profile = Profile::under(dir.path()); + profile.create().unwrap(); + assert_eq!( + Path::new(&profile.workspace_path()), + std::fs::canonicalize(&profile.workspace) + .unwrap() + .as_path() + .to_str() + .map(without_verbatim_prefix) + .map(PathBuf::from) + .unwrap() + ); +} + +#[test] +fn an_app_is_a_bundle_with_a_plist_or_an_executable_file() { + let dir = tempfile::tempdir().expect("temp dir"); + let bundle = dir.path().join("OpenWork.app"); + std::fs::create_dir_all(bundle.join("Contents")).unwrap(); + assert_eq!(app_at(&bundle), None, "a bundle with no Info.plist"); + std::fs::write(bundle.join("Contents/Info.plist"), "").unwrap(); + assert_eq!(app_at(&bundle), Some(OpenWorkApp::Bundle(bundle.clone()))); + + let executable = dir.path().join("openwork"); + assert_eq!(app_at(&executable), None, "nothing there"); + std::fs::write(&executable, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(app_at(&executable), None, "a file it may not execute"); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + assert_eq!( + app_at(&executable), + Some(OpenWorkApp::Executable(executable.clone())) + ); +} + +#[test] +fn an_executable_is_started_as_it_is() { + let app = OpenWorkApp::Executable(PathBuf::from("/opt/OpenWork/openwork")); + assert_eq!(program(&app), PathBuf::from("/opt/OpenWork/openwork")); +}