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
2 changes: 1 addition & 1 deletion capsules/marketplace/browser/marketplace.js
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ import { sharedModelOffers, modelAccessOpportunities, REMOTE_MODEL_ID, SERVICE_O
const shared = state.sharedModels.filter(matches);
const access = state.modelAccess.filter(matches);
const localStatus = state.appLoading ? "Loading verified models…" : state.appLoadError
|| (state.modelCatalogState === "unavailable" ? "The model catalog could not be verified." : "");
|| (state.modelCatalogState === "unavailable" ? "The signed model list could not be fetched or verified. Connect to the internet and try again." : "");
const unavailable = state.remoteAvailability.filter(service => service.status !== "reachable").length;
els.storeSections.innerHTML = `${modelsSettingsChrome()}
<section class="store-section"><h2 class="store-section-title">On this Home</h2>
Expand Down
2 changes: 1 addition & 1 deletion components.json
Original file line number Diff line number Diff line change
Expand Up @@ -1739,7 +1739,7 @@
}
},
"model_catalog": {
"head_cid": "bafkreid5lc25ap4v3qw576uw3z3zdsbsfhqnnt5uwfdkawju4ejrtin7z4",
"head_cid": "bafkreihmcsvldwwa5ttbgw5yfrs62kiv3natqhfj3tkg3yzemsd73runr4",
"publisher_dids": [
"did:key:z6MkgwHd2BCWe1jHMXPiR6H1q1RFPcv1YzhMbK5G1kBarbfe"
],
Expand Down
41 changes: 30 additions & 11 deletions elastos/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,8 @@ release's Added, Changed, Fixed, Removed and Security bullets before you update;

Test-channel release. Signed releases show these notes in System before you update.

### Added

- Marketplace offers a second local model, Qwen2.5 1.5B Instruct (1.1 GB,
Apache-2.0), for noticeably better answers than SmolLM2. It needs about 3 GB
of free memory.
If your Home is on 0.8.0-alpha.10 or older, update it once from Terminal with
`elastos update`; the System page in those releases cannot install this one.

### Changed

Expand All @@ -30,11 +27,28 @@ Test-channel release. Signed releases show these notes in System before you upda
starts again on your next message. Only one local model runs at a time.
- When the model list is updated, a model already on this device says "Model
list updated. Press Use to keep using this model." Use keeps the files; nothing
downloads again. This release updates the list, so press Use once for SmolLM2.
downloads again.
- Marketplace shows a model's own description from the signed model list.

### Fixed

- An update made by an older version could leave out the release's signed model
list, after which Home, Undo and later updates stopped with "Installed release
inputs require repair". Home, Update and Undo now fetch the release's own
signed model list from your trusted source and continue. Without a connection,
Home still starts and Local AI says the list could not be fetched; Update and
Undo say so and change nothing.
- Update and Undo now carry the signed model list with the release's other
files, in Terminal and in System, so a release with a new model list installs
cleanly and Undo puts back the previous list.
- An Undo that stopped halfway could leave one component from the other release,
after which Update, Undo and `elastos setup` refused to continue. Home, Update
and Undo now put back this release's signed copy, and `elastos update` does so
even when no newer release exists. Replacing a component folder no longer
removes the old one first.
- `elastos setup` fetched the newest release's components by name, so setting up
an older release failed. It now fetches that release's own files by their
signed IDs.
- A brief network stall no longer stops a model download. Home tries a part
that timed out up to three more times before it stops.
- After about 64 model downloads, retries or cancels, Home could no longer get
Expand All @@ -45,11 +59,16 @@ Test-channel release. Signed releases show these notes in System before you upda

### Developer detail

- Signed model catalogue 3 is pinned in `components.json` (head
`bafkreid5lc25ap4v3qw576uw3z3zdsbsfhqnnt5uwfdkawju4ejrtin7z4`): SmolLM2-135M
unchanged plus Qwen2.5-1.5B-Instruct Q4_K_M (package
`bafybeievyi6xp5b62qkuh65ywkd72medhx6uxrmik2fhnr3aameeqnxvwe`, built
reproducibly locally and on the seed, pinned there) (#84).
- Home start, `elastos update`, System update and Undo first repair the installed
release from its signed components: a missing or wrong-head pinned model
catalogue is fetched by CID over Carrier and checked for CID and publisher
signature; a component with the wrong bytes is re-fetched by CID. Both update
paths install the target's catalogue as journaled support. Setup fetches
components and the catalogue by the release's signed CID instead of by name.
The catalogue pin stays at alpha.11's head
(`bafkreihmcsvldwwa5ttbgw5yfrs62kiv3natqhfj3tkg3yzemsd73runr4`) so alpha.11 and
alpha.12 updaters, which do not install catalogues, can update to this release
(#113).
- `model-provider` admits a local engine right before spawn when free memory
covers GGUF weights once, the f16 KV cache at the configured context, batch
scratch and headroom of max(10% RAM, 1 GiB). Free memory is Linux
Expand Down
4 changes: 3 additions & 1 deletion elastos/crates/elastos-server/src/api/capsule_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,9 @@ pub(crate) mod tests {
serde_json::json!(format!("sha256:{:x}", digest.finalize()));
}

fn sign_model_catalog(payload: &Value) -> (crate::setup::ModelCatalogConfig, Vec<u8>) {
pub(crate) fn sign_model_catalog(
payload: &Value,
) -> (crate::setup::ModelCatalogConfig, Vec<u8>) {
let key = elastos_runtime::signature::SigningKey::from_bytes(&[7; 32]);
let (signature, signer_did) = crate::crypto::domain_separated_sign(
&key,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ async fn caller_capsule_catalog_summary(
state: &GatewayState,
context: &HomeLaunchTokenContext,
) -> CapsuleCatalogResponse {
// Home may have started offline without its pinned catalogue; Local AI's
// read retries it, and otherwise reports it unavailable.
if let Err(error) = crate::setup::retry_model_catalog(&state.data_dir).await {
tracing::debug!("model catalogue unavailable: {error:#}");
}
#[cfg(unix)]
{
// Catalog trust may be revoked while the provider is answering. Rebuild
Expand Down
33 changes: 25 additions & 8 deletions elastos/crates/elastos-server/src/carrier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8369,6 +8369,15 @@ impl CarrierClient {
.await
}

pub(crate) async fn fetch_content_bounded(
&self,
cid: &str,
max_bytes: usize,
) -> Result<Vec<u8>> {
let msg = serde_json::json!({"op": "content_fetch", "cid": cid});
self.fetch_bytes(msg, "content fetch", cid, max_bytes).await
}

/// Sends `msg` and reads its length-prefixed reply. Every phase is
/// bounded, none by total time: the request and each body read get
/// `DOWNLOAD_IDLE_TIMEOUT`, and the source gets `DOWNLOAD_HEADER_WAIT` to
Expand Down Expand Up @@ -8582,10 +8591,15 @@ const DOWNLOAD_HEADER_WAIT: Duration = Duration::from_secs(if cfg!(test) { 3 } e
pub(crate) const MAX_DOWNLOAD_BYTES: usize = 200 * 1024 * 1024;

/// Fetches the signed CID when present, else the release name, then closes.
async fn fetch_and_close(client: CarrierClient, cid: Option<&str>, path: &str) -> Result<Vec<u8>> {
async fn fetch_and_close(
client: CarrierClient,
cid: Option<&str>,
path: &str,
max_bytes: usize,
) -> Result<Vec<u8>> {
let result = match cid {
Some(cid) => client.fetch_content(cid, None).await,
None => client.fetch_file(path).await,
Some(cid) => client.fetch_content_bounded(cid, max_bytes).await,
None => client.fetch_file_bounded(path, max_bytes).await,
};
client.close().await;
result
Expand All @@ -8597,6 +8611,7 @@ pub(crate) async fn fetch_file_from_trusted_source_bound(
path: &str,
connect_timeout_secs: u64,
bind_addr: Option<std::net::SocketAddr>,
max_bytes: usize,
) -> Result<Vec<u8>> {
let node_id = source_transport_endpoint_id(source)?;
let mut errors = Vec::new();
Expand All @@ -8605,7 +8620,7 @@ pub(crate) async fn fetch_file_from_trusted_source_bound(
match CarrierClient::connect_endpoint_addr_bound(endpoint, connect_timeout_secs, bind_addr)
.await
{
Ok(client) => match fetch_and_close(client, cid, path).await {
Ok(client) => match fetch_and_close(client, cid, path, max_bytes).await {
Ok(bytes) => return Ok(bytes),
Err(err) => errors.push(format!("ticket[{index}] fetch of {path} failed: {err}")),
},
Expand All @@ -8618,7 +8633,7 @@ pub(crate) async fn fetch_file_from_trusted_source_bound(
match CarrierClient::connect_endpoint_addr_bound(endpoint, connect_timeout_secs, bind_addr)
.await
{
Ok(client) => match fetch_and_close(client, cid, path).await {
Ok(client) => match fetch_and_close(client, cid, path, max_bytes).await {
Ok(bytes) => return Ok(bytes),
Err(err) => errors.push(format!("relay[{index}] fetch of {path} failed: {err}")),
},
Expand All @@ -8630,7 +8645,7 @@ pub(crate) async fn fetch_file_from_trusted_source_bound(
node_id.ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?;
let addrs = source_carrier_addrs(source);
match CarrierClient::connect_bound(&node_id, &addrs, connect_timeout_secs, bind_addr).await {
Ok(client) => match fetch_and_close(client, cid, path).await {
Ok(client) => match fetch_and_close(client, cid, path, max_bytes).await {
Ok(bytes) => Ok(bytes),
Err(err) => {
errors.push(format!("direct fetch of {path} failed: {err}"));
Expand Down Expand Up @@ -10862,7 +10877,9 @@ pub(crate) mod tests {
.await
.unwrap();
assert_eq!(
fetch_and_close(client, None, "artifact").await.unwrap(),
fetch_and_close(client, None, "artifact", MAX_DOWNLOAD_BYTES)
.await
.unwrap(),
b"fixture"
);
assert!(!runtime.is_closed());
Expand Down Expand Up @@ -10978,7 +10995,7 @@ pub(crate) mod tests {
let client = CarrierClient::connect_owned_endpoint(endpoint, address, 5)
.await
.unwrap();
let result = fetch_and_close(client, None, "artifact").await;
let result = fetch_and_close(client, None, "artifact", MAX_DOWNLOAD_BYTES).await;
let closed_before_return = observer.is_closed();
observer.close().await;
let remote_close = tokio::time::timeout(Duration::from_secs(5), serving)
Expand Down
2 changes: 1 addition & 1 deletion elastos/crates/elastos-server/src/gateway_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub async fn run_gateway(
}

pub async fn run_browser_home() -> anyhow::Result<()> {
elastos_server::update_controller::enter_browser_home()?;
elastos_server::update_controller::enter_browser_home().await?;
elastos_server::gateway_cmd::run_gateway_direct_with_ready(
format!(
"localhost:{}",
Expand Down
14 changes: 12 additions & 2 deletions elastos/crates/elastos-server/src/install_transaction/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ fn scratch(tx: &InstallTransaction, index: usize, candidate: bool) -> PathBuf {
))
}

/// The signed model catalogue is the one top-level file a release's support may replace.
const MODEL_CATALOG: &str = "model-catalog.json";

pub(crate) fn validate_support_path(path: &Path) -> anyhow::Result<()> {
if path == Path::new(MODEL_CATALOG) {
return Ok(());
}
let mut parts = path.components();
anyhow::ensure!(matches!(parts.next(), Some(std::path::Component::Normal(name)) if ["bin", "capsules", "libexec", "tools"].iter().any(|allowed| name == *allowed))
&& parts.all(|part| matches!(part, std::path::Component::Normal(name) if !name.to_string_lossy().starts_with(".elastos."))), "unsafe release support path");
Expand All @@ -29,7 +35,9 @@ pub(super) fn validate_entries(tx: &InstallTransaction, entries: &[Entry]) -> an
for (index, entry) in entries.iter().enumerate() {
let path = &entry.path;
validate_support_path(path)?;
if path.components().count() == 1 && (entry.original.is_some() || entry.candidate.is_none())
if path.components().count() == 1
&& path != Path::new(MODEL_CATALOG)
&& (entry.original.is_some() || entry.candidate.is_none())
|| tx
.destinations
.values()
Expand Down Expand Up @@ -169,7 +177,9 @@ pub(super) fn prepare(tx: &InstallTransaction, paths: &[(PathBuf, PathBuf)]) ->
for (relative, source) in paths {
validate_support_path(relative)?;
anyhow::ensure!(
relative.components().count() > 1 || source.is_dir(),
relative.components().count() > 1
|| relative == Path::new(MODEL_CATALOG)
|| source.is_dir(),
"new support root must be a directory"
);
journal.support.push(Entry {
Expand Down
53 changes: 43 additions & 10 deletions elastos/crates/elastos-server/src/installed_release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,47 @@ pub(crate) fn read_without_migration_for_update(
}
}

/// The installed signed components, before their support is admitted. Repair of a
/// missing catalogue or a mismatched component trusts only these bytes.
pub(crate) fn read_signed_components(
data: &Path,
binary: &Path,
source: &TrustedSource,
repair_version: bool,
) -> Result<Vec<u8>> {
require_no_pending(binary)?;
require_current_source(data, source)?;
let private = consumed_present(data)?;
let (head, release) = if private {
(
installation_release_head_path(data),
installation_release_manifest_path(data),
)
} else {
(
publisher_release_head_path(data),
publisher_release_manifest_path(data),
)
};
let (_, release) = read_pair_and_binary(
data,
binary,
source,
&head,
&release,
private,
repair_version,
)?;
let components = read_regular(&data.join("components.json"), MAX_COMPONENTS, false)?;
admit_descriptor(
&release["payload"]["platforms"][crate::update::detect_release_platform()]["components"],
&hex::encode(Sha256::digest(&components)),
components.len() as u64,
)
.context(REPAIR)?;
Ok(components)
}

/// Setup holds this guard while it obtains signed support for an installed binary.
/// Its authority starts with Runtime's private pair, before support is present.
pub(crate) fn read_for_setup(
Expand Down Expand Up @@ -454,16 +495,8 @@ fn admit_support(data: &Path, binary: &Path, components: &[u8]) -> Result<()> {
}
}
}
if let Some(trust) = &manifest.model_catalog {
let bytes = read_regular(&data.join("model-catalog.json"), 128 * 1024, false)?;
let envelope: serde_json::Value = serde_json::from_slice(&bytes)?;
let published_at = envelope["payload"]["published_at"]
.as_u64()
.context("Installed catalogue publication time is missing")?;
// Installed custody retains the signed snapshot. Current offer discovery
// continues to enforce its expiry at the current time.
crate::api::capsule_inventory::verify_model_catalog(trust, &bytes, published_at)?;
}
// The pinned catalogue is not Home's to require: Local AI verifies it on every
// read, and Update/Undo repair it before admission.
if manifest.external.contains_key("home") || manifest.capsules.contains_key("home") {
let document = crate::api::browser_capsules::installed_home_document(data)
.context("Installed Home document is unavailable")?;
Expand Down
16 changes: 8 additions & 8 deletions elastos/crates/elastos-server/src/installed_release/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,8 +513,9 @@ fn native_provider_outside_home_profile_is_verified_when_present_and_never_requi
}

#[test]
fn installed_catalogue_keeps_pinned_custody_after_offer_expiry() {
fn home_admission_does_not_depend_on_the_catalogue_that_local_ai_verifies() {
for case in [
"missing",
"expired valid",
"wrong signer",
"wrong cid",
Expand Down Expand Up @@ -542,10 +543,13 @@ fn installed_catalogue_keeps_pinned_custody_after_offer_expiry() {
serde_json::to_vec(&components).unwrap(),
)
.unwrap();
fs::write(fixture.data.join("model-catalog.json"), catalogue).unwrap();
if case != "missing" {
fs::write(fixture.data.join("model-catalog.json"), catalogue).unwrap();
}
fixture.bind_components();
assert_eq!(fixture.load().is_ok(), case == "expired valid", "{case}");
// Current model discovery retains the real-time expiry gate.
// Home starts offline after an older updater left the catalogue out.
assert!(fixture.load().is_ok(), "{case}");
// Local AI alone reports it, with the real-time expiry gate.
assert!(
crate::api::capsule_inventory::model_catalog_entries(&fixture.data).is_err(),
"{case}"
Expand Down Expand Up @@ -592,9 +596,6 @@ fn setup_reader_admits_binary_only_installation_without_support_reads_or_writes(
"components changed",
"components directory",
"components symlink",
"catalogue missing",
"catalogue changed",
"catalogue symlink",
"support missing",
"support changed",
"support directory",
Expand Down Expand Up @@ -627,7 +628,6 @@ fn setup_reader_admits_binary_only_installation_without_support_reads_or_writes(
let (input, mutation) = case.split_once(' ').unwrap();
let path = fixture.data.join(match input {
"components" => "components.json",
"catalogue" => "model-catalog.json",
"support" => "bin/kubo",
_ => unreachable!(),
});
Expand Down
Loading
Loading