Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

## 2.4.0: Apple silicon, T3-style first run, easy sign-in

- **Apple silicon.** Packaging prefers native arm64 even when the shell is under Rosetta. llama.cpp Metal builds require `default.metallib` and set `GGML_METAL_PATH_RESOURCES` at runtime. `shadowcode doctor` reports Apple silicon vs Intel vs Rosetta and whether the Metal shaders are present.
- **TMPDIR redaction.** Darwin `/var/folders/<xx>/<yyyy>/T` names are no longer treated as high-entropy secrets, so macOS command cards stay readable.
- **First useful action is one screen.** Welcome is Open project (ask-first approvals by default). Sign-in, API keys and a free local download live on the empty conversation, not a second blocking wizard.
- **Easy sign-in.** Connect Codex, Claude Code, Cursor, Antigravity (Install & connect) and Grok from that same picker. Paste an OpenRouter, xAI or OpenAI key inline; new keys move to the macOS Keychain (or Linux desktop keyring) when one is available. Settings › Accounts remains the place to switch, disconnect and add other endpoints.
- **Chat-first chrome.** Sidebar threads show Working / Done / Needs approval. Permission mode short label is Allow edits. Changes includes Open pull request… into the Git tab.

## 2.3.3: Desktop reliability

Extends the existing Linux release signing key trust interval through 2.3.3. The tagged 2.3.2 candidate was not published because its trust interval ended at 2.3.1.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ exclude = ["vendor/glib-0.18.5"]
resolver = "2"

[workspace.package]
version = "2.3.3"
version = "2.4.0"
edition = "2021"
rust-version = "1.95"
license = "Apache-2.0"
Expand Down
20 changes: 13 additions & 7 deletions docs/MACOS_PORT_ESTIMATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,19 @@ Still open:
- **Publisher signatures for the macOS images.** They are checked against
the release's SHA-256 file over HTTPS, not signed with the release key:
that needs macOS targets in `release/trust/policy` and the signing tools.
- **Apple silicon** builds (`aarch64-apple-darwin`) are untested: there is
no arm64 Mac here. The runtime build already has the M1–M4 CPU variants.
- **Real model runs** on the Mac: the runtime loads and sees the GPU, but no
GGUF was available to generate with.
- Redaction's high-entropy rule masks macOS's per-user temporary folder
(`/var/folders/<random>/T/…`) as a secret. Safe but noisy; a path-aware
rule must not let base64 secrets with slashes through.
- **Notarization.** Disk images stay ad-hoc signed (GitHub is the channel).
- **Real model runs** on Apple silicon: doctor and the Metal runtime path
are wired; generating with a GGUF on this Mac still needs a model file.

Closed in 2.4.0 (this Apple silicon Mac Mini):

- Native arm64 packaging even when the shell is under Rosetta
(`hw.optional.arm64`); Intel builds remain for Intel Macs.
- Metal: `GGML_METAL_PATH_RESOURCES` points at `default.metallib` next to
llama-server; doctor reports processor and shader presence.
- Redaction skips Darwin `/var/folders/<xx>/<yyyy>/T` names so TMPDIR
paths are not masked as secrets.
- API keys prefer the login Keychain when the UI sends `prefer_keyring`.

## Work items

Expand Down
12 changes: 12 additions & 0 deletions docs/RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# ShadowCode 2.4.0 (candidate)

Not yet tagged. This candidate is the first Apple silicon verification and fine-tune pass, plus T3-style first-run and one-click provider sign-in. Do not overwrite published 2.3.3 assets. Suggested tag: `v2.4.0` after CI and a real-model Metal smoke if a GGUF is available.

**Apple silicon.** Native arm64 packaging when `hw.optional.arm64=1` even if `uname -m` is `x86_64` under Rosetta. Metal llama.cpp sets `GGML_METAL_PATH_RESOURCES` to the directory that holds `default.metallib`. Doctor reports processor and Metal shader presence. Darwin temp-folder names are no longer redacted as secrets.

**T3-style first run.** Open project lands in chat with the composer ready. Connect, paste an API key, or download a free local model on that same screen. Sidebar states are Working / Done / Needs approval. Git: commit from Changes, then Open pull request….

**Easy sign-in.** Codex, Claude, Cursor, Antigravity and Grok each have Connect (Antigravity can Install & connect). API keys: OpenRouter, xAI (Grok) and OpenAI on the same picker; stored in Keychain on Mac when available.

**Still blocked.** Vendor CLIs that are not installed still show Not installed (except Antigravity, which can download the agent). Cursor/Claude/Codex have no password form — they use each vendor's own browser sign-in. macOS images remain ad-hoc signed, not notarized. Real GGUF generation on this Mac is not part of this candidate unless a model is present.

# ShadowCode 2.3.3

Linux x64 and ARM64 packages are published and authenticated with the existing release signing key. Windows, macOS and Flatpak packages are also published. Version 2.3.2 was not published because its signing policy did not authorize that version; its tag remains unchanged. Version 2.3.3 extends the existing key's trust interval and adds a release preflight for policy mismatches.
Expand Down
9 changes: 5 additions & 4 deletions native/core/src/keyring.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
//! API keys in the desktop keyring, with `secrets.env` as the fallback.
//!
//! The keyring is the freedesktop Secret Service (GNOME Keyring, KWallet,
//! KeePassXC …) on the session D-Bus, or the login Keychain on macOS. A key moves there only when the user
//! asks (Settings › Accounts), and moves back the same way; the move writes
//! and reads back the new place before removing the old one, so a key is
//! never lost. `config/keyring.json` (mode 600) lists which keys live in the
//! KeePassXC …) on the session D-Bus, or the login Keychain on macOS. A key
//! moves there when the user asks (Settings › Accounts) or when the Mac UI
//! saves an API key with `prefer_keyring`; it moves back the same way. The
//! move writes and reads back the new place before removing the old one, so
//! a key is never lost. `config/keyring.json` (mode 600) lists which keys live in the
//! keyring; everything else stays in `secrets.env`, which is what headless
//! machines, SSH sessions and `shadowcode serve` use. Saving or removing a
//! key that lives in the keyring from a session that cannot reach one puts
Expand Down
33 changes: 33 additions & 0 deletions native/core/src/local_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,14 @@ pub fn runtime_env(binary: &Path) -> Vec<(String, std::ffi::OsString)> {
#[cfg(unix)]
if let Some(dir) = binary.parent() {
env.push(("LD_LIBRARY_PATH".into(), dir.as_os_str().to_owned()));
// llama.cpp loads default.metallib from this directory (compiled at
// build time). Without it, first Metal use compiles shaders at run
// time or fails outside a GUI session.
#[cfg(target_os = "macos")]
env.push((
"GGML_METAL_PATH_RESOURCES".into(),
dir.as_os_str().to_owned(),
));
}
#[cfg(not(unix))]
let _ = binary;
Expand Down Expand Up @@ -2239,6 +2247,12 @@ mod tests {
);
assert_eq!(metal[0].name, "AMD Radeon Pro 555X");
assert_eq!(backend_of(&metal), "metal");
// Apple silicon (M-series reports as MTL0: Apple M…).
let apple = parse_devices(
"Available devices:\n MTL0: Apple M4 (21845 MiB, 19660 MiB free)\n",
);
assert_eq!(apple[0].name, "Apple M4");
assert_eq!(backend_of(&apple), "metal");
let (v, c) =
parse_version("x\nversion: 0.4.1-dev (build 1, commit 18f9f7bef)\nbuilt with GNU")
.unwrap();
Expand Down Expand Up @@ -2298,4 +2312,23 @@ mod tests {
assert!(readded.excluded.is_empty());
assert!(remove(&config, "/nowhere/x.gguf").is_err());
}

#[test]
fn runtime_env_points_metal_shaders_at_the_binary_on_macos() {
let dir = tempfile::tempdir().unwrap();
let binary = dir.path().join("llama-server");
fs::write(&binary, b"x").unwrap();
let env = runtime_env(&binary);
#[cfg(target_os = "macos")]
{
let metal = env
.iter()
.find(|(name, _)| name == "GGML_METAL_PATH_RESOURCES")
.map(|(_, value)| value.clone());
assert_eq!(metal.as_deref(), Some(dir.path().as_os_str()));
}
#[cfg(not(target_os = "macos"))]
assert!(!env.iter().any(|(name, _)| name == "GGML_METAL_PATH_RESOURCES"));
let _ = binary;
}
}
7 changes: 4 additions & 3 deletions native/core/src/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
//!
//! - The model list comes from the public `GET /api/v1/models` and is cached
//! in the state directory; offline mode never fetches it.
//! - The key lives in the profile secrets file (`OPENROUTER_API_KEY`), is
//! checked with `GET /api/v1/key` before it is saved, and is never returned
//! to the window.
//! - The key lives in the profile secrets file (`OPENROUTER_API_KEY`) or,
//! when the UI sends `prefer_keyring`, in the macOS Keychain / desktop
//! keyring. It is checked with `GET /api/v1/key` before it is saved, and is
//! never returned to the window.
//! - Turns run on ShadowCode's own agent loop against the OpenAI-compatible
//! `POST /api/v1/chat/completions`, so ShadowCode's permissions, approvals,
//! checkpoints and review all apply.
Expand Down
47 changes: 41 additions & 6 deletions native/core/src/redaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,45 @@ fn high_entropy_tokens(text: &str) -> Vec<(usize, usize)> {
for found in re.find_iter(text) {
let run = found.as_str();
if let Some(segments) = path_segments(run) {
spans.extend(segments.filter(|(_, segment)| looks_random(segment)).map(
|(offset, segment)| {
let start = found.start() + offset;
(start, start + segment.len())
},
));
let listed: Vec<(usize, &str)> = segments.collect();
let skip = darwin_temp_folder_offsets(&listed);
spans.extend(listed.into_iter().filter_map(|(offset, segment)| {
if skip.contains(&offset) || !looks_random(segment) {
return None;
}
let start = found.start() + offset;
Some((start, start + segment.len()))
}));
} else if looks_random(run) {
spans.push((found.start(), found.end()));
}
}
spans
}

/// Offsets of Darwin per-user temp folder names (`/var/folders/<xx>/<yyyy>/T`).
/// Those names are high-entropy by design (mixed letters, digits, `_`, often
/// 32+ characters) and are not secrets; masking them hid every macOS command
/// that ran from `$TMPDIR`.
fn darwin_temp_folder_offsets(segments: &[(usize, &str)]) -> Vec<usize> {
let names: Vec<&str> = segments.iter().map(|(_, name)| *name).collect();
for i in 0..names.len() {
if names[i] != "var" || names.get(i + 1) != Some(&"folders") {
continue;
}
if names.get(i + 4) != Some(&"T") {
continue;
}
return segments
.get(i + 2)
.into_iter()
.chain(segments.get(i + 3))
.map(|(offset, _)| *offset)
.collect();
}
Vec::new()
}

/// The segments (with their offsets) of an absolute path under a well-known
/// root, or `None` for anything else.
fn path_segments(run: &str) -> Option<impl Iterator<Item = (usize, &str)>> {
Expand Down Expand Up @@ -647,13 +673,22 @@ mod tests {
for path in [
"/Volumes/NVME1TB/shadowcode-macos/task-demo",
"/private/var/folders/qq/twswrb793xqgdr954yy4t1dc0000gn/T/.tmpAb3dEf/project",
// Real Darwin TMPDIR names are often 32+ mixed alnum/`_` chars.
"/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000gn/T/.tmpAb3dEf/project",
"/Users/Robert/Projects/ShadowCode2026/native/core/src/redaction.rs",
"/home/dev/work/Project-2026_v2/src/main.rs",
"file:///private/var/folders/qq/twswrb793xqgdr954yy4t1dc0000gn/T/.tmpX1/team.git",
] {
let result = redact_text(&format!("cwd: {path}"));
assert!(!result.redacted, "{path} -> {}", result.text);
}
let noisy = "/var/folders/8q/k1q2r3s4t5u6v7w8x9y0z1a2b3c4d5__/T/shadowcode-cmd.log";
let result = redact_text(&format!("ran: cat {noisy}"));
assert!(
!result.redacted && result.text.contains("k1q2r3s4t5u6v7w8x9y0z1a2b3c4d5__"),
"{}",
result.text
);
// A secret that is a whole path segment is still hidden, the rest kept.
let secret = "Zx9Kq2Lm7Pw4Rt6Yv8Nb3Hc5Jd1Fg0Se7Ua2Qi";
let result = redact_text(&format!("/Users/dev/{secret}/notes.txt"));
Expand Down
13 changes: 13 additions & 0 deletions native/core/src/service/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ impl Service {
if !key.trim().is_empty() {
// The first save also fetches the model list.
let _ = crate::openrouter::refresh(self.engine.paths(), false).await;
// The Mac UI asks for Keychain (or the Linux desktop
// keyring) so a pasted key is not left in secrets.env.
if call.body["prefer_keyring"].as_bool() == Some(true) {
let paths = self.engine.paths();
let name = crate::openrouter::KEY_ENV;
if crate::keyring::availability().is_ok()
&& !crate::keyring::listed(paths).contains(name)
{
if let Err(error) = crate::keyring::move_in(paths, name) {
tracing::warn!("keyring.prefer_openrouter error={error:#}");
}
}
}
}
return Ok(
crate::openrouter::status(self.engine.paths(), cfg.offline(), true).await,
Expand Down
98 changes: 98 additions & 0 deletions native/core/src/service/inspection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,103 @@ fn owner_only(m: &std::fs::Metadata) -> bool {
}
}

/// Whether this process is an Intel binary translated on Apple silicon.
#[cfg(target_os = "macos")]
fn proc_translated() -> bool {
let mut value: i32 = 0;
let mut size = std::mem::size_of_val(&value);
let rc = unsafe {
libc::sysctlbyname(
b"sysctl.proc_translated\0".as_ptr().cast(),
std::ptr::from_mut(&mut value).cast(),
&mut size,
std::ptr::null_mut(),
0,
)
};
rc == 0 && value == 1
}

fn host_platform_checks() -> Vec<Value> {
let arch = std::env::consts::ARCH;
#[cfg(target_os = "macos")]
{
let translated = proc_translated();
let detail = if arch == "aarch64" && !translated {
format!("Apple silicon ({arch}); native arm64, not Rosetta")
} else if translated {
format!(
"Running under Rosetta as {arch}; use the Apple silicon (aarch64) build for Metal"
)
} else {
format!("Intel Mac ({arch})")
};
let mut checks = vec![check(
"host-arch",
if translated { "warn" } else { "pass" },
"Processor",
detail,
"Install ShadowCode_VERSION_aarch64.dmg on Apple silicon.",
)];
checks.extend(macos_metal_checks());
checks
}
#[cfg(not(target_os = "macos"))]
{
vec![check(
"host-arch",
"info",
"Processor",
format!("{}/{arch}", std::env::consts::OS),
"",
)]
}
}

#[cfg(target_os = "macos")]
fn macos_metal_checks() -> Vec<Value> {
let runtime = crate::local_engine::runtime("");
let metal_lib = runtime
.path
.as_ref()
.and_then(|path| path.parent())
.map(|dir| dir.join("default.metallib"));
let has_lib = metal_lib.as_ref().is_some_and(|path| path.is_file());
let backend = runtime.backend();
vec![
check(
"local-runtime",
match runtime.state {
"ready" => "pass",
"setup_required" => "info",
_ => "warn",
},
"Local llama.cpp runtime",
runtime.detail.clone(),
"Install or rebuild the macOS app so the Metal llama.cpp runtime is bundled.",
),
check(
"metal",
if runtime.state != "ready" {
"info"
} else if backend == "metal" || has_lib {
"pass"
} else {
"warn"
},
"Metal GPU",
if has_lib {
format!("default.metallib present; backend {backend}")
} else if runtime.state == "ready" {
format!("No default.metallib next to llama-server; backend {backend}")
} else {
"Runtime not installed yet; Metal shaders ship as default.metallib beside llama-server.".into()
},
"Rebuild with scripts/build-llama.cpp.sh so Metal shaders are compiled at build time.",
),
]
}

fn check(id: &str, status: &str, label: &str, detail: impl Into<String>, fix: &str) -> Value {
json!({"id":id,"status":status,"ok":status=="pass","label":label,"detail":detail.into(),"fix":if status=="pass"{""}else{fix}})
}
Expand Down Expand Up @@ -355,6 +452,7 @@ impl Service {
.unwrap_or("not an OS sandbox"),
"",
));
checks.extend(host_platform_checks());
checks.extend(crate::sandbox::doctor_checks());
checks.push(self.rules_check());
for mut check in crate::cli_agent::doctor::checks(&cfg.cli_agents).await {
Expand Down
10 changes: 10 additions & 0 deletions native/core/src/service/model_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,16 @@ impl Service {
let key = body["api_key"].as_str().unwrap_or("").trim();
if !key.is_empty() {
config::set_secret(paths, &key_env, key)?;
// The Mac UI asks for Keychain (or the Linux desktop keyring)
// so a pasted endpoint key is not left in secrets.env.
if body["prefer_keyring"].as_bool() == Some(true)
&& crate::keyring::availability().is_ok()
&& !crate::keyring::listed(paths).contains(&key_env)
{
if let Err(error) = crate::keyring::move_in(paths, &key_env) {
tracing::warn!("keyring.prefer_endpoint error={error:#}");
}
}
}
// With a key the server needs one: the row asks for it if it is
// removed later. Without one (Ollama, a server on the LAN) it never does.
Expand Down
Loading
Loading