Add branch and historical version selection to Galaxy

This commit is contained in:
AngusBell97
2026-09-11 14:36:56 +01:00
committed by firestar5683
parent 6b8bb279d4
commit c4f46c51b2
16 changed files with 3697 additions and 18 deletions
@@ -1899,3 +1899,24 @@ button.gx-chip:hover {
.gx-monitor td:first-child { min-width:180px; max-width:350px; white-space:normal; overflow-wrap:anywhere; }
.gx-monitor tbody tr:hover { background:var(--surface); }
@media(max-width:600px) { .gx-monitor__summary { grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .gx-monitor__metric { padding:12px; } .gx-monitor__metric strong { font-size:22px; } }
/* Version and branch selector menus. */
.gx-select { display: inline-flex; position: relative; vertical-align: middle; min-width: 0; max-width: 100%; }
.gx-select.gx-field { padding: 0; background: var(--surface-container-high); }
.gx-select.gx-field--full { display: flex; width: 100%; }
.gx-select__button { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-width: 0; min-height: 44px; padding: 10px var(--sp-3); border: 0; border-radius: inherit; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; }
.gx-select__button > span { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.gx-select__button > i { flex-shrink: 0; }
.gx-select:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; }
.gx-select__button:disabled { opacity: .5; cursor: default; }
.gx-select-menu { position: fixed; inset: auto; margin: 0; padding: 6px; box-sizing: border-box; max-width: none; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--outline-variant, var(--glass-border)); border-radius: var(--radius-md); color: var(--on-surface); background: var(--surface-container-high, #252033); box-shadow: 0 12px 36px #0006; font: var(--fs-base) var(--font-body); }
.gx-select-menu::backdrop { background: transparent; }
.gx-select-menu [role="option"] { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 10px; background: transparent; color: inherit; text-align: left; font: inherit; white-space: normal; overflow-wrap: anywhere; cursor: pointer; }
.gx-select-menu [role="option"][aria-selected="true"] { background: var(--primary-container); color: var(--on-primary-container); }
.gx-select-menu [role="option"]:hover:not(:disabled), .gx-select-menu [role="option"]:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; background: var(--primary-container); color: var(--on-primary-container); }
.gx-select-menu [role="option"]:disabled { opacity: .45; cursor: default; }
.gx-select-menu__group { padding: 10px 12px 4px; color: var(--on-surface-variant); font-size: .85em; }
.gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #141020; }
[data-theme="light"] .gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #fff; }
.gx-select-menu:not([open]) { display: none; }
@@ -228,6 +228,12 @@ export const api = {
getUpdateBranches() { return request("/api/update/branches") },
getUpdateBranch() { return request("/api/update/branch") },
setUpdateBranch(branch) { return request("/api/update/branch", { method: "POST", data: { branch } }) },
getUpdateVersions(branch, { page = 1, head = "", signal } = {}) {
const query = new URLSearchParams({ branch, page: String(page) })
if (head) query.set("head", head)
return request(`/api/update/versions?${query}`, { cache: "no-store", signal })
},
installUpdateVersion(branch, commit) { return request("/api/update/version", { method: "POST", data: { branch, commit, confirmed: true } }) },
updateFast() { return request("/api/update/fast", { method: "POST" }) },
getUpdateFastStatus() { return request("/api/update/fast/status") },
updateRecover() { return request("/api/update/recover", { method: "POST" }) },
@@ -34,7 +34,7 @@ export const GalaxyModal = {
<transition name="gx-slide" appear>
<div class="gx-sheet" role="dialog" :aria-label="title">
<h3 class="gx-sheet__title">{{ title }}</h3>
<p v-if="message" style="color: var(--text-muted); line-height: 1.5;">{{ message }}</p>
<p v-if="message" style="color: var(--text-muted); line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere;">{{ message }}</p>
<input v-if="input" ref="input" v-model="value" class="gx-field gx-field--full" type="text"
:placeholder="inputPlaceholder" @keyup.enter="confirm" />
<div class="gx-dialog__actions">
@@ -0,0 +1,141 @@
let nextId = 0
// Keep the native select as the value/event adapter; presentation belongs to Galaxy.
export const GalaxySelect = {
name: "GalaxySelect",
inheritAttrs: false,
props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean },
emits: ["change", "update:modelValue"],
data() { return { uid: `gx-select-${++nextId}`, items: [], selected: "", label: "", open: false, name: "Choose an option", typeahead: "", typedAt: 0 } },
computed: {
current() { return this.modelValue !== undefined ? this.modelValue : this.value },
buttonId() { return this.$attrs.id || this.uid },
nativeAttrs() {
const attrs = { ...this.$attrs }
for (const key of ["class", "style", "id", "aria-label", "aria-labelledby", "aria-describedby"]) delete attrs[key]
return attrs
},
},
methods: {
sync() {
const native = this.$refs.native
if (!native) return
if (this.current !== undefined) native.value = String(this.current ?? "")
const items = [...native.options].map((option, index) => ({ index, value: option.value, label: option.label, description: option.dataset?.description || "", disabled: option.disabled || (option.parentElement?.tagName === "OPTGROUP" && option.parentElement.disabled), group: option.parentElement?.tagName === "OPTGROUP" ? option.parentElement.label : "" }))
if (JSON.stringify(items) !== JSON.stringify(this.items)) this.items = items
this.selected = native.value
this.label = native.selectedOptions[0]?.dataset?.collapsedLabel || native.selectedOptions[0]?.label || "Choose an option"
const button = this.$refs.button
const labelled = this.$attrs["aria-labelledby"]?.split(/\s+/).map(id => document.getElementById(id)?.textContent || "").join(" ")
const labels = [...new Set([...(button?.labels || []), button?.closest("label")].filter(Boolean))].map(label => {
const copy = label.cloneNode(true)
copy.querySelectorAll(".gx-select, select, button").forEach(node => node.remove())
return copy.textContent.trim()
}).filter(Boolean).join(" ")
this.name = this.$attrs["aria-label"] || labelled || labels || button?.closest(".gx-row")?.querySelector(".gx-row__label")?.textContent || this.$attrs.title || "Choose an option"
if (this.disabled && this.open) this.close()
},
async show(event) {
if (this.disabled) return
this.sync()
this.open = true
await this.$nextTick()
const menu = this.$refs.menu
menu.showModal()
this.position()
const options = [...menu.querySelectorAll('[role="option"]:not(:disabled)')]
const selected = options.find(option => option.dataset.value === this.selected)
const first = event?.key === "End" ? options.at(-1) : options[0]
;(selected || first)?.focus()
},
close() {
if (!this.open) return
this.open = false
this.$refs.menu?.close()
if (this.$refs.button?.isConnected) this.$refs.button.focus({ preventScroll: true })
},
position() {
if (!this.open) return
const rect = this.$refs.button.getBoundingClientRect()
const viewport = window.visualViewport
const width = viewport?.width || window.innerWidth
const height = viewport?.height || window.innerHeight
const menu = this.$refs.menu
let zoom = 1
for (let node = menu; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1
const menuWidth = Math.min(Math.max(rect.width, 240), width - 24)
menu.style.width = `${menuWidth / zoom}px`
menu.style.maxHeight = `${(height - 24) / zoom}px`
const menuHeight = Math.min(menu.scrollHeight * zoom + 2, height - 24)
const below = height - rect.bottom - 12
const top = below >= Math.min(menuHeight, 220) ? Math.min(rect.bottom + 6, height - menuHeight - 12) : Math.max(12, rect.top - menuHeight - 6)
menu.style.left = `${Math.max(12, Math.min(rect.left, width - menuWidth - 12)) / zoom}px`
menu.style.top = `${Math.max(12, top) / zoom}px`
},
select(item) {
if (this.disabled) return this.close()
const native = this.$refs.native
const option = native.options[item.index]
if (!option || option.value !== item.value || option.disabled || option.parentElement?.disabled) return this.close()
this.close()
if (native.value === option.value) return
native.value = option.value
native.dispatchEvent(new Event("change", { bubbles: true }))
this.sync()
},
change(event) {
this.$emit("update:modelValue", event.target.value)
this.$emit("change", event)
},
buttonKey(event) {
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { event.preventDefault(); this.show(event) }
},
menuKey(event) {
if (event.key === "Tab") { event.preventDefault(); this.close(); return }
const options = [...this.$refs.menu.querySelectorAll('[role="option"]:not(:disabled)')]
const index = options.indexOf(document.activeElement)
let target
if (event.key === "ArrowDown") target = options[(index + 1) % options.length]
if (event.key === "ArrowUp") target = options[(index - 1 + options.length) % options.length]
if (event.key === "Home") target = options[0]
if (event.key === "End") target = options.at(-1)
if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
const now = performance.now()
this.typeahead = (now - this.typedAt < 700 ? this.typeahead : "") + event.key.toLocaleLowerCase()
this.typedAt = now
target = options.find(option => option.textContent.trim().toLocaleLowerCase().startsWith(this.typeahead))
}
if (target) { event.preventDefault(); target.focus() }
},
},
mounted() {
this.sync()
window.addEventListener("resize", this.position)
window.visualViewport?.addEventListener("resize", this.position)
},
updated() { this.sync() },
beforeUnmount() {
this.$refs.menu?.close()
window.removeEventListener("resize", this.position)
window.visualViewport?.removeEventListener("resize", this.position)
},
template: `
<span class="gx-select" :class="$attrs.class" :style="$attrs.style">
<select ref="native" v-bind="nativeAttrs" hidden tabindex="-1" aria-hidden="true" :disabled="disabled" @change="change"><slot /></select>
<button ref="button" :id="buttonId" class="gx-select__button" type="button" role="combobox" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="uid + '-list'"
:aria-label="name" :aria-describedby="$attrs['aria-describedby']" :disabled="disabled" @click="show" @keydown="buttonKey">
<span>{{ label }}</span><i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<dialog ref="menu" class="gx-select-menu" @cancel.prevent="close" @click="event => { if (event.target === $refs.menu) close() }" @keydown="menuKey">
<div :id="uid + '-list'" role="listbox" :aria-label="name">
<template v-for="(item, index) in items" :key="item.index">
<div v-if="item.group && item.group !== items[index - 1]?.group" class="gx-select-menu__group">{{ item.group }}</div>
<button type="button" role="option" :data-value="item.value" :aria-selected="item.value === selected" :disabled="item.disabled" @click="select(item)">
<span style="min-width:0; overflow-wrap:anywhere;"><span>{{ item.label }}</span><small v-if="item.description" class="gx-note" style="display:block; margin-top:4px; white-space:normal; line-height:1.4;">{{ item.description }}</small></span><i v-if="item.value === selected" class="bi bi-check-lg" aria-hidden="true"></i>
</button>
</template>
</div>
</dialog>
</span>
`,
}
@@ -0,0 +1,205 @@
let nextHistoryId = 0
function validDate(value) {
const date = value ? new Date(value) : null
return date && Number.isFinite(date.getTime()) ? date : null
}
function dateLabel(date) {
return date.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })
}
export function groupVersionHistory(commits) {
const groups = new Map()
for (const commit of commits) {
const date = validDate(commit.date)
// Local calendar days match the local date/time displayed for each build.
const key = date ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}` : "unknown"
if (!groups.has(key)) groups.set(key, { key, label: date ? dateLabel(date) : "Unknown date", commits: [] })
groups.get(key).commits.push(commit)
}
return [...groups.values()].sort((a, b) => a.key === "unknown" ? 1 : b.key === "unknown" ? -1 : b.key.localeCompare(a.key))
}
// History arrives newest first. Keep that exact build when older pages repeat a release.
export function releaseVersions(commits) {
const versions = new Map()
for (const commit of commits) {
const key = commit.version || ""
if (!versions.has(key)) versions.set(key, commit)
}
return [...versions.values()].sort((a, b) => {
if (!a.version) return b.version ? 1 : 0
if (!b.version) return -1
const left = a.version.split(".").map(Number), right = b.version.split(".").map(Number)
return right[0] - left[0] || right[1] - left[1] || right[2] - left[2]
})
}
export function versionTitle(commit, releaseBranch) {
const date = validDate(commit?.date)
const when = date ? `${dateLabel(date)} · ${date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}` : "Unknown date"
return releaseBranch ? `${commit?.version || "Unnumbered version"} · ${when}` : `${commit?.subject || "Untitled change"} · ${when} · ${String(commit?.sha || "").slice(0, 10)}`
}
export const VersionHistoryPicker = {
name: "VersionHistoryPicker",
inheritAttrs: false,
props: { value: { default: "" }, commits: { type: Array, default: () => [] }, releaseBranch: Boolean, loading: Boolean, hasMore: Boolean, error: { default: "" }, notice: { default: "" }, disabled: Boolean },
emits: ["change", "loadmore"],
data() { return { uid: `gx-history-${++nextHistoryId}`, open: false, expanded: {} } },
computed: {
choices() { return this.releaseBranch ? releaseVersions(this.commits) : this.commits },
groups() { return groupVersionHistory(this.choices) },
selected() { return this.choices.find(commit => commit.sha === this.value) },
label() { return this.selected ? versionTitle(this.selected, this.releaseBranch) : "Select an earlier version" },
range() {
if (this.releaseBranch) return `${this.choices.length} ${this.choices.length === 1 ? "release" : "releases"} · ${this.commits.length} builds checked`
const known = this.groups.filter(group => group.key !== "unknown")
if (!known.length) return this.commits.length ? `${this.commits.length} versions loaded` : "No history loaded yet"
return `${this.commits.length} versions · ${known.at(-1).label}${known.length > 1 ? ` – ${known[0].label}` : ""}`
},
},
watch: {
commits: { immediate: true, handler() { this.syncDays() } },
disabled(value) { if (value) this.close() },
},
methods: {
versionTitle,
syncDays() {
const firstLoad = !Object.keys(this.expanded).length
const expanded = {}
for (const [index, group] of this.groups.entries()) expanded[group.key] = this.expanded[group.key] ?? (firstLoad && index === 0)
this.expanded = expanded
},
async show() {
if (this.disabled) return
const selectedDay = this.groups.find(group => group.commits.some(commit => commit.sha === this.value))
if (selectedDay) this.expanded[selectedDay.key] = true
this.open = true
await this.$nextTick()
this.$refs.menu.showModal()
this.position()
;(this.$refs.menu.querySelector('[aria-selected="true"]') || this.$refs.menu.querySelector('[data-day]') || this.$refs.close).focus()
},
close() {
if (!this.open) return
this.open = false
this.$refs.menu?.close()
if (this.$refs.button?.isConnected) this.$refs.button.focus({ preventScroll: true })
},
position() {
if (!this.open) return
const viewport = window.visualViewport
const width = viewport?.width || window.innerWidth
const height = viewport?.height || window.innerHeight
const menu = this.$refs.menu
const rect = this.$refs.button.getBoundingClientRect()
let zoom = 1
for (let node = menu; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1
const menuWidth = Math.min(Math.max(rect.width, 420), width - 24)
const menuHeight = Math.min(640, height - 24)
menu.style.width = `${menuWidth / zoom}px`
menu.style.height = `${menuHeight / zoom}px`
menu.style.left = `${Math.max(12, Math.min(rect.left, width - menuWidth - 12)) / zoom}px`
menu.style.top = `${Math.max(12, Math.min(rect.bottom + 6, height - menuHeight - 12)) / zoom}px`
},
select(sha) {
if (this.disabled || !/^[a-f0-9]{40}$/.test(sha) || !(this.releaseBranch ? releaseVersions(this.commits) : this.commits).some(commit => commit.sha === sha)) return
this.$emit("change", { target: { value: sha } })
this.close()
},
loadMore() {
if (this.disabled || this.loading || (!this.hasMore && !this.error)) return
this.$emit("loadmore")
},
buttonKey(event) {
if (["ArrowDown", "ArrowUp"].includes(event.key)) { event.preventDefault(); this.show() }
},
menuKey(event) {
const target = event.target
const day = target.closest("[data-day-group]")
if (day && ["ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault()
this.expanded[day.dataset.dayGroup] = event.key === "ArrowRight"
day.querySelector("[data-day]").focus()
return
}
const items = [...this.$refs.menu.querySelectorAll("button:not(:disabled)")].filter(button => button.getClientRects().length)
const index = items.indexOf(target)
let next
if (event.key === "ArrowDown") next = items[(index + 1) % items.length]
if (event.key === "ArrowUp") next = items[(index - 1 + items.length) % items.length]
if (event.key === "Home") next = items[0]
if (event.key === "End") next = items.at(-1)
if (next) { event.preventDefault(); next.focus() }
},
},
mounted() {
window.addEventListener("resize", this.position)
window.visualViewport?.addEventListener("resize", this.position)
},
beforeUnmount() {
this.$refs.menu?.close()
window.removeEventListener("resize", this.position)
window.visualViewport?.removeEventListener("resize", this.position)
},
template: `
<div class="gx-history-picker" style="min-width:0;">
<span class="gx-select gx-field gx-field--full">
<button ref="button" :id="$attrs.id || uid" class="gx-select__button" type="button" aria-haspopup="dialog" :aria-expanded="open" :aria-controls="uid + '-dialog'"
:aria-label="'Earlier version: ' + label" :disabled="disabled" @click="show" @keydown="buttonKey">
<span style="white-space:normal; overflow-wrap:anywhere;">{{ label }}</span><i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
</span>
<p class="gx-note" style="margin:6px 0 0;">{{ range }}. {{ hasMore ? (releaseBranch ? 'Open the picker to load older versions.' : 'Open the picker to load older days.') : (commits.length && !error ? 'All available history loaded.' : 'Open the picker to browse history.') }}</p>
<p v-if="notice" class="gx-note" role="status" style="margin:6px 0 0;">{{ notice }}</p>
<dialog ref="menu" :id="uid + '-dialog'" class="gx-select-menu gx-history-menu" :aria-labelledby="uid + '-title'" :aria-describedby="uid + '-help'"
style="padding:0; overflow:hidden;" @cancel.prevent="close" @click="event => { if (event.target === $refs.menu) close() }" @keydown="menuKey">
<div style="height:100%; display:flex; flex-direction:column; min-height:0;">
<div style="padding:14px 16px; border-bottom:1px solid var(--outline-variant);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<strong :id="uid + '-title'">{{ releaseBranch ? 'Earlier StarPilot versions' : 'Earlier versions by day' }}</strong>
<button ref="close" type="button" class="gx-btn gx-btn--tonal" aria-label="Close version history" style="min-width:44px; padding:8px;" @click="close">✕</button>
</div>
<p class="gx-note" style="margin:6px 0;" aria-live="polite">{{ range }}</p>
<p v-if="notice" class="gx-note" role="status" style="margin:6px 0;">{{ notice }}</p>
<p :id="uid + '-help'" class="gx-note" style="margin:0;">{{ releaseBranch ? 'Each version uses its newest build. Build dates are shown in your local time.' : 'Expand a day to choose a build. Build dates are shown in your local time.' }}</p>
</div>
<div ref="scroll" style="overflow-y:auto; overscroll-behavior:contain; min-height:0; flex:1; padding:6px;" :aria-busy="loading">
<div v-if="releaseBranch" role="listbox" aria-label="StarPilot releases">
<button v-for="commit in choices" :key="commit.sha" type="button" role="option" :data-value="commit.sha" :aria-selected="commit.sha === value" :disabled="disabled" @click="select(commit.sha)">
<span style="min-width:0; overflow-wrap:anywhere;">{{ versionTitle(commit, true) }}</span>
<i v-if="commit.sha === value" class="bi bi-check-lg" aria-hidden="true"></i>
</button>
</div>
<template v-else>
<section v-for="group in groups" :key="group.key" :data-day-group="group.key" style="border-bottom:1px solid var(--outline-variant);">
<button type="button" class="gx-btn gx-btn--tonal" :data-day="group.key" :aria-expanded="expanded[group.key]" :aria-controls="uid + '-' + group.key"
style="width:100%; display:flex; justify-content:space-between; gap:12px; text-align:left; padding:12px; border-radius:8px; margin:2px 0;"
@click="expanded[group.key] = !expanded[group.key]">
<span>{{ group.label }} <small style="opacity:.7;">· {{ group.commits.length }} {{ group.commits.length === 1 ? 'build' : 'builds' }}</small></span>
<i :class="expanded[group.key] ? 'bi bi-chevron-up' : 'bi bi-chevron-down'" aria-hidden="true"></i>
</button>
<div v-if="expanded[group.key]" :id="uid + '-' + group.key" role="listbox" :aria-label="group.label + ' versions'">
<button v-for="commit in group.commits" :key="commit.sha" type="button" role="option" :data-value="commit.sha" :aria-selected="commit.sha === value" :disabled="disabled" @click="select(commit.sha)">
<span style="min-width:0; overflow-wrap:anywhere;">
<span>{{ versionTitle(commit, releaseBranch) }}</span>
</span><i v-if="commit.sha === value" class="bi bi-check-lg" aria-hidden="true"></i>
</button>
</div>
</section>
</template>
</div>
<div style="padding:12px 16px; border-top:1px solid var(--outline-variant); flex-shrink:0;">
<p v-if="error" class="gx-note gx-note--danger" role="alert" style="overflow-wrap:anywhere;">{{ error }}</p>
<p v-if="loading" class="gx-note" role="status">Loading older history…</p>
<button v-if="hasMore || error" type="button" class="gx-btn gx-btn--tonal" :aria-disabled="loading || disabled" style="width:100%; white-space:normal;" @click="loadMore">{{ error ? 'Retry history' : (releaseBranch ? 'Load older versions' : 'Load older days') }}</button>
<p v-else-if="!loading" class="gx-note" role="status">{{ commits.length ? 'All available history loaded.' : 'No history available.' }}</p>
<p v-if="hasMore && !error" class="gx-note" style="margin-bottom:0;">Keep loading to browse further back.</p>
</div>
</div>
</dialog>
</div>
`,
}
@@ -2,6 +2,8 @@ import { api, showSnackbar } from "../api.js"
import { usePolling } from "../composables.js"
import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { GalaxySection } from "../components/GalaxySection.js"
import { GalaxySelect } from "../components/GalaxySelect.js"
import { VersionHistoryPicker, versionTitle, releaseVersions } from "../components/VersionHistoryPicker.js"
import { GxNotice } from "../components/GxNotice.js"
function shortCommit(commit) {
@@ -16,12 +18,26 @@ function toPercent(value) {
export const SystemTools = {
name: "SystemTools",
components: { GalaxySection, GxNotice },
components: { GalaxySection, GxNotice, GalaxySelect, VersionHistoryPicker },
data() {
return {
branches: [],
currentBranch: "",
targetBranch: "",
versionMode: "latest",
selectedCommit: "",
versionCommits: [],
versionHead: "",
versionPage: 0,
versionHasMore: false,
versionLoading: false,
versionError: "",
versionNotice: "",
versionGeneration: 0,
branchLoading: true,
otherBranchesOpen: false,
branchBusy: false,
isOnroad: false,
fastStatus: null,
checkedForUpdates: false,
@@ -42,8 +58,27 @@ export const SystemTools = {
this.poll.start()
},
mounted() { this.loadBranches(); this.loadProfiles(); this.loadTailscale() },
beforeUnmount() { this.poll?.destroy() },
beforeUnmount() { this.poll?.destroy(); this.resetVersions() },
computed: {
primaryBranchValue() {
if (this.otherBranchesOpen) return "other:"
return ["StarPilot", "Dom"].includes(this.targetBranch) ? this.targetBranch : ""
},
otherBranches() {
const branches = this.branches.filter(branch => !["StarPilot", "Dom"].includes(branch))
if (this.currentBranch && !["StarPilot", "Dom"].includes(this.currentBranch) && !branches.includes(this.currentBranch)) {
branches.unshift(this.currentBranch)
}
return branches
},
branchSwitchBlocked() {
return this.branchLoading || this.isOnroad || !!this.fastStatus?.isOnroad || !!this.fastStatus?.running || !!this.busy
},
versionChoices() { return this.targetBranch === "StarPilot" ? releaseVersions(this.versionCommits) : this.versionCommits },
installVersionBlocked() {
return this.branchSwitchBlocked || this.branchBusy || !this.branches.includes(this.targetBranch) ||
(this.versionMode === "earlier" && (this.versionLoading || !/^[a-f0-9]{40}$/.test(this.selectedCommit) || !this.versionChoices.some(commit => commit.sha === this.selectedCommit)))
},
updateAvailable() { return this.checkedForUpdates && !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
factoryResetStatus() {
const s = this.fastStatus
@@ -69,6 +104,10 @@ export const SystemTools = {
const data = await api.getUpdateBranches()
this.branches = Array.isArray(data?.branches) ? data.branches : []
this.currentBranch = data?.currentBranch || ""
if (!this.targetBranch) {
this.targetBranch = this.currentBranch
this.otherBranchesOpen = !!this.targetBranch && !["StarPilot", "Dom"].includes(this.targetBranch)
}
this.isOnroad = !!data?.isOnroad
} catch (e) {
showSnackbar("Failed to load update info.", "error")
@@ -168,20 +207,123 @@ export const SystemTools = {
showSnackbar("Reset failed.", "error")
}
},
onBranchSelect(e) {
onPrimaryBranchSelect(e) {
const branch = e.target.value
e.target.value = this.currentBranch || ""
this.switchBranch(branch)
if (this.branchSwitchBlocked || this.branchBusy) return
this.otherBranchesOpen = branch === "other:"
if (this.otherBranchesOpen) {
// Other is navigation, never an install target.
this.targetBranch = ""
this.resetVersions()
} else this.selectTargetBranch(branch)
},
async switchBranch(branch) {
if (!branch || branch === this.currentBranch) return
if (!(await GalaxyConfirm({ title: "Switch branch?", message: `Switch to ${branch} and update?`, confirmLabel: "Switch" }))) return
onBranchSelect(e) { this.selectTargetBranch(e.target.value) },
selectTargetBranch(branch) {
if (!branch || !this.branches.includes(branch) || this.branchBusy || this.branchSwitchBlocked) return
if (branch === this.targetBranch) return
this.targetBranch = branch
this.otherBranchesOpen = !["StarPilot", "Dom"].includes(branch)
this.resetVersions()
},
resetVersions() {
this.versionAbort?.abort()
this.versionAbort = null
this.versionGeneration++
this.versionMode = "latest"
this.selectedCommit = ""
this.versionCommits = []
this.versionHead = ""
this.versionPage = 0
this.versionHasMore = false
this.versionLoading = false
this.versionError = ""
this.versionNotice = ""
},
async onVersionModeSelect(e) {
if (this.branchSwitchBlocked || this.branchBusy) return
const mode = e.target.value
if (!["latest", "earlier"].includes(mode)) return
this.resetVersions()
this.versionMode = mode
if (mode === "earlier") await this.loadVersions()
},
async loadVersions(more = false) {
if (!this.targetBranch || !this.branches.includes(this.targetBranch) || this.versionLoading || this.versionMode !== "earlier" || (more && !this.versionHasMore)) return
const branch = this.targetBranch
const generation = this.versionGeneration
let page = more ? this.versionPage + 1 : 1
let head = more ? this.versionHead : ""
const controller = new AbortController()
this.versionAbort = controller
this.versionLoading = true
this.versionError = ""
const originalCount = this.versionChoices.length
const budget = more && branch === "StarPilot" ? 4 : 1
try {
await api.setUpdateBranch(branch)
showSnackbar(`Switching to ${branch}...`)
for (let scanned = 0; scanned < budget; scanned++) {
const data = await api.getUpdateVersions(branch, {page, head, signal: controller.signal})
if (generation !== this.versionGeneration || controller.signal.aborted) return
if (data?.branch !== branch || data?.page !== page || !/^[a-f0-9]{40}$/.test(data?.head || "") || (head && data.head !== head) || !Array.isArray(data?.commits)) throw new Error("Version history changed. Choose Latest and try again.")
const commits = data.commits.filter(commit => /^[a-f0-9]{40}$/.test(commit?.sha || ""))
this.versionCommits = page > 1 ? [...this.versionCommits, ...commits.filter(commit => !this.versionCommits.some(existing => existing.sha === commit.sha))] : commits
if (data.cached) {
const saved = new Date(data.cachedAt)
const when = Number.isFinite(saved.getTime()) ? " from " + saved.toLocaleString() : ""
this.versionNotice = "Showing saved history" + when + ". Installation still needs an online check."
} else if (page === 1) this.versionNotice = ""
this.versionHead = data.head
this.versionPage = page
this.versionHasMore = !!data.hasMore && commits.length > 0
if (!this.versionCommits.length) this.versionError = "No versions are available for this branch. Choose Latest or another branch."
if (!this.versionHasMore || this.versionChoices.length > originalCount) break
page++
head = this.versionHead
}
} catch (e) {
if (generation !== this.versionGeneration || controller.signal.aborted) return
this.versionError = e?.message || "Failed to load version history. Try again."
} finally {
if (generation === this.versionGeneration) {
this.versionLoading = false
this.versionAbort = null
}
}
},
versionDate(date) {
const value = new Date(date)
return Number.isNaN(value.getTime()) ? "Date unavailable" : value.toLocaleDateString(undefined, {year: "numeric", month: "short", day: "numeric"})
},
async returnToLatest() {
const branch = this.fastStatus?.versionPin?.branch
if (this.branchSwitchBlocked || this.branchBusy || !this.branches.includes(branch)) return
this.selectTargetBranch(branch)
this.resetVersions()
await this.installSelectedVersion()
},
async installSelectedVersion() {
if (this.installVersionBlocked) return
const branch = this.targetBranch
const commit = this.versionMode === "latest" ? "latest" : this.selectedCommit
const generation = this.versionGeneration
this.branchBusy = true
try {
const selected = this.versionCommits.find(item => item.sha === commit)
const version = commit === "latest" ? "Latest" : `${versionTitle(selected, branch === "StarPilot")}\nCommit: ${commit}`
const policy = commit === "latest" ? "Automatic updates will remain off after installation. You can enable them in settings." : "Automatic updates will be paused for this earlier version."
if (!(await GalaxyConfirm({title: "Install selected version?", message: `Branch: ${branch}\nVersion: ${version}\n\n${policy}\n\nThis replaces the current software. Settings and statistics are kept, and local code changes are backed up. Older versions may remove this picker; an SSH recovery copy is saved on the device.\n\nYour device will reboot when installation finishes.`, confirmLabel: "Install & Reboot", danger: true}))) return
// Refresh driving/updater state after the user has reviewed the target.
await this.loadFastStatus({throwOnError: true})
if (this.branchSwitchBlocked || this.targetBranch !== branch || generation !== this.versionGeneration) {
showSnackbar("Installation is unavailable while driving or updating, or the selection has changed.", "error")
return
}
const result = await api.installUpdateVersion(branch, commit)
showSnackbar(result?.message || `Installing ${version} on ${branch}...`)
await this.loadFastStatus()
} catch (e) {
showSnackbar(e?.message || "Switch failed.", "error")
showSnackbar(e?.message || "Installation failed.", "error")
} finally {
this.branchBusy = false
}
},
async checkUpdates() {
@@ -342,7 +484,7 @@ export const SystemTools = {
<span v-else class="gx-chip">Not checked</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:6px;">
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '—' }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Installed branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '—' }}</span></div>
<div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
@@ -383,12 +525,48 @@ export const SystemTools = {
</div>
<div class="gx-card" style="margin-bottom:12px;">
<div class="gx-section__header"><i class="bi bi-git-branch"></i><span class="gx-section__title">Switch Branch</span></div>
<div class="gx-section__header"><i class="bi bi-git-branch"></i><span class="gx-section__title">Install a Version</span></div>
<div style="padding: var(--sp-3);">
<select class="gx-field gx-field--full" :disabled="!!isOnroad" @change="onBranchSelect">
<option v-if="!branches.length" value="">No branches available</option>
<option v-for="b in branches" :key="b" :value="b" :selected="b === currentBranch">{{ b === currentBranch ? b + ' (current)' : b }}</option>
</select>
<p class="gx-note" style="margin-top:0; overflow-wrap:anywhere;">Installed branch: <strong>{{ currentBranch || 'Unknown' }}</strong></p>
<GalaxySelect id="gx-primary-branch" class="gx-field gx-field--full" aria-label="Target branch"
:value="primaryBranchValue" :disabled="branchSwitchBlocked || branchBusy" @change="onPrimaryBranchSelect">
<option value="" disabled>Select a branch</option>
<option value="StarPilot" data-collapsed-label="StarPilot" data-description="Stable releases. Recommended for most users." :disabled="!branches.includes('StarPilot')">StarPilot — Release</option>
<option value="Dom" data-collapsed-label="Dom" data-description="Latest features and fixes under development. Updates regularly and may introduce bugs." :disabled="!branches.includes('Dom')">Dom — Development</option>
<option value="other:">Other branches…</option>
</GalaxySelect>
<div v-if="otherBranchesOpen" style="margin-top:var(--sp-3); padding-left:var(--sp-3); border-left:2px solid var(--outline-variant);">
<label for="gx-other-branch" class="gx-row__label">Other branches</label>
<p class="gx-note">Additional branches from this installation's repository.</p>
<GalaxySelect id="gx-other-branch" class="gx-field gx-field--full" aria-label="Other branches"
:value="otherBranches.includes(targetBranch) ? targetBranch : ''" :disabled="branchSwitchBlocked || branchBusy" @change="onBranchSelect">
<option value="" disabled>{{ otherBranches.length ? 'Select another branch' : 'No other branches available' }}</option>
<option v-for="b in otherBranches" :key="b" :value="b">{{ b === currentBranch ? b + ' (current)' : b }}</option>
</GalaxySelect>
</div>
<p v-if="!branchLoading && !branches.length" class="gx-note">No branch list available. Reload when connected to check available branches.</p>
<div v-if="targetBranch" style="margin-top:var(--sp-3); display:grid; gap:8px; min-width:0;">
<label for="gx-version-mode" class="gx-row__label">Version</label>
<GalaxySelect id="gx-version-mode" class="gx-field gx-field--full" aria-label="Version" :value="versionMode"
:disabled="branchSwitchBlocked || branchBusy || !branches.includes(targetBranch)" @change="onVersionModeSelect">
<option value="latest">Latest</option>
<option value="earlier">Choose earlier…</option>
</GalaxySelect>
<template v-if="versionMode === 'earlier'">
<VersionHistoryPicker id="gx-version-commit" :value="selectedCommit" :commits="versionCommits"
:release-branch="targetBranch === 'StarPilot'" :loading="versionLoading" :has-more="versionHasMore" :error="versionError" :notice="versionNotice"
:disabled="branchSwitchBlocked || branchBusy" @change="selectedCommit = $event.target.value"
@loadmore="loadVersions(versionCommits.length > 0 && versionHasMore)" />
</template>
<p v-if="!branches.includes(targetBranch)" class="gx-note">This installed branch is no longer listed by the repository. Select an available target branch to install a version.</p>
<button type="button" class="gx-btn" :disabled="installVersionBlocked" @click="installSelectedVersion">
<i v-if="branchBusy" class="bi bi-arrow-repeat gx-spin"></i>{{ branchBusy ? 'Starting installation…' : 'Install selected version' }}
</button>
</div>
<div v-if="fastStatus?.versionPin" class="gx-note" style="margin-top:var(--sp-3); overflow-wrap:anywhere;">
<p>Pinned version: <strong>{{ fastStatus.versionPin.branch }} · {{ shortCommit(fastStatus.versionPin.commit) }}</strong><br>Automatic updates were paused at installation.</p>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="branchSwitchBlocked || branchBusy || !branches.includes(fastStatus.versionPin.branch)" @click="returnToLatest">Return to Latest</button>
</div>
</div>
</div>
@@ -0,0 +1,267 @@
import fs from 'node:fs'
import vm from 'node:vm'
import assert from 'node:assert/strict'
import test from 'node:test'
const root = process.argv[2] || new URL('../../../../', import.meta.url).pathname
const js = root + '/starpilot/system/the_galaxy/assets/mobile/js/'
const source = fs.readFileSync(js + 'views/SystemTools.js', 'utf8')
const SHA = 'a'.repeat(40), HEAD = 'b'.repeat(40)
function fixture() {
const calls = [], confirmations = [], messages = [], reads = []
let confirm = async () => true
const api = {
setUpdateBranch: async branch => calls.push({legacy: branch}),
installUpdateVersion: async (branch, commit) => calls.push({branch, commit}),
getUpdateFastStatus: async () => ({running: false, isOnroad: false, versionPin: null}),
getUpdateVersions: async (branch, options) => {reads.push({branch, ...options}); return {branch, head: HEAD, page: options.page, hasMore: options.page === 1, commits: [{sha: options.page === 1 ? SHA : 'c'.repeat(40), date: '2026-09-10T12:00:00Z', subject: 'Fix launch'}]}},
}
const context = vm.createContext({api, AbortController, showSnackbar: (...args) => messages.push(args), GalaxySection: {}, GalaxySelect: {}, GxNotice: {}, GalaxyConfirm: async options => {confirmations.push(options); return confirm()}})
vm.runInContext(fs.readFileSync(js + 'components/VersionHistoryPicker.js', 'utf8').replace(/export /g, ''), context)
vm.runInContext(source.replace(/^import .*$/gm, '').replace('export const SystemTools =', 'globalThis.component ='), context)
const component = context.component
const instance = {...component.data(), ...component.methods, branches: ['Dom', 'SunnyPilot', 'StarPilot', 'feature/test'], currentBranch: 'feature/local', branchLoading: false}
for (const [key, getter] of Object.entries(component.computed)) Object.defineProperty(instance, key, {get: () => getter.call(instance)})
return {instance, calls, confirmations, messages, reads, api, setConfirm: fn => {confirm = fn}}
}
test('primary and Other choices stage a target without installing or confirming', async () => {
const {instance, calls, confirmations} = fixture()
await instance.onPrimaryBranchSelect({target: {value: 'Dom'}})
assert.equal(instance.targetBranch, 'Dom')
await instance.onPrimaryBranchSelect({target: {value: 'other:'}})
assert.equal(instance.otherBranchesOpen, true)
await instance.onBranchSelect({target: {value: 'SunnyPilot'}})
assert.equal(instance.targetBranch, 'SunnyPilot')
assert.equal(instance.currentBranch, 'feature/local')
assert.deepEqual(calls, [])
assert.deepEqual(confirmations, [])
})
test('current custom branch remains visible when absent from remote', () => {
const {instance} = fixture()
assert.deepEqual(Array.from(instance.otherBranches), ['feature/local', 'SunnyPilot', 'feature/test'])
})
test('every remote branch can install latest only through explicit confirmation', async () => {
for (const branch of ['StarPilot', 'Dom', 'SunnyPilot', 'feature/test']) {
const {instance, calls, confirmations} = fixture()
instance.selectTargetBranch(branch)
await instance.installSelectedVersion()
assert.deepEqual(calls, [{branch, commit: 'latest'}])
assert.equal(confirmations.length, 1)
assert.ok(confirmations[0].message.includes(branch))
assert.ok(confirmations[0].message.includes('Latest'))
}
})
test('historical install requires a loaded full SHA and cancellation writes nothing', async () => {
const {instance, calls, confirmations, setConfirm} = fixture()
instance.selectTargetBranch('Dom')
await instance.onVersionModeSelect({target: {value: 'earlier'}})
instance.selectedCommit = 'deadbeef'
await instance.installSelectedVersion()
assert.deepEqual(confirmations, [])
instance.selectedCommit = SHA
setConfirm(async () => false)
await instance.installSelectedVersion()
assert.deepEqual(calls, [])
setConfirm(async () => true)
await instance.installSelectedVersion()
assert.deepEqual(calls, [{branch: 'Dom', commit: SHA}])
assert.ok(confirmations.at(-1).message.includes(SHA))
})
test('pagination uses the original branch head and changing branch resets to Latest', async () => {
const {instance, reads} = fixture()
instance.selectTargetBranch('Dom')
await instance.onVersionModeSelect({target: {value: 'earlier'}})
instance.selectedCommit = SHA
await instance.loadVersions(true)
assert.equal(reads[1].page, 2)
assert.equal(reads[1].head, HEAD)
assert.equal(instance.versionCommits.length, 2)
instance.selectTargetBranch('feature/test')
assert.equal(instance.versionMode, 'latest')
assert.equal(instance.selectedCommit, '')
assert.equal(instance.versionCommits.length, 0)
assert.equal(instance.versionHead, '')
})
test('late history response after branch change cannot repopulate history', async () => {
const {instance, api} = fixture()
let resolve, signal
api.getUpdateVersions = (branch, options) => {signal = options.signal; return new Promise(done => {resolve = done})}
instance.selectTargetBranch('Dom')
const pending = instance.onVersionModeSelect({target: {value: 'earlier'}})
instance.selectTargetBranch('StarPilot')
assert.equal(signal.aborted, true)
resolve({branch: 'Dom', head: HEAD, page: 1, hasMore: false, commits: [{sha: SHA}]})
await pending
assert.equal(instance.versionCommits.length, 0)
assert.equal(instance.targetBranch, 'StarPilot')
assert.equal(instance.versionLoading, false)
})
test('failed or missing history explains the issue and never enables historical install', async () => {
const {instance, api} = fixture()
instance.selectTargetBranch('Dom')
api.getUpdateVersions = async () => {throw new Error('Branch no longer available')}
await instance.onVersionModeSelect({target: {value: 'earlier'}})
assert.equal(instance.versionError, 'Branch no longer available')
assert.equal(instance.installVersionBlocked, true)
api.getUpdateVersions = async () => ({branch: 'Dom', head: HEAD, page: 1, hasMore: false, commits: []})
await instance.loadVersions()
assert.match(instance.versionError, /No versions/)
})
test('driving, running, or busy state blocks install and is rechecked after confirmation', async () => {
for (const state of [{isOnroad: true}, {fastStatus: {running: true}}, {busy: 'check'}]) {
const {instance, calls, confirmations} = fixture()
instance.selectTargetBranch('Dom')
Object.assign(instance, state)
await instance.installSelectedVersion()
assert.deepEqual(calls, [])
assert.deepEqual(confirmations, [])
}
const {instance, calls, api, setConfirm} = fixture()
instance.selectTargetBranch('Dom')
setConfirm(async () => {api.getUpdateFastStatus = async () => ({isOnroad: true}); return true})
await instance.installSelectedVersion()
assert.deepEqual(calls, [])
})
test('concurrent install confirmation and failed status refresh cannot submit', async () => {
const {instance, calls, confirmations, api, setConfirm} = fixture()
instance.selectTargetBranch('Dom')
let release
setConfirm(() => new Promise(resolve => {release = resolve}))
const pending = instance.installSelectedVersion()
await instance.installSelectedVersion()
assert.equal(confirmations.length, 1)
api.getUpdateFastStatus = async () => {throw new Error('offline')}
release(true)
await pending
assert.deepEqual(calls, [])
})
test('return to Latest uses pinned branch and confirms without changing automatic update settings', async () => {
const {instance, calls, confirmations} = fixture()
instance.fastStatus = {versionPin: {branch: 'feature/test', commit: SHA, installedAt: '2026-09-10'}}
await instance.returnToLatest()
assert.deepEqual(calls, [{branch: 'feature/test', commit: 'latest'}])
assert.ok(confirmations[0].message.includes('remain off'))
})
test('API preserves branch encoding, pagination head, abort signal and confirmed install body', async () => {
const calls = []
const context = vm.createContext({URLSearchParams, fetch: async (url, init) => {calls.push({url, init}); return {ok: true, json: async () => ({})}}})
vm.runInContext(fs.readFileSync(js + 'api.js', 'utf8').replace(/export /g, '') + '\nglobalThis.client = api', context)
const signal = new AbortController().signal
await context.client.getUpdateVersions('feature/a&b', {page: 2, head: HEAD, signal})
const url = new URL(calls[0].url, 'https://example.test')
assert.equal(url.searchParams.get('branch'), 'feature/a&b')
assert.equal(url.searchParams.get('page'), '2')
assert.equal(url.searchParams.get('head'), HEAD)
assert.equal(calls[0].init.signal, signal)
await context.client.installUpdateVersion('feature/a&b', SHA)
assert.equal(calls[1].url, '/api/update/version')
assert.equal(calls[1].init.method, 'POST')
assert.deepEqual(JSON.parse(calls[1].init.body), {branch: 'feature/a&b', commit: SHA, confirmed: true})
})
test('GalaxySelect reads optional option descriptions without adding them to collapsed labels', () => {
const context = vm.createContext({document: {getElementById() {}}})
vm.runInContext(fs.readFileSync(js + 'components/GalaxySelect.js', 'utf8').replace('export const GalaxySelect =', 'globalThis.component ='), context)
const native = {value: 'Dom', options: [{value: 'Dom', label: 'Dom', dataset: {description: 'Development description'}}, {value: 'StarPilot', label: 'StarPilot'}], selectedOptions: [{label: 'Dom'}]}
const instance = {...context.component.data(), $refs: {native}, $attrs: {}, current: 'Dom'}
context.component.methods.sync.call(instance)
assert.equal(instance.items[0].description, 'Development description')
assert.equal(instance.items[1].description, '')
assert.equal(instance.label, 'Dom')
})
test('unavailable branch and navigation values cannot become install targets', async () => {
const {instance, calls, confirmations} = fixture()
for (const branch of ['other:', '', 'deleted-branch']) instance.selectTargetBranch(branch)
await instance.installSelectedVersion()
assert.equal(instance.targetBranch, '')
assert.deepEqual(calls, [])
assert.deepEqual(confirmations, [])
})
test('mismatched paginated history cannot mix another branch head into the selected version', async () => {
const {instance, api} = fixture()
instance.selectTargetBranch('Dom')
await instance.onVersionModeSelect({target: {value: 'earlier'}})
api.getUpdateVersions = async () => ({branch: 'Dom', head: 'd'.repeat(40), page: 2, hasMore: false, commits: [{sha: 'e'.repeat(40)}]})
await instance.loadVersions(true)
assert.equal(instance.versionCommits.length, 1)
assert.equal(instance.versionHead, HEAD)
assert.match(instance.versionError, /history changed/)
})
test('switching back to Latest invalidates history and selected revision', async () => {
const {instance, api} = fixture()
let reject
api.getUpdateVersions = async () => new Promise((resolve, fail) => {reject = fail})
instance.selectTargetBranch('Dom')
const pending = instance.onVersionModeSelect({target: {value: 'earlier'}})
await instance.onVersionModeSelect({target: {value: 'latest'}})
reject(new Error('Old network failure'))
await pending
assert.equal(instance.versionMode, 'latest')
assert.equal(instance.versionError, '')
assert.equal(instance.versionLoading, false)
assert.equal(instance.installVersionBlocked, false)
})
test('release confirmation shows a friendly version and the exact installation SHA', async () => {
const {instance, calls, confirmations} = fixture()
instance.selectTargetBranch('StarPilot')
await instance.onVersionModeSelect({target: {value: 'earlier'}})
instance.versionCommits[0].version = '6.7.7'
instance.selectedCommit = SHA
await instance.installSelectedVersion()
assert.match(confirmations[0].message, /Version: 6\.7\.7/)
assert.ok(confirmations[0].message.includes('Commit: ' + SHA))
assert.deepEqual(calls, [{branch: 'StarPilot', commit: SHA}])
})
test('saved history is labelled and retained across pagination, then reset on branch change', async () => {
const {instance,api,calls} = fixture()
const normal=api.getUpdateVersions
api.getUpdateVersions=async (...args)=>({...await normal(...args), cached:true, cachedAt:'2026-09-11T12:00:00Z'})
instance.selectTargetBranch('Dom')
await instance.onVersionModeSelect({target:{value:'earlier'}})
assert.match(instance.versionNotice,/saved history/i)
assert.match(instance.versionNotice,/online check/i)
api.getUpdateVersions=normal
await instance.loadVersions(true)
assert.match(instance.versionNotice,/saved history/i)
assert.deepEqual(calls,[])
instance.selectTargetBranch('StarPilot')
assert.equal(instance.versionNotice,'')
})
test('fresh history does not show a saved-history notice', async () => {
const {instance} = fixture()
instance.selectTargetBranch('Dom')
await instance.onVersionModeSelect({target:{value:'earlier'}})
assert.equal(instance.versionNotice,'')
})
test('release pagination searches duplicate pages for the next version and installs only its newest build', async () => {
const {instance,api,calls}=fixture(), pages=[]
api.getUpdateVersions=async(branch,{page})=>{pages.push(page);return {branch,head:HEAD,page,hasMore:page<5,commits:[{sha:String(page).repeat(40),version:page<4?'6.7.7':'6.7.6',date:'2026-09-11T12:00:00Z',subject:'Build'}]}}
instance.selectTargetBranch('StarPilot');await instance.onVersionModeSelect({target:{value:'earlier'}})
await instance.loadVersions(true)
assert.deepEqual(pages,[1,2,3,4])
assert.deepEqual(Array.from(instance.versionChoices,c=>c.sha),['1'.repeat(40),'4'.repeat(40)])
instance.selectedCommit='2'.repeat(40);assert.equal(instance.installVersionBlocked,true)
instance.selectedCommit='4'.repeat(40);await instance.installSelectedVersion()
assert.deepEqual(calls,[{branch:'StarPilot',commit:'4'.repeat(40)}])
})
test('a release search has a bounded request budget and preserves progress when older history is unavailable', async () => {
const {instance,api}=fixture();let requests=0
api.getUpdateVersions=async(branch,{page})=>{requests++;return {branch,head:HEAD,page,hasMore:true,commits:[{sha:page.toString(16).repeat(40),version:'6.7.7',date:'2026-09-11T12:00:00Z',subject:'Build'}]}}
instance.selectTargetBranch('StarPilot');await instance.onVersionModeSelect({target:{value:'earlier'}})
await instance.loadVersions(true);assert.equal(requests,5);assert.equal(instance.versionPage,5)
api.getUpdateVersions=async()=>{throw Error('GitHub rate limit')}
await instance.loadVersions(true)
assert.equal(instance.versionPage,5);assert.equal(instance.versionChoices.length,1);assert.match(instance.versionError,/rate limit/)
})
test('changing branch during a multi-page release search cancels remaining pages and rejects stale results', async () => {
const {instance,api}=fixture();let finish,requests=0
api.getUpdateVersions=async(branch,{page})=>{requests++;return {branch,head:HEAD,page,hasMore:true,commits:[{sha:'1'.repeat(40),version:'6.7.7',date:'2026-09-11T12:00:00Z',subject:'Build'}]}}
instance.selectTargetBranch('StarPilot');await instance.onVersionModeSelect({target:{value:'earlier'}})
api.getUpdateVersions=(branch,{page})=>{requests++;return new Promise(resolve=>{finish=()=>resolve({branch,head:HEAD,page,hasMore:true,commits:[{sha:'2'.repeat(40),version:'6.7.7',date:'2026-09-11T12:00:00Z',subject:'Build'}]})})}
const pending=instance.loadVersions(true)
instance.selectTargetBranch('Dom');finish();await pending
assert.equal(requests,2);assert.equal(instance.versionCommits.length,0);assert.equal(instance.versionLoading,false)
})
@@ -0,0 +1,794 @@
import importlib.util
import io
from http.client import IncompleteRead
import subprocess
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, unquote, urlparse
from urllib.request import Request
import pytest
MODULE = Path(__file__).resolve().parents[1] / 'version_history.py'
@pytest.fixture
def history(tmp_path, monkeypatch):
assert MODULE.exists(), 'The branch history implementation must exist'
spec = importlib.util.spec_from_file_location('version_history_under_test', MODULE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(module, "SNAPSHOT_CACHE_DIR", tmp_path / "history-cache", raising=False)
return module
@pytest.fixture
def repo(tmp_path):
subprocess.run(['git', 'init', '-q', str(tmp_path)], check=True)
subprocess.run(['git', '-C', str(tmp_path), 'remote', 'add', 'origin', 'https://github.com/firestar5683/openpilot.git'], check=True)
return tmp_path
def commit(n):
return {'sha': f'{n:040x}', 'commit': {'message': f'Change {n}\n\nDetails', 'committer': {'date': '2026-09-11T12:00:00Z'}}}
class GitHub:
def __init__(self):
self.heads = {'main': 120, 'feature/slash': 60}
self.requests = []
self.invalid_compare = None
def __call__(self, url):
self.requests.append(url)
parsed = urlparse(url)
assert parsed.scheme == 'https' and parsed.netloc == 'api.github.com'
assert parsed.path.startswith('/repos/firestar5683/openpilot/')
path = parsed.path.split('/openpilot/')[1]
if path.startswith('branches/'):
branch = unquote(path[len('branches/'):])
return {'name': branch, 'commit': {'sha': f'{self.heads[branch]:040x}'}}
if path.startswith('compare/'):
base, head = (int(part, 16) for part in path[len('compare/'):].split('...'))
return self.invalid_compare or {'status': 'identical' if base == head else ('ahead' if base < head else 'behind'),
'merge_base_commit': {'sha': f'{min(base, head):040x}'}}
assert path == 'commits'
query = parse_qs(parsed.query)
head, page, size = int(query['sha'][0], 16), int(query['page'][0]), int(query['per_page'][0])
start = head - (page - 1) * size
return [commit(n) for n in range(start, max(0, start - size), -1)]
@pytest.fixture
def api(history, monkeypatch):
fake = GitHub()
monkeypatch.setattr(history, '_get_json', fake)
return fake
def test_lists_25_commits_and_keeps_page_head_when_branch_advances(history, repo, api):
first = history.list_versions(repo, 'main')
assert first == {'branch': 'main', 'head': f'{120:040x}', 'page': 1, 'hasMore': True,
'commits': [{'sha': f'{n:040x}', 'subject': f'Change {n}', 'date': '2026-09-11T12:00:00Z'} for n in range(120, 95, -1)]}
api.heads['main'] = 121
second = history.list_versions(repo, 'main', page=2, head=first['head'])
assert second['head'] == first['head']
assert second['commits'][0]['sha'] == f'{95:040x}'
last = history.list_versions(repo, 'main', page=5, head=first['head'])
assert len(last['commits']) == 20 and last['hasMore'] is False
def test_slash_branch_and_switch_do_not_reuse_other_branch_head(history, repo, api):
history.list_versions(repo, 'main')
slash = history.list_versions(repo, 'feature/slash')
assert slash['branch'] == 'feature/slash' and slash['head'] == f'{60:040x}'
with pytest.raises(history.VersionHistoryError, match='branch'):
history.list_versions(repo, 'feature/slash', page=2, head=f'{120:040x}')
@pytest.mark.parametrize('branch', ['', '-main', '../main', 'main~1', 'main..old', 'a b', '@{-1}', 'a\nb', None])
def test_invalid_branches_never_reach_network(history, repo, api, branch):
with pytest.raises(history.VersionHistoryError, match='branch'):
history.list_versions(repo, branch)
assert not api.requests
@pytest.mark.parametrize('value', ['abcd', 'g' * 40, 'a' * 41, 'HEAD', '--help', None])
def test_invalid_exact_commit_is_rejected(history, repo, api, value):
with pytest.raises(history.VersionHistoryError, match='commit'):
history.resolve_version(repo, 'main', value)
assert not api.requests
@pytest.mark.parametrize('url', ['git@github.com:firestar5683/openpilot.git', 'ssh://git@github.com/firestar5683/openpilot.git', 'https://github.com/firestar5683/openpilot'])
def test_github_remote_formats(history, repo, api, url):
subprocess.run(['git', '-C', str(repo), 'remote', 'set-url', 'origin', url], check=True)
assert history.resolve_version(repo, 'main', 'latest')['commit'] == f'{120:040x}'
@pytest.mark.parametrize('url', ['https://gitlab.com/owner/repo.git', 'https://github.com.evil.test/owner/repo', '/local/repo', 'https://user:secret@github.com/owner/repo', 'https://github.com/owner/repo?x=1', 'https://github.com/owner/repo/extra'])
def test_rejects_untrusted_origin_without_network_or_credential_disclosure(history, repo, api, url):
subprocess.run(['git', '-C', str(repo), 'remote', 'set-url', 'origin', url], check=True)
with pytest.raises(history.VersionHistoryError, match='origin') as exc:
history.list_versions(repo, 'main')
assert 'secret' not in str(exc.value) and not api.requests
def test_resolve_refreshes_cached_head_and_validates_pin(history, repo, api):
history.list_versions(repo, 'main')
api.heads['main'] = 121
assert history.resolve_version(repo, 'main', 'latest') == {'branch': 'main', 'commit': f'{121:040x}', 'head': f'{121:040x}', 'pinned': False}
assert history.resolve_version(repo, 'main', f'{100:040x}') == {'branch': 'main', 'commit': f'{100:040x}', 'head': f'{121:040x}', 'pinned': True}
assert history.resolve_version(repo, 'main', f'{121:040x}')['pinned'] is True
@pytest.mark.parametrize('comparison', [{'status': 'diverged', 'merge_base_commit': {'sha': f'{50:040x}'}}, {'status': 'ahead', 'merge_base_commit': {'sha': f'{50:040x}'}}, {}, {'status': 'behind', 'merge_base_commit': {'sha': f'{100:040x}'}}])
def test_exact_pin_rejects_cross_branch_or_invalid_ancestry(history, repo, api, comparison):
api.invalid_compare = comparison
if not comparison:
api.invalid_compare = {'status': None}
with pytest.raises(history.VersionHistoryError, match='branch|GitHub'):
history.resolve_version(repo, 'main', f'{100:040x}')
def test_stale_head_after_branch_rewrite_is_rejected_after_browser_ttl(history, repo, api, monkeypatch):
clock = [100.0]
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
old = history.list_versions(repo, 'main')['head']
api.heads['main'] = 80
# Browsing can reuse a head for 60 seconds; installation always refreshes.
assert history.list_versions(repo, 'main', page=2, head=old)['head'] == old
clock[0] += 61
with pytest.raises(history.VersionHistoryError, match='branch'):
history.list_versions(repo, 'main', page=2, head=old)
@pytest.mark.parametrize('kwargs', [{'page': 0}, {'page': True}, {'page': 1.5}, {'page': '2'}, {'page': 2}, {'head': 'HEAD'}])
def test_rejects_invalid_or_unpinned_pagination(history, repo, api, kwargs):
with pytest.raises(history.VersionHistoryError):
history.list_versions(repo, 'main', **kwargs)
assert not api.requests
@pytest.mark.parametrize('body', [{}, {'commit': {'sha': 'oops'}}, {'commit': {'sha': f'{120:040x}'}, 'name': 'different'}])
def test_invalid_branch_response(history, repo, monkeypatch, body):
monkeypatch.setattr(history, '_get_json', lambda url: body)
with pytest.raises(history.VersionHistoryError, match='GitHub'):
history.list_versions(repo, 'main')
@pytest.mark.parametrize('rows', [None, {}, [{}], [dict(commit(120), sha='wrong')], [dict(commit(120), commit={'message': 'x', 'committer': {'date': 'bad'}})], [commit(119)], [commit(120), commit(120)]])
def test_invalid_commit_response(history, repo, monkeypatch, rows):
fake = GitHub()
monkeypatch.setattr(history, '_get_json', lambda url: rows if '/commits?' in url else fake(url))
with pytest.raises(history.VersionHistoryError, match='GitHub'):
history.list_versions(repo, 'main')
def test_cache_expires_and_returns_independent_results(history, repo, api, monkeypatch):
clock = [100.0]
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
first = history.list_versions(repo, 'main')
first['commits'][0]['subject'] = 'corrupt'
count = len(api.requests)
assert history.list_versions(repo, 'main')['commits'][0]['subject'] == 'Change 120'
assert len(api.requests) == count
api.heads['main'] = 121
clock[0] += 61
assert history.list_versions(repo, 'main')['head'] == f'{121:040x}'
@pytest.mark.parametrize('code', [403, 429, 404, 500])
def test_http_errors_are_actionable(history, repo, monkeypatch, code):
def fail(*args, **kwargs):
raise HTTPError('https://api.github.com/', code, 'failure', {'Retry-After': '60'}, io.BytesIO(b'{}'))
monkeypatch.setattr(history, '_open_url', fail)
with pytest.raises(history.VersionHistoryError, match='rate limit|retry|not found'):
history.list_versions(repo, 'main')
def test_network_failure_is_retryable(history, repo, monkeypatch):
def fail(*args, **kwargs):
raise URLError('offline')
monkeypatch.setattr(history, '_open_url', fail)
with pytest.raises(history.VersionHistoryError, match='retry'):
history.list_versions(repo, 'main')
@pytest.mark.parametrize('content', [b'{broken', b'x' * (4 * 1024 * 1024 + 1)], ids=['invalid-json', 'oversized-json'])
def test_http_invalid_or_oversized_json_is_bounded(history, repo, monkeypatch, content):
monkeypatch.setattr(history, '_open_url', lambda *a, **kw: io.BytesIO(content))
with pytest.raises(history.VersionHistoryError, match='GitHub'):
history.list_versions(repo, 'main')
def test_truncated_http_response_is_retryable(history, repo, monkeypatch):
class Truncated(io.BytesIO):
def read(self, *args):
raise IncompleteRead(b'partial', 100)
monkeypatch.setattr(history, '_open_url', lambda *a, **kw: Truncated())
with pytest.raises(history.VersionHistoryError, match='retry'):
history.list_versions(repo, 'main')
def test_full_block_has_more_is_exact(history, repo, api, monkeypatch):
clock = [100.0]
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
api.heads['main'] = 100
page = history.list_versions(repo, 'main', page=4, head=f'{100:040x}')
assert len(page['commits']) == 25 and page['hasMore'] is False
api.heads['main'] = 101
clock[0] += 61 # Let browsing discover the new branch head.
page = history.list_versions(repo, 'main', page=4, head=f'{101:040x}')
assert page['hasMore'] is True
def test_evicted_branch_is_refreshed_after_many_branches(history, repo, api):
history.list_versions(repo, 'main')
for n in range(65):
api.heads[f'branch{n}'] = 1
history.list_versions(repo, f'branch{n}')
api.heads['main'] = 121
assert history.list_versions(repo, 'main')['head'] == f'{121:040x}'
@pytest.mark.parametrize('url', ['https://evil.test/data', 'http://api.github.com/repos/a/b', 'https://api.github.com.evil.test/data'])
def test_redirects_cannot_leave_github_api(history, url):
handler = history._GitHubRedirectHandler()
with pytest.raises(history.VersionHistoryError, match='redirect'):
handler.redirect_request(Request('https://api.github.com/repos/a/b'), None, 301, 'Moved', {}, url)
def test_canonical_github_repository_redirect_is_allowed(history):
redirected = history._GitHubRedirectHandler().redirect_request(Request('https://api.github.com/repos/firestar5683/openpilot'), None, 301, 'Moved', {}, 'https://api.github.com/repos/firestar5683/StarPilot')
assert redirected.full_url == 'https://api.github.com/repos/firestar5683/StarPilot'
def test_history_has_no_artificial_depth_limit(history, repo, api):
api.heads['main'] = 30000
result = history.list_versions(repo, 'main', page=1001, head=f'{30000:040x}')
assert len(result['commits']) == 25
assert result['commits'][0]['sha'] == f'{5000:040x}'
assert result['hasMore'] is True
assert len(api.requests) == 2
def test_starpilot_versions_come_from_each_exact_sha(history, repo, api, monkeypatch):
api.heads['StarPilot'] = 3
requests = []
def raw(request, **kwargs):
requests.append(request.full_url)
sha = request.full_url.split('/')[-5]
version = '6.7.7' if int(sha, 16) >= 2 else '6.7.6'
return io.BytesIO(f'STARPILOT_DISPLAY_VERSION = "{version}"\n'.encode())
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
rows = history.list_versions(repo, 'StarPilot')['commits']
assert [r.get('version') for r in rows] == ['6.7.7', '6.7.7', '6.7.6']
assert len(requests) == 3
assert all(url.startswith('https://raw.githubusercontent.com/firestar5683/openpilot/') for url in requests)
assert all(url.endswith('/selfdrive/ui/lib/starpilot_version.py') for url in requests)
assert [r['sha'] for r in rows] == [f'{n:040x}' for n in (3, 2, 1)]
def test_other_branches_do_not_fetch_starpilot_versions(history, repo, api, monkeypatch):
def raw(*args, **kwargs):
pytest.fail('Other branches must not request StarPilot version files')
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
assert len(history.list_versions(repo, 'main')['commits']) == 25
def test_missing_old_version_file_is_explicit_and_cached(history, repo, api, monkeypatch):
api.heads['StarPilot'] = 1
calls = []
def raw(request, **kwargs):
calls.append(request.full_url)
raise HTTPError(request.full_url, 404, 'Not Found', {}, io.BytesIO())
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
first = history.list_versions(repo, 'StarPilot')
assert first['commits'][0]['version'] is None
assert history.list_versions(repo, 'StarPilot')['commits'][0]['version'] is None
assert len(calls) == 1
@pytest.mark.parametrize('body', [
b'STARPILOT_DISPLAY_VERSION = "6.7.7-beta"\n',
b'STARPILOT_DISPLAY_VERSION = str(6.7)\n',
b'STARPILOT_DISPLAY_VERSION = "6.7.7"; raise RuntimeError()\n',
b'STARPILOT_DISPLAY_VERSION = "6.7.7"\nSTARPILOT_DISPLAY_VERSION = "6.7.6"\n',
b'not python',
b'\xff',
b'x' * (64 * 1024 + 1),
], ids=['non-numeric', 'expression', 'extra-statement', 'ambiguous', 'missing-literal', 'invalid-encoding', 'oversized'])
def test_invalid_version_metadata_is_retryable(history, repo, api, monkeypatch, body):
api.heads['StarPilot'] = 1
monkeypatch.setattr(history, '_open_raw_url', lambda *a, **kw: io.BytesIO(body), raising=False)
with pytest.raises(history.VersionHistoryError, match='version.*retry'):
history.list_versions(repo, 'StarPilot')
@pytest.mark.parametrize('failure', [
HTTPError('https://raw.githubusercontent.com/', 429, 'Slow down', {}, io.BytesIO()),
HTTPError('https://raw.githubusercontent.com/', 500, 'Unavailable', {}, io.BytesIO()),
URLError('offline'), IncompleteRead(b'partial', 100),
])
def test_version_network_failure_is_not_cached_as_missing(history, repo, api, monkeypatch, failure):
api.heads['StarPilot'] = 1
def fail(*args, **kwargs):
raise failure
monkeypatch.setattr(history, '_open_raw_url', fail, raising=False)
with pytest.raises(history.VersionHistoryError, match='retry'):
history.list_versions(repo, 'StarPilot')
monkeypatch.setattr(history, '_open_raw_url', lambda *a, **kw: io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n'))
assert history.list_versions(repo, 'StarPilot')['commits'][0]['version'] == '6.7.7'
def test_version_cache_outlives_branch_cache_and_expires(history, repo, api, monkeypatch):
api.heads['StarPilot'] = 1
clock = [100.0]
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
calls = []
def raw(*args, **kwargs):
calls.append(1)
return io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n')
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
assert history.list_versions(repo, 'StarPilot')['commits'][0]['version'] == '6.7.7'
clock[0] += 3600
history.list_versions(repo, 'StarPilot')
assert len(calls) == 1
clock[0] += 24 * 3600
history.list_versions(repo, 'StarPilot')
assert len(calls) == 2
@pytest.mark.parametrize('callers', [1, 2])
def test_version_requests_are_parallel_and_bounded(history, repo, api, monkeypatch, callers):
import threading
import time
api.heads['StarPilot'] = 8
lock = threading.Lock()
counts = {'active': 0, 'peak': 0}
def raw(*args, **kwargs):
with lock:
counts['active'] += 1
counts['peak'] = max(counts['peak'], counts['active'])
time.sleep(0.03)
with lock:
counts['active'] -= 1
return io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n')
monkeypatch.setattr(history, '_open_raw_url', raw, raising=False)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=callers) as clients:
results = list(clients.map(lambda _: history.list_versions(repo, 'StarPilot'), range(callers)))
assert all(row['version'] == '6.7.7' for result in results for row in result['commits'])
assert 1 < counts['peak'] <= 4
@pytest.mark.parametrize('url', ['https://evil.test/file', 'http://raw.githubusercontent.com/file', 'https://api.github.com/file'])
def test_raw_redirects_stay_on_https_raw_github(history, url):
with pytest.raises(history.VersionHistoryError, match='redirect'):
history._RawGitHubRedirectHandler().redirect_request(Request('https://raw.githubusercontent.com/a/b'), None, 301, 'Moved', {}, url)
def test_failed_version_request_cancels_pending_work_promptly(history, repo, api, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
import threading
api.heads['StarPilot'] = 25
started = threading.Barrier(4)
release = threading.Event()
downloads = []
class RecordingExecutor(ThreadPoolExecutor):
def submit(self, *args, **kwargs):
future = super().submit(*args, **kwargs)
downloads.append(future)
return future
monkeypatch.setattr(history, 'ThreadPoolExecutor', RecordingExecutor)
def raw(request, **kwargs):
if int(request.full_url.split('/')[-5], 16) >= 22:
started.wait(timeout=5)
if request.full_url.split('/')[-5] == f'{25:040x}':
raise URLError('offline')
release.wait(timeout=5)
return io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n')
monkeypatch.setattr(history, '_open_raw_url', raw)
with ThreadPoolExecutor(max_workers=1) as caller:
result = caller.submit(history.list_versions, repo, 'StarPilot')
try:
with pytest.raises(history.VersionHistoryError, match='retry'):
result.result(timeout=1)
finally:
release.set()
assert sum(future.cancelled() for future in downloads) >= 20
for future in downloads:
if not future.cancelled():
try:
future.result(timeout=2)
except history.VersionHistoryError:
pass
def test_version_cache_is_bounded_and_preserves_recent_entries(history, monkeypatch):
calls = []
def raw(request, **kwargs):
calls.append(request.full_url)
return io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n')
monkeypatch.setattr(history, '_open_raw_url', raw)
monkeypatch.setattr(history, 'MAX_VERSION_CACHE_ENTRIES', 2)
base = 'https://api.github.com/repos/firestar5683/openpilot'
for n in (1, 2, 1, 3, 1):
assert history._display_version(base, f'{n:040x}') == '6.7.7'
assert len(calls) == 3
history._display_version(base, f'{2:040x}')
assert len(calls) == 4
def test_version_cache_does_not_mix_repository_origins(history, monkeypatch):
def raw(request, **kwargs):
version = '6.7.7' if '/firestar5683/' in request.full_url else '6.7.6'
return io.BytesIO(f"STARPILOT_DISPLAY_VERSION = '{version}' # display\n".encode())
monkeypatch.setattr(history, '_open_raw_url', raw)
assert history._display_version('https://api.github.com/repos/firestar5683/openpilot', f'{1:040x}') == '6.7.7'
assert history._display_version('https://api.github.com/repos/other/openpilot', f'{1:040x}') == '6.7.6'
def test_raw_canonical_repository_redirect_is_allowed(history):
url = 'https://raw.githubusercontent.com/firestar5683/StarPilot/' + f'{1:040x}' + '/selfdrive/ui/lib/starpilot_version.py'
redirected = history._RawGitHubRedirectHandler().redirect_request(Request('https://raw.githubusercontent.com/firestar5683/openpilot/file'), None, 301, 'Moved', {}, url)
assert redirected.full_url == url
def test_paging_reuses_head_and_immutable_blocks_for_six_hours(history, repo, api, monkeypatch):
clock = [100.0]
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
first = history.list_versions(repo, 'main')
assert len(api.requests) == 2
history.list_versions(repo, 'main', page=2, head=first['head'])
assert len(api.requests) == 2
clock[0] += 3600
history.list_versions(repo, 'main', page=3, head=first['head'])
assert len(api.requests) == 3 # Only the mutable head refreshes.
clock[0] += 6 * 3600
history.list_versions(repo, 'main', page=3, head=first['head'])
assert len(api.requests) == 5 # Head plus expired immutable block.
def test_immutable_ancestry_cache_outlives_browser_head_cache(history, repo, api, monkeypatch):
clock = [100.0]
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
base = 'https://api.github.com/repos/firestar5683/openpilot'
history._ancestor(base, f'{100:040x}', f'{120:040x}')
clock[0] += 3600
history._ancestor(base, f'{100:040x}', f'{120:040x}')
assert len(api.requests) == 1
clock[0] += 6 * 3600
history._ancestor(base, f'{100:040x}', f'{120:040x}')
assert len(api.requests) == 2
def test_install_rejects_force_push_during_browser_cache_window(history, repo, api):
old = history.list_versions(repo, 'main')['head']
api.heads['main'] = 80
with pytest.raises(history.VersionHistoryError, match='branch'):
history.resolve_version(repo, 'main', old)
@pytest.mark.parametrize('headers,expected', [
({'Retry-After': '90', 'X-RateLimit-Reset': '3280'}, '90 seconds'),
({'X-RateLimit-Reset': '3280'}, '38 minutes'),
({'Retry-After': 'bad', 'X-RateLimit-Reset': '3280'}, '38 minutes'),
({'X-RateLimit-Reset': '999'}, 'retry later'),
])
def test_rate_limit_reports_retry_time(history, monkeypatch, headers, expected):
monkeypatch.setattr(history.time, 'time', lambda: 1000.0)
def fail(*args, **kwargs):
raise HTTPError('https://api.github.com/', 403, 'Limited', headers, io.BytesIO())
monkeypatch.setattr(history, '_open_url', fail)
with pytest.raises(history.VersionHistoryError, match=expected):
history._get_json('https://api.github.com/repos/a/b')
@pytest.mark.parametrize('code', [403, 429])
def test_rate_limit_short_backoff_is_shared_and_then_retries(history, monkeypatch, code):
clock = [1000.0]
monkeypatch.setattr(history.time, 'time', lambda: clock[0])
monkeypatch.setattr(history.time, 'monotonic', lambda: clock[0])
calls = []
def raw(*args, **kwargs):
calls.append(1)
if len(calls) == 1:
raise HTTPError('https://api.github.com/', code, 'Limited', {'X-RateLimit-Reset': '3280', 'X-RateLimit-Remaining': '0'}, io.BytesIO())
return io.BytesIO(b'{"ok": true}')
monkeypatch.setattr(history, '_open_url', raw)
for path in ('branches/main', 'commits'):
with pytest.raises(history.VersionHistoryError, match='38 minutes'):
history._get_json('https://api.github.com/repos/a/b/' + path)
assert len(calls) == 1
clock[0] += 61
assert history._get_json('https://api.github.com/repos/a/b/branches/main') == {'ok': True}
assert len(calls) == 2
def test_unrelated_403_does_not_backoff_other_metadata(history, monkeypatch):
calls = []
def raw(*args, **kwargs):
calls.append(1)
if len(calls) == 1:
raise HTTPError('https://api.github.com/', 403, 'Forbidden', {}, io.BytesIO())
return io.BytesIO(b'{"ok": true}')
monkeypatch.setattr(history, '_open_url', raw)
with pytest.raises(history.VersionHistoryError, match='restriction'):
history._get_json('https://api.github.com/repos/a/b')
assert history._get_json('https://api.github.com/repos/c/d') == {'ok': True}
def test_backoff_keeps_cached_history_available_but_does_not_stale_install_head(history, repo, monkeypatch):
fake = GitHub()
original_get_json = history._get_json
monkeypatch.setattr(history, '_get_json', fake)
first = history.list_versions(repo, 'main')
# Restore the actual transport, then establish a known quota backoff.
monkeypatch.setattr(history, '_get_json', original_get_json)
def fail(*args, **kwargs):
raise HTTPError('https://api.github.com/', 403, 'Limited', {'X-RateLimit-Remaining': '0'}, io.BytesIO())
monkeypatch.setattr(history, '_open_url', fail)
with pytest.raises(history.VersionHistoryError, match='retry'):
history._get_json('https://api.github.com/repos/a/b')
assert history.list_versions(repo, 'main', page=2, head=first['head'])['commits'][0]['sha'] == f'{95:040x}'
with pytest.raises(history.VersionHistoryError, match='retry'):
history.resolve_version(repo, 'main', 'latest')
def offline_reload(history, monkeypatch):
spec = importlib.util.spec_from_file_location('history_reloaded_offline', MODULE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(module, 'SNAPSHOT_CACHE_DIR', history.SNAPSHOT_CACHE_DIR, raising=False)
def fail(*args, **kwargs):
raise URLError('offline')
monkeypatch.setattr(module, '_open_url', fail)
return module
def test_snapshot_survives_module_reload_and_keeps_original_timestamp(history, repo, api, monkeypatch):
first = history.list_versions(repo, 'main')
offline = offline_reload(history, monkeypatch)
cached = offline.list_versions(repo, 'main')
assert cached['cached'] is True
assert cached['commits'] == first['commits'] and cached['head'] == first['head']
assert 'retry' in cached['cacheReason']
original_time = cached['cachedAt']
assert offline.list_versions(repo, 'main')['cachedAt'] == original_time
def test_snapshot_pagination_requires_the_exact_requested_head(history, repo, api, monkeypatch):
first = history.list_versions(repo, 'main')
page = history.list_versions(repo, 'main', page=2, head=first['head'])
offline = offline_reload(history, monkeypatch)
assert offline.list_versions(repo, 'main', page=2, head=first['head'])['commits'] == page['commits']
with pytest.raises(offline.VersionHistoryError, match='retry'):
offline.list_versions(repo, 'main', page=2, head=f'{119:040x}')
with pytest.raises(offline.VersionHistoryError, match='retry'):
offline.list_versions(repo, 'main', page=3, head=first['head'])
def test_snapshot_does_not_cross_repository_origins(history, repo, api, monkeypatch):
history.list_versions(repo, 'main')
subprocess.run(['git', '-C', str(repo), 'remote', 'set-url', 'origin', 'https://github.com/other/openpilot'], check=True)
offline = offline_reload(history, monkeypatch)
with pytest.raises(offline.VersionHistoryError, match='retry'):
offline.list_versions(repo, 'main')
def test_snapshot_never_bypasses_install_freshness(history, repo, api, monkeypatch):
first = history.list_versions(repo, 'main')
offline = offline_reload(history, monkeypatch)
for chosen in ('latest', first['head']):
with pytest.raises(offline.VersionHistoryError, match='retry'):
offline.resolve_version(repo, 'main', chosen)
@pytest.mark.parametrize('failure_kind', ['rewrite', '404', 'malformed'])
def test_snapshot_does_not_hide_invalid_live_metadata(history, repo, api, monkeypatch, failure_kind):
first = history.list_versions(repo, 'main')
history.list_versions(repo, 'main', page=2, head=first['head'])
live = offline_reload(history, monkeypatch)
if failure_kind == 'rewrite':
fake = GitHub()
fake.heads['main'] = 80
monkeypatch.setattr(live, '_get_json', fake)
elif failure_kind == '404':
def missing(*args, **kwargs):
raise HTTPError('https://api.github.com/', 404, 'Missing', {}, io.BytesIO())
monkeypatch.setattr(live, '_open_url', missing)
else:
monkeypatch.setattr(live, '_get_json', lambda url: {})
with pytest.raises(live.VersionHistoryError):
live.list_versions(repo, 'main', page=2, head=first['head'])
@pytest.mark.parametrize('damage', ['corrupt', 'oversized', 'origin', 'branch', 'page', 'requested_head', 'row_sha', 'row_date',
'row_subject', 'row_version', 'head_mismatch', 'duplicate', 'old', 'future', 'naive_time'])
def test_invalid_snapshot_is_ignored(history, repo, api, monkeypatch, damage):
import json
from datetime import datetime, timedelta, timezone
history.list_versions(repo, 'main')
path = next(history.SNAPSHOT_CACHE_DIR.glob('*.json'))
data = json.loads(path.read_text())
if damage == 'corrupt':
path.write_text('{broken')
elif damage == 'oversized':
path.write_bytes(b'x' * (128 * 1024 + 1))
else:
if damage == 'origin':
data['repository'] = 'https://api.github.com/repos/other/openpilot'
elif damage == 'branch':
data['branch'] = 'other'
elif damage == 'page':
data['result']['page'] = 2
elif damage == 'requested_head':
data['requestedHead'] = f'{119:040x}'
elif damage == 'row_sha':
data['result']['commits'][0]['sha'] = 'HEAD'
elif damage == 'row_date':
data['result']['commits'][0]['date'] = 'yesterday'
elif damage == 'row_subject':
data['result']['commits'][0]['subject'] = []
elif damage == 'row_version':
data['result']['commits'][0]['version'] = '<script>'
elif damage == 'head_mismatch':
data['result']['head'] = f'{119:040x}'
elif damage == 'duplicate':
data['result']['commits'][1] = data['result']['commits'][0]
elif damage == 'old':
data['cachedAt'] = (datetime.now(timezone.utc) - timedelta(days=31)).isoformat()
elif damage == 'future':
data['cachedAt'] = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
elif damage == 'naive_time':
data['cachedAt'] = '2026-09-11T12:00:00'
path.write_text(json.dumps(data))
offline = offline_reload(history, monkeypatch)
with pytest.raises(offline.VersionHistoryError, match='retry'):
offline.list_versions(repo, 'main')
def test_snapshot_storage_failure_does_not_fail_online_browsing(history, repo, api):
history.SNAPSHOT_CACHE_DIR.write_text('not a directory')
assert len(history.list_versions(repo, 'main')['commits']) == 25
def test_snapshot_preload_timestamp_is_truthful_and_permissions_private(history, repo, api):
from datetime import datetime, timedelta, timezone
first = history.list_versions(repo, 'main')
saved_at = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
base = 'https://api.github.com/repos/firestar5683/openpilot'
assert history._save_history_snapshot(base, 'main', 1, None, first, saved_at=saved_at)
cached = history._load_history_snapshot(base, 'main', 1, None)
assert cached['cachedAt'] == saved_at
path = next(history.SNAPSHOT_CACHE_DIR.glob('*.json'))
assert path.stat().st_mode & 0o777 == 0o600
assert history.SNAPSHOT_CACHE_DIR.stat().st_mode & 0o777 == 0o700
assert not list(history.SNAPSHOT_CACHE_DIR.glob('*.tmp'))
def test_snapshot_prunes_file_count_and_total_bytes(history, repo, api, monkeypatch):
monkeypatch.setattr(history, 'MAX_SNAPSHOT_FILES', 3)
for n in range(5):
api.heads[f'branch{n}'] = 120
history.list_versions(repo, f'branch{n}')
files = list(history.SNAPSHOT_CACHE_DIR.glob('*.json'))
assert len(files) == 3
one_file_size = max(path.stat().st_size for path in files)
monkeypatch.setattr(history, 'MAX_SNAPSHOT_TOTAL_BYTES', one_file_size + 20)
api.heads['branch5'] = 120
history.list_versions(repo, 'branch5')
assert sum(path.stat().st_size for path in history.SNAPSHOT_CACHE_DIR.glob('*.json')) <= one_file_size + 20
def test_snapshot_prunes_old_files_on_successful_write(history, repo, api, monkeypatch):
history.list_versions(repo, 'main')
now = history.time.time()
monkeypatch.setattr(history.time, 'time', lambda: now + 31 * 86400)
api.heads['other'] = 120
history.list_versions(repo, 'other')
assert len(list(history.SNAPSHOT_CACHE_DIR.glob('*.json'))) == 1
@pytest.mark.parametrize('code', [403, 429])
def test_quota_failure_uses_labelled_snapshot(history, repo, api, monkeypatch, code):
first = history.list_versions(repo, 'main')
offline = offline_reload(history, monkeypatch)
def limited(*args, **kwargs):
raise HTTPError('https://api.github.com/', code, 'Limited', {'Retry-After': '90'}, io.BytesIO())
monkeypatch.setattr(offline, '_open_url', limited)
result = offline.list_versions(repo, 'main')
assert result['cached'] is True and result['commits'] == first['commits']
assert '90 seconds' in result['cacheReason']
@pytest.mark.parametrize('failure_kind', ['offline', 'invalid'])
def test_starpilot_snapshot_fallback_only_for_version_transport_failure(history, repo, api, monkeypatch, failure_kind):
api.heads['StarPilot'] = 3
monkeypatch.setattr(history, '_open_raw_url', lambda *a, **kw: io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n'))
first = history.list_versions(repo, 'StarPilot')
offline = offline_reload(history, monkeypatch)
monkeypatch.setattr(offline, '_get_json', api)
def raw(*args, **kwargs):
if failure_kind == 'offline':
raise URLError('offline')
return io.BytesIO(b'not a version literal')
monkeypatch.setattr(offline, '_open_raw_url', raw)
if failure_kind == 'offline':
result = offline.list_versions(repo, 'StarPilot')
assert result['cached'] is True and result['commits'] == first['commits']
else:
with pytest.raises(offline.VersionHistoryError, match='invalid version'):
offline.list_versions(repo, 'StarPilot')
def test_atomic_snapshot_write_failure_leaves_no_partial_file(history, repo, api, monkeypatch):
def fail(*args, **kwargs):
raise OSError('disk full')
monkeypatch.setattr(history.os, 'replace', fail)
result = history.list_versions(repo, 'main')
assert len(result['commits']) == 25 and 'cached' not in result
assert not list(history.SNAPSHOT_CACHE_DIR.iterdir())
def test_unreadable_snapshot_is_ignored(history, repo, api, monkeypatch):
history.list_versions(repo, 'main')
offline = offline_reload(history, monkeypatch)
original_open = Path.open
def denied(path, *args, **kwargs):
if path.parent == history.SNAPSHOT_CACHE_DIR:
raise PermissionError('cache unavailable')
return original_open(path, *args, **kwargs)
monkeypatch.setattr(Path, 'open', denied)
with pytest.raises(offline.HistoryUnavailable):
offline.list_versions(repo, 'main')
def test_cached_response_cannot_be_resaved_with_a_new_timestamp(history, repo, api, monkeypatch):
history.list_versions(repo, 'main')
offline = offline_reload(history, monkeypatch)
cached = offline.list_versions(repo, 'main')
assert not offline._save_history_snapshot('https://api.github.com/repos/firestar5683/openpilot', 'main', 1, None, cached)
assert offline.list_versions(repo, 'main')['cachedAt'] == cached['cachedAt']
@pytest.mark.parametrize('transport', ['api', 'raw'])
@pytest.mark.parametrize('code,headers,fallback', [
(403, {}, False),
(403, {'Retry-After': 'invalid'}, False),
(403, {'Retry-After': '90'}, True),
(403, {'X-RateLimit-Remaining': '0'}, True),
(429, {}, True),
(503, {}, True),
])
def test_http_failure_snapshot_fallback_boundary(history, repo, api, monkeypatch, transport, code, headers, fallback):
branch = 'main' if transport == 'api' else 'StarPilot'
api.heads['StarPilot'] = 3
monkeypatch.setattr(history, '_open_raw_url', lambda *a, **kw: io.BytesIO(b'STARPILOT_DISPLAY_VERSION = "6.7.7"\n'))
first = history.list_versions(repo, branch)
offline = offline_reload(history, monkeypatch)
def fail(*args, **kwargs):
raise HTTPError('https://github.com/', code, 'Unavailable', headers, io.BytesIO())
if transport == 'api':
monkeypatch.setattr(offline, '_open_url', fail)
else:
monkeypatch.setattr(offline, '_get_json', api)
monkeypatch.setattr(offline, '_open_raw_url', fail)
if fallback:
result = offline.list_versions(repo, branch)
assert result['cached'] is True and result['commits'] == first['commits']
else:
with pytest.raises(offline.VersionHistoryError) as error:
offline.list_versions(repo, branch)
assert not isinstance(error.value, offline.HistoryUnavailable)
def test_empty_snapshot_preload_timestamp_is_rejected(history, repo, api):
first = history.list_versions(repo, 'main')
assert not history._save_history_snapshot('https://api.github.com/repos/firestar5683/openpilot', 'main', 1, None, first, saved_at='')
@@ -0,0 +1,71 @@
import fs from 'node:fs'
import vm from 'node:vm'
import assert from 'node:assert/strict'
import test from 'node:test'
const root = process.argv[2] || new URL('../../../../', import.meta.url).pathname
const file = root + '/starpilot/system/the_galaxy/assets/mobile/js/components/VersionHistoryPicker.js'
const context = vm.createContext({Date, Map, Set})
if (fs.existsSync(file)) vm.runInContext(fs.readFileSync(file, 'utf8').replace(/export /g, '') + '\nglobalThis.picker = VersionHistoryPicker; globalThis.group = groupVersionHistory; globalThis.title = versionTitle; globalThis.releases = releaseVersions;', context)
const row = (sha, date, version = '6.7.7') => ({sha: sha.repeat(40), date, version, subject: 'Correct launch behavior'})
test('history groups every loaded local day and merges nonadjacent page entries', () => {
assert.equal(typeof context.group, 'function', 'date-grouped history is available')
const groups = context.group([row('a', '2026-09-11T12:00:00'), row('b', '2026-09-10T13:00:00'), row('c', '2026-09-11T09:00:00'), row('d', '2024-01-02T10:00:00')])
assert.deepEqual(Array.from(groups, g => g.key), ['2026-09-11', '2026-09-10', '2024-01-02'])
assert.deepEqual(Array.from(groups[0].commits, c => c.sha), ['a'.repeat(40), 'c'.repeat(40)])
})
test('StarPilot uses version numbers with time to distinguish builds and never a hash headline', () => {
assert.equal(typeof context.title, 'function', 'friendly version labels are available')
const a = context.title(row('a', '2026-09-11T12:00:00'), true)
const b = context.title(row('b', '2026-09-11T13:00:00'), true)
assert.match(a, /6\.7\.7/)
assert.notEqual(a, b)
assert.ok(!a.includes('aaaaaaaaaa'))
assert.match(context.title(row('c', '2026-09-11T12:00:00', ''), true), /Unnumbered version/)
assert.match(context.title(row('a', '2026-09-11T12:00:00'), false), /Correct launch behavior/)
})
test('bad dates remain selectable in an unknown-date day without hiding history', () => {
assert.equal(typeof context.group, 'function')
const groups = context.group([row('a', 'not a date'), row('b', '2026-09-10T12:00:00')])
assert.equal(groups.length, 2)
assert.equal(groups[1].label, 'Unknown date')
assert.equal(groups[1].commits[0].sha, 'a'.repeat(40))
})
test('newly appended days stay collapsed while the current day keeps its state', () => {
assert.ok(context.picker, 'history picker is available')
const instance = {...context.picker.data(), groups: [{key:'2026-09-11'}, {key:'2026-09-10'}]}
context.picker.methods.syncDays.call(instance)
assert.deepEqual({...instance.expanded}, {'2026-09-11':true, '2026-09-10':false})
instance.expanded['2026-09-10'] = true
instance.groups.push({key:'2026-09-09'})
context.picker.methods.syncDays.call(instance)
assert.equal(instance.expanded['2026-09-10'], true)
assert.equal(instance.expanded['2026-09-09'], false)
})
test('selection emits only an available exact SHA and disabled selection emits nothing', () => {
assert.ok(context.picker)
const calls = []
const instance = {commits: [row('a', '2026-09-11')], disabled:false, close:()=>{}, $emit:(...args)=>calls.push(args)}
context.picker.methods.select.call(instance, 'b'.repeat(40))
assert.equal(calls.length, 0)
context.picker.methods.select.call(instance, 'a'.repeat(40))
assert.equal(calls[0][0], 'change')
assert.equal(calls[0][1].target.value, 'a'.repeat(40))
instance.disabled = true
context.picker.methods.select.call(instance, 'a'.repeat(40))
assert.equal(calls.length, 1)
})
test('release numbers appear once across pages, retaining the first newest build and numeric ordering', () => {
const rows=[row('a','2026-09-11','6.7.7'),row('b','2026-09-10','6.7.7'),row('c','2026-09-09','6.7.6'),row('d','2026-09-08','6.7.7')]
assert.deepEqual(Array.from(context.releases(rows),r=>r.sha),['a'.repeat(40),'c'.repeat(40)])
assert.deepEqual(Array.from(context.releases([row('a','2026-09-11','6.9.0'),row('b','2026-09-10','6.10.0')]),r=>r.version),['6.10.0','6.9.0'])
assert.equal(context.releases([row('a','2026-09-11',null),row('b','2026-09-10',null)]).length,1)
assert.equal(context.group(rows).reduce((n,g)=>n+g.commits.length,0),4)
})
test('release picker cannot emit a hidden duplicate build', () => {
const calls=[]
const instance={releaseBranch:true,commits:[row('a','2026-09-11'),row('b','2026-09-10')],disabled:false,close:()=>{},$emit:(...args)=>calls.push(args)}
context.picker.methods.select.call(instance,'b'.repeat(40));assert.equal(calls.length,0)
context.picker.methods.select.call(instance,'a'.repeat(40));assert.equal(calls[0][1].target.value,'a'.repeat(40))
})
@@ -0,0 +1,363 @@
import importlib.util
import subprocess
import json
import os
import sqlite3
import stat
from pathlib import Path
import pytest
SPEC=importlib.util.spec_from_file_location('version_install',Path(__file__).resolve().parents[1]/'version_install.py')
if SPEC.origin and Path(SPEC.origin).exists():
module=importlib.util.module_from_spec(SPEC);SPEC.loader.exec_module(module)
else: module=None
def git(repo,*args):
return subprocess.check_output(['git','-C',str(repo),*args],stderr=subprocess.STDOUT).decode().strip()
@pytest.fixture
def fixture(tmp_path):
repo=tmp_path/'repo';repo.mkdir();git(repo,'init','-b','Dom');git(repo,'config','user.email','test@example.invalid');git(repo,'config','user.name','Test')
files={'launch_env.sh':'export AGNOS_VERSION="19.6.20"\n','launch_chffrplus.sh':'#!/bin/sh\n','starpilot/common/starpilot_variables.py':'automatic_updates = AutomaticUpdates\n','system/updated/updated.py':'automatic_updates_enabled\n','common/params_keys.h':'AutomaticUpdates\n','system/hardware/tici/agnos.json':'[]\n','feature.txt':'old\n'}
for name,value in files.items():
p=repo/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(value)
git(repo,'add','.');git(repo,'commit','-m','first');old=git(repo,'rev-parse','HEAD')
(repo/'feature.txt').write_text('new\n');git(repo,'commit','-am','second');new=git(repo,'rev-parse','HEAD')
(repo/'feature.txt').write_text('local changes\n');(repo/'local.txt').write_text('untracked settings tool\n')
data=tmp_path/'data';params=data/'params/d';params.mkdir(parents=True)
(params/'IsOnroad').write_text('0');(params/'IsOffroad').write_text('1');(params/'AutomaticUpdates').write_text('1');(params/'ExampleSetting').write_text('preserve')
staging=data/'safe_staging/finalized';staging.mkdir(parents=True);(staging/'.overlay_consistent').write_text('')
return repo,data,old,new
def run(f,check=lambda:None):
assert module is not None,'Exact-revision installer is not implemented'
repo,data,old,new=f
return module.install(repo,{'branch':'Dom','commit':old,'head':new,'pinned':True},data_root=data,check_parked=check,progress=lambda *a:None,require_device_binaries=False)
def test_installs_exact_revision_and_preserves_local_recovery(fixture):
result=run(fixture);repo,data,old,new=fixture
assert git(repo,'rev-parse','HEAD')==old
assert (repo/'feature.txt').read_text()=='old\n'
assert (data/'params/d/ExampleSetting').read_text()=='preserve'
assert (data/'params/d/AutomaticUpdates').read_text()=='0'
assert not (data/'safe_staging/finalized/.overlay_consistent').exists()
backup=Path(result['backup'])
assert (backup/'working.patch').stat().st_size>0
assert (backup/'recover.py').is_file()
assert (backup/'params/ExampleSetting').read_text()=='preserve'
assert module.read_pin(repo,data)['commit']==old
module.restore(backup,check_parked=lambda:None,restore_data=False)
assert git(repo,'rev-parse','HEAD')==new
assert (repo/'feature.txt').read_text()=='local changes\n'
assert (repo/'local.txt').read_text()=='untracked settings tool\n'
def test_rejects_target_with_incompatible_agnos_before_checkout(fixture):
repo,data,old,new=fixture
(repo/'launch_env.sh').write_text('export AGNOS_VERSION="20.0"\n');git(repo,'add','launch_env.sh');git(repo,'commit','-m','new OS')
current=git(repo,'rev-parse','HEAD')
assert module is not None,'Exact-revision installer is not implemented'
with pytest.raises(module.InstallError,match='AGNOS'):
run((repo,data,old,current))
assert git(repo,'rev-parse','HEAD')==current
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
def test_onroad_guard_prevents_changes(fixture):
repo,data,old,new=fixture
def reject():raise RuntimeError('park first')
with pytest.raises(RuntimeError,match='park first'):run(fixture,check=reject)
assert git(repo,'rev-parse','HEAD')==new
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
def test_fetch_sha_mismatch_cannot_install_a_different_commit(fixture):
assert module is not None,'Exact-revision installer is not implemented'
with pytest.raises(module.InstallError):module.validate_target({'branch':'Dom','commit':'HEAD','pinned':True})
def test_failed_checkout_keeps_recovery_and_restores_previous_source(fixture,monkeypatch):
assert module is not None,'Exact-revision installer is not implemented'
repo,data,old,new=fixture
original=module.git
def fail(repo,*args,**kwargs):
if args and args[0]=='checkout' and args[-1]==old:raise module.InstallError('injected checkout failure')
return original(repo,*args,**kwargs)
monkeypatch.setattr(module,'git',fail)
with pytest.raises(module.InstallError,match='injected'):run(fixture)
assert git(repo,'rev-parse','HEAD')==new
assert (repo/'feature.txt').read_text()=='local changes\n'
assert (data/'params/d/ExampleSetting').read_text()=='preserve'
def test_atomic_write_preserves_mode_and_does_not_touch_another_temporary_file(tmp_path):
path=tmp_path/'setting';path.write_bytes(b'old');path.chmod(0o640)
stale=tmp_path/'setting.version-tmp';stale.write_bytes(b'other writer')
module.atomic_write(path,b'new')
assert path.read_bytes()==b'new' and stat.S_IMODE(path.stat().st_mode)==0o640
assert stale.read_bytes()==b'other writer'
@pytest.mark.parametrize('content',['[]','null','1','"text"','{broken'])
def test_malformed_pin_is_ignored(fixture,content):
repo,data,*_=fixture
path=data/'starpilot/version_selection.json';path.parent.mkdir(parents=True,exist_ok=True);path.write_text(content)
assert module.read_pin(repo,data) is None
def test_untracked_symlink_rejected_before_source_or_settings_changes(fixture):
repo,data,old,new=fixture
(repo/'unsupported-link').symlink_to('feature.txt')
with pytest.raises(module.InstallError,match='symlink|regular file'):run(fixture)
assert git(repo,'rev-parse','HEAD')==new and (repo/'feature.txt').read_text()=='local changes\n'
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
assert (data/'safe_staging/finalized/.overlay_consistent').exists()
def test_new_branch_has_explicit_origin_upstream(fixture):
repo,data,old,new=fixture
git(repo,'remote','add','origin','https://github.com/example/project.git')
module.install(repo,{'branch':'feature/old','commit':old,'head':new,'pinned':True},data_root=data,check_parked=lambda:None,progress=lambda *a:None,require_device_binaries=False)
assert git(repo,'config','branch.feature/old.remote')=='origin'
assert git(repo,'config','branch.feature/old.merge')=='refs/heads/feature/old'
def test_database_backup_contains_wal_and_recovery_keeps_newer_settings_and_stats(fixture):
repo,data,old,new=fixture
db=data/'starpilot/model_stats.sqlite';db.parent.mkdir(parents=True,exist_ok=True)
with sqlite3.connect(db) as connection:
connection.execute('pragma journal_mode=WAL');connection.execute('create table events (id integer, note text)')
connection.execute('insert into events values (1, ?)',('before',));connection.commit()
result=run(fixture)
backup=Path(result['backup'])
with sqlite3.connect(backup/'model_stats.sqlite') as saved:
assert saved.execute('select * from events').fetchall()==[(1,'before')]
assert saved.execute('pragma integrity_check').fetchone()==('ok',)
connection.execute('insert into events values (2, ?)',('after',));connection.commit()
(data/'params/d/ExampleSetting').write_text('newer setting')
module.restore(backup,check_parked=lambda:None)
assert connection.execute('select * from events').fetchall()==[(1,'before'),(2,'after')]
assert (data/'params/d/ExampleSetting').read_text()=='newer setting'
assert (backup/'params/ExampleSetting').read_text()=='preserve'
def test_device_preflight_checks_actual_installed_os_before_mutation(fixture,tmp_path,monkeypatch):
repo,data,old,new=fixture
version=tmp_path/'VERSION';version.write_text('18.0\n')
monkeypatch.setattr(module,'OS_VERSION_FILE',version)
with pytest.raises(module.InstallError,match='running|installed'):
module.install(repo,{'branch':'Dom','commit':old,'head':new,'pinned':True},data_root=data,check_parked=lambda:None,progress=lambda *a:None,require_device_binaries=True)
assert git(repo,'rev-parse','HEAD')==new and (data/'params/d/AutomaticUpdates').read_text()=='1'
def test_active_tesla_setting_rejects_target_without_wake_support(fixture):
repo,data,old,new=fixture
(data/'params/d/TeslaWakeOnCAN').write_text('1')
with pytest.raises(module.InstallError,match='TeslaWakeOnCAN'):run(fixture)
assert git(repo,'rev-parse','HEAD')==new and (data/'params/d/TeslaWakeOnCAN').read_text()=='1'
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
@pytest.mark.parametrize('setting',['RemoteStartBootsComma','HKGRemoteStartBootsComma','IgnoreIgnitionLine'])
def test_active_panda_variant_cannot_be_silently_ignored(fixture,setting):
repo,data,old,new=fixture
(data/'params/d'/setting).write_text('1')
with pytest.raises(module.InstallError,match=setting):run(fixture)
assert git(repo,'rev-parse','HEAD')==new and (data/'params/d'/setting).read_text()=='1'
def test_dirty_submodule_is_rejected_without_losing_edits(fixture,tmp_path):
repo,data,old,new=fixture
child=tmp_path/'child';child.mkdir();git(child,'init','-b','main');git(child,'config','user.name','Test');git(child,'config','user.email','test@example.invalid')
(child/'nested.txt').write_text('original');git(child,'add','.');git(child,'commit','-m','child')
git(repo,'-c','protocol.file.allow=always','submodule','add',str(child),'vendor/child')
git(repo,'commit','-m','submodule')
current=git(repo,'rev-parse','HEAD');(repo/'vendor/child/nested.txt').write_text('valuable local edit')
with pytest.raises(module.InstallError,match='submodule'):run((repo,data,old,current))
assert git(repo,'rev-parse','HEAD')==current
assert (repo/'vendor/child/nested.txt').read_text()=='valuable local edit'
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
def test_backup_failure_does_not_disable_updates_or_change_source(fixture,monkeypatch):
repo,data,old,new=fixture
def fail(*a,**kw):raise OSError('backup full')
monkeypatch.setattr(module.shutil,'copytree',fail)
with pytest.raises(OSError,match='backup full'):run(fixture)
assert git(repo,'rev-parse','HEAD')==new and (repo/'feature.txt').read_text()=='local changes\n'
assert (data/'params/d/AutomaticUpdates').read_text()=='1'
@pytest.mark.parametrize('lock',['index.lock','shallow.lock'])
def test_repository_idle_check_rejects_locks_without_deleting_them(fixture,lock):
repo,data,*_=fixture
path=repo/'.git'/lock;path.write_text('busy')
with pytest.raises(module.InstallError,match='lock|busy'):module.check_repository_idle(repo)
assert path.read_text()=='busy'
@pytest.mark.parametrize('argv',[[b'python3',b'-m',b'openpilot.system.updated.updated'],[b'system.updated.updated'],[b'python3',b'/data/openpilot/system/updated/updated.py']])
def test_updater_recognizes_prefixed_process_titles(argv):
assert module._is_updater(argv) is True
def test_updater_does_not_match_unrelated_process():
assert module._is_updater([b'python3',b'/tmp/not-updated.py']) is False
def firmware_target(fixture, *, missing=False):
repo,data,old,new=fixture
selector=(Path(__file__).resolve().parents[4]/'selfdrive/pandad/panda_firmware.py').read_text()
path=repo/'selfdrive/pandad/panda_firmware.py';path.parent.mkdir(parents=True);path.write_text(selector)
(repo/'common/params_keys.h').write_text('AutomaticUpdates TeslaWakeOnCAN RemoteStartBootsComma HKGRemoteStartBootsComma IgnoreIgnitionLine')
for name in ('panda_tesla_wake.bin.signed','panda_h7_tesla_wake.bin.signed'):
path=repo/'panda/board/obj'/name;path.parent.mkdir(parents=True,exist_ok=True)
if not missing or 'h7' not in name:path.write_bytes(b'firmware-test-content')
git(repo,'add','selfdrive','panda','common/params_keys.h');git(repo,'commit','-m','firmware')
target=git(repo,'rev-parse','HEAD')
(data/'params/d/TeslaWakeOnCAN').write_text('1')
return target
def test_active_firmware_variant_validates_target_images(fixture):
repo,data,*_=fixture
target=firmware_target(fixture)
assert module.preflight(repo,target,False,data)['agnos']=='19.6.20'
def test_missing_selected_h7_image_blocks_even_when_other_image_exists(fixture):
repo,data,*_=fixture
target=firmware_target(fixture,missing=True)
with pytest.raises(module.InstallError,match='panda_h7_tesla_wake'):
module.preflight(repo,target,False,data)
assert (data/'params/d/TeslaWakeOnCAN').read_bytes()==b'1'
def test_modified_recovery_archive_is_rejected_before_checkout(fixture):
import io
import tarfile
repo,data,*_=fixture
result=run(fixture)
backup=Path(result['backup'])
with tarfile.open(backup/'untracked.tar','w') as archive:
member=tarfile.TarInfo('../escaped');member.size=4;archive.addfile(member,io.BytesIO(b'evil'))
before=git(repo,'rev-parse','HEAD')
with pytest.raises(module.InstallError,match='Unsafe recovery'):
module.restore(backup,check_parked=lambda:None)
assert git(repo,'rev-parse','HEAD')==before
assert not (repo.parent/'escaped').exists()
@pytest.mark.parametrize('restart',[False,True])
def test_updater_only_signals_owned_processes_and_restarts_after_install(monkeypatch,restart):
records={100:(1,'1000','S',[b'openpilot.system.updated.updated']),
101:(100,'1001','S',[b'git',b'fetch']),
102:(1,'1002','T',[b'system.updated.updated']),
103:(1,'1003','S',[b'other'])}
calls=[]
monkeypatch.setattr(module,'_processes',lambda:dict(records))
def kill(pid,sig):
calls.append((pid,sig))
parent,start,state,argv=records[pid]
records[pid]=(parent,start,'T' if sig==module.signal.SIGSTOP else 'S',argv)
monkeypatch.setattr(module.os,'kill',kill)
with module.suspend_updater() as control:
assert records[100][2]==records[101][2]=='T'
if restart:control.restart_after_install()
end=module.signal.SIGKILL if restart else module.signal.SIGCONT
assert calls==[(100,module.signal.SIGSTOP),(101,module.signal.SIGSTOP),(101,end),(100,end)]
assert not any(pid in (102,103) for pid,_ in calls)
assert module._ACTIVE_UPDATER.get() is None
def test_updater_does_not_signal_reused_pid(monkeypatch):
records={100:(1,'original','S',[b'system.updated.updated'])}
calls=[]
monkeypatch.setattr(module,'_processes',lambda:dict(records))
def kill(pid,sig):
calls.append((pid,sig));records[pid]=(1,'original','T',[b'system.updated.updated'])
monkeypatch.setattr(module.os,'kill',kill)
with module.suspend_updater() as control:
control.restart_after_install()
records[100]=(1,'replacement','S',[b'other'])
assert calls==[(100,module.signal.SIGSTOP)]
def test_failed_install_recovery_marks_updater_for_restart(fixture,monkeypatch):
repo,data,old,new=fixture
original=module.git
def fail(repo,*args,**kwargs):
if args and args[0]=='checkout' and args[-1]==old:raise module.InstallError('checkout failure')
return original(repo,*args,**kwargs)
monkeypatch.setattr(module,'git',fail)
monkeypatch.setattr(module,'_processes',lambda:{})
with module.suspend_updater() as control:
with pytest.raises(module.InstallError,match='previous source restored'):run(fixture)
assert control.restart
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0'
def test_failure_after_update_pause_still_restarts_updater(fixture,monkeypatch):
repo,data,*_=fixture
monkeypatch.setattr(module,'_processes',lambda:{})
def fail(*args):raise OSError('staging failure')
monkeypatch.setattr(module,'_clear_staging',fail)
with module.suspend_updater() as control:
with pytest.raises(OSError,match='staging failure'):run(fixture)
assert control.restart
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0'
def test_failed_manual_restore_after_pause_still_restarts_updater(fixture,monkeypatch):
repo,data,*_=fixture
backup=Path(run(fixture)['backup'])
(data/'params/d/AutomaticUpdates').write_bytes(b'1')
monkeypatch.setattr(module,'_processes',lambda:{})
original=module.git
def fail(repo,*args,**kwargs):
if args and args[0]=='checkout':raise module.InstallError('restore checkout failure')
return original(repo,*args,**kwargs)
monkeypatch.setattr(module,'git',fail)
with module.suspend_updater() as control:
with pytest.raises(module.InstallError,match='restore checkout failure'):
module.restore(backup,check_parked=lambda:None)
assert control.restart
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0'
def test_preflight_failure_does_not_restart_updater(fixture,monkeypatch):
repo,data,*_=fixture
(repo/'.git/index.lock').write_bytes(b'busy')
monkeypatch.setattr(module,'_processes',lambda:{})
with module.suspend_updater() as control:
with pytest.raises(module.InstallError,match='busy'):run(fixture)
assert not control.restart
assert (data/'params/d/AutomaticUpdates').read_bytes()==b'1'
def test_reviewed_legacy_firmware_selector_remains_compatible(fixture):
repo,data,*_=fixture
firmware_target(fixture)
path=repo/'selfdrive/pandad/panda_firmware.py'
source=path.read_text()
guard=' if tesla_wake and (remote_start or hkg_remote_start):\n raise ValueError("Tesla wake firmware cannot be combined with remote-start firmware")\n'
path.write_text(source.replace(guard,''))
git(repo,'add',str(path));git(repo,'commit','--allow-empty','-m','legacy firmware selector')
assert module.preflight(repo,git(repo,'rev-parse','HEAD'),False,data)['agnos']=='19.6.20'
@pytest.mark.parametrize('key', ['RemoteStartBootsComma', 'HKGRemoteStartBootsComma', 'RemoteStart', 'HkgRemoteStart'])
def test_conflicting_enabled_firmware_flags_are_refused(fixture,key):
repo,data,*_=fixture
target=firmware_target(fixture)
(data/'params/d'/key).write_text('1')
with pytest.raises(module.InstallError,match='cannot be combined'):
module.preflight(repo,target,False,data)
assert (data/'params/d/TeslaWakeOnCAN').read_bytes()==b'1'
assert (data/'params/d'/key).read_bytes()==b'1'
def test_unrecognized_firmware_selector_is_refused(fixture):
repo,data,*_=fixture
firmware_target(fixture)
path=repo/'selfdrive/pandad/panda_firmware.py'
path.write_text(path.read_text().replace('name_parts.extend(["tesla", "wake"])','name_parts.extend(["unexpected", "wake"])'))
git(repo,'add',str(path));git(repo,'commit','-m','unrecognized firmware selector')
with pytest.raises(module.InstallError,match='unrecognized firmware selection logic'):
module.preflight(repo,git(repo,'rev-parse','HEAD'),False,data)
@@ -0,0 +1,189 @@
"""Preserve Git's index and working tree independently through local recovery."""
import importlib.util
from pathlib import Path
import subprocess
import tempfile
import pytest
SPEC = importlib.util.spec_from_file_location('index_installer', Path(__file__).resolve().parents[1] / 'version_install.py')
installer = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(installer)
def git(repo, *args):
return subprocess.check_output(['git', '-C', str(repo), *args], stderr=subprocess.STDOUT)
@pytest.fixture
def checkout(monkeypatch):
monkeypatch.setenv('GIT_ALLOW_PROTOCOL', 'file')
monkeypatch.setenv('GIT_CONFIG_NOSYSTEM', '1')
monkeypatch.setenv('GIT_CONFIG_GLOBAL', '/dev/null')
with tempfile.TemporaryDirectory(prefix='galaxy-index-recovery-') as directory:
root = Path(directory)
repo = root / 'repo'
repo.mkdir()
git(repo, 'init', '-b', 'Dom')
git(repo, 'config', 'user.email', 'test@example.invalid')
git(repo, 'config', 'user.name', 'Index recovery test')
files = {
'launch_env.sh': b'export AGNOS_VERSION="19.6.20"\n',
'launch_chffrplus.sh': b'#!/bin/sh\n',
'common/params_keys.h': b'AutomaticUpdates\n',
'starpilot/common/starpilot_variables.py': b'automatic_updates\n',
'system/updated/updated.py': b'automatic_updates_enabled\n',
'system/hardware/tici/agnos.json': b'[]\n',
'tracked.txt': b'one\ntwo\nthree\n',
'binary.bin': b'\x00original\xff',
'removed.txt': b'keep staged deletion\n',
}
for name, content in files.items():
path = repo / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
(repo / 'tracked-link').symlink_to('tracked.txt')
git(repo, 'add', '.')
git(repo, 'commit', '-m', 'Historical')
old = git(repo, 'rev-parse', 'HEAD').decode().strip()
(repo / 'latest.txt').write_bytes(b'latest\n')
git(repo, 'add', '.')
git(repo, 'commit', '-m', 'Current')
latest = git(repo, 'rev-parse', 'HEAD').decode().strip()
data = root / 'data'
params = data / 'params/d'
params.mkdir(parents=True)
for key, value in {'AutomaticUpdates': b'1', 'IsOnroad': b'0', 'IsOffroad': b'1', 'KeepSetting': b'yes'}.items():
(params / key).write_bytes(value)
yield repo, data, old, latest
assert not root.exists(), 'Disposable index recovery fixture was not removed'
print('CLEANUP VERIFIED: ' + str(root))
def install(checkout):
repo, data, old, latest = checkout
return Path(installer.install(
repo, {'branch': 'Dom', 'commit': old, 'head': latest, 'pinned': True}, data_root=data,
check_parked=lambda: installer.require_parked(data), progress=lambda *args: None,
require_device_binaries=False)['backup'])
@pytest.mark.parametrize('changes', ['staged_only', 'partially_staged', 'binary_modes_symlink_add_remove'])
def test_recovery_round_trips_index_and_worktree_separately(checkout, changes):
repo, data, old, latest = checkout
original = (repo / 'tracked.txt').read_bytes()
if changes == 'staged_only':
(repo / 'tracked.txt').write_bytes(b'staged value absent from worktree\n')
git(repo, 'add', 'tracked.txt')
(repo / 'tracked.txt').write_bytes(original)
assert git(repo, 'diff', '--binary', 'HEAD') == b''
assert git(repo, 'diff', '--cached', '--binary', 'HEAD')
elif changes == 'partially_staged':
(repo / 'tracked.txt').write_bytes(b'ONE\ntwo\nthree\n')
git(repo, 'add', 'tracked.txt')
(repo / 'tracked.txt').write_bytes(b'ONE\ntwo\nTHREE\n')
else:
(repo / 'binary.bin').write_bytes(b'\x00staged binary\xfe')
(repo / 'binary.bin').chmod(0o755)
(repo / 'tracked-link').unlink()
(repo / 'tracked-link').symlink_to('binary.bin')
(repo / 'added.bin').write_bytes(b'\x00new staged file\xff')
git(repo, 'add', 'binary.bin', 'tracked-link', 'added.bin')
git(repo, 'rm', 'removed.txt')
(repo / 'binary.bin').write_bytes(b'\x00unstaged binary\xfd')
(repo / 'added.bin').write_bytes(b'\x00new staged plus working edit\xfd')
# A staged deletion may coexist with an untracked recreation of that path.
(repo / 'removed.txt').write_bytes(b'new untracked replacement\n')
(repo / 'tool.sh').write_bytes(b'#!/bin/sh\nexit 0\n')
(repo / 'tool.sh').chmod(0o755)
index_before = git(repo, 'ls-files', '--stage', '-z')
staged_before = git(repo, 'diff', '--cached', '--binary', 'HEAD')
working_before = git(repo, 'diff', '--binary')
status_before = git(repo, 'status', '--porcelain=v1', '--untracked-files=all')
backup = install(checkout)
assert git(repo, 'rev-parse', 'HEAD').decode().strip() == old
installer.restore(backup, check_parked=lambda: installer.require_parked(data))
assert git(repo, 'rev-parse', 'HEAD').decode().strip() == latest
assert git(repo, 'ls-files', '--stage', '-z') == index_before
assert git(repo, 'diff', '--cached', '--binary', 'HEAD') == staged_before
assert git(repo, 'diff', '--binary') == working_before
assert git(repo, 'status', '--porcelain=v1', '--untracked-files=all') == status_before
assert (data / 'params/d/KeepSetting').read_bytes() == b'yes'
if changes == 'binary_modes_symlink_add_remove':
assert (repo / 'tracked-link').readlink() == Path('binary.bin')
assert (repo / 'removed.txt').read_bytes() == b'new untracked replacement\n'
assert (repo / 'tool.sh').stat().st_mode & 0o111 == 0o111
def test_legacy_combined_patch_backup_still_restores_worktree(checkout):
repo, data, old, latest = checkout
(repo / 'tracked.txt').write_bytes(b'legacy combined local changes\n')
combined = git(repo, 'diff', '--binary', 'HEAD')
backup = install(checkout)
# Before index preservation, working.patch represented HEAD -> worktree.
(backup / 'index.patch').unlink(missing_ok=True)
(backup / 'working.patch').write_bytes(combined)
installer.restore(backup, check_parked=lambda: installer.require_parked(data))
assert git(repo, 'rev-parse', 'HEAD').decode().strip() == latest
assert git(repo, 'diff', '--binary', 'HEAD') == combined
assert git(repo, 'diff', '--cached', 'HEAD') == b''
def repository_snapshot(repo, data):
return {
'head': git(repo, 'rev-parse', 'HEAD'),
'index': git(repo, 'ls-files', '--stage', '-z'),
'status': git(repo, 'status', '--porcelain=v1', '--untracked-files=all'),
'working': (repo / 'tracked.txt').read_bytes(),
'params': {path.name: path.read_bytes() for path in (data / 'params/d').iterdir()},
}
def test_conflicted_merge_is_rejected_without_losing_index_stages(checkout):
repo, data, old, latest = checkout
git(repo, 'checkout', '-b', 'conflicting-side')
(repo / 'tracked.txt').write_bytes(b'other branch content\n')
git(repo, 'commit', '-am', 'Other side')
git(repo, 'checkout', 'Dom')
(repo / 'tracked.txt').write_bytes(b'current branch content\n')
git(repo, 'commit', '-am', 'Current side')
merge = subprocess.run(['git', '-C', str(repo), 'merge', 'conflicting-side'], capture_output=True)
assert merge.returncode == 1
assert git(repo, 'ls-files', '--unmerged')
before = repository_snapshot(repo, data)
with pytest.raises(installer.InstallError, match='unmerged|operation in progress'):
install(checkout)
assert repository_snapshot(repo, data) == before
assert not (data / 'starpilot/version-backups').exists()
# Also prove an unmerged index is rejected when MERGE_HEAD is absent (for
# example, externally constructed conflict stages or a damaged operation).
(repo / '.git/MERGE_HEAD').unlink()
with pytest.raises(installer.InstallError, match='unmerged'):
install(checkout)
assert repository_snapshot(repo, data) == before
assert not (data / 'starpilot/version-backups').exists()
@pytest.mark.parametrize('marker', [
'MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD',
'rebase-merge', 'rebase-apply', 'sequencer', 'BISECT_LOG',
])
def test_active_git_operation_markers_are_rejected_before_mutation(checkout, marker):
repo, data, old, latest = checkout
(repo / 'tracked.txt').write_bytes(b'valuable local edits\n')
marker_path = Path(git(repo, 'rev-parse', '--git-path', marker).decode().strip())
if not marker_path.is_absolute():
marker_path = repo / marker_path
if marker in ('rebase-merge', 'rebase-apply', 'sequencer'):
marker_path.mkdir()
sentinel = marker_path / 'rehearsal-sentinel'
else:
sentinel = marker_path
sentinel.write_bytes((latest + '\n').encode())
before = repository_snapshot(repo, data)
with pytest.raises(installer.InstallError, match='operation in progress'):
install(checkout)
assert repository_snapshot(repo, data) == before
assert sentinel.read_bytes() == (latest + '\n').encode()
assert not (data / 'starpilot/version-backups').exists()
@@ -0,0 +1,257 @@
"""Disposable local Git rehearsal; no device, network, reboot or real signals.
Run with pytest -c /dev/null --confcutdir=<this directory>. Git operations,
resolution, compatibility checks, checkout, backups and restore are real. Public
metadata transport is answered from a local bare origin. The installed OS file
is synthetic; ELF headers exercise validation only, not ARM execution. Recovery
uses the copied module in a fresh interpreter with process discovery disabled.
"""
import ast
import importlib.util
import inspect
from pathlib import Path
import sqlite3
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import pytest
MODULE_DIR = Path(__file__).resolve().parents[1]
def load_module(name, path=None):
spec = importlib.util.spec_from_file_location(name, path or MODULE_DIR / (name + '.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def git(repo, *args):
return subprocess.check_output(['git', '-C', str(repo), *args], stderr=subprocess.STDOUT, text=True).strip()
def write(root, name, content):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content if isinstance(content, bytes) else content.encode())
return path
@pytest.fixture
def rehearsal(monkeypatch):
# Constrain every Git subprocess, including the copied recovery helper, to
# local file transport even if a future fixture accidentally adds a remote.
monkeypatch.setenv('GIT_ALLOW_PROTOCOL', 'file')
monkeypatch.setenv('GIT_CONFIG_NOSYSTEM', '1')
monkeypatch.setenv('GIT_CONFIG_GLOBAL', '/dev/null')
with tempfile.TemporaryDirectory(prefix='galaxy-version-rehearsal-') as directory:
root = Path(directory)
installer = load_module('rehearsal_installer', MODULE_DIR / 'version_install.py')
history = load_module('rehearsal_history', MODULE_DIR / 'version_history.py')
seed = root / 'seed'
seed.mkdir()
git(seed, 'init', '-b', 'Dom')
git(seed, 'config', 'user.name', 'Local rehearsal')
git(seed, 'config', 'user.email', 'rehearsal@example.invalid')
files = {
'launch_env.sh': 'export AGNOS_VERSION="19.6.20"\n',
'launch_chffrplus.sh': '#!/bin/sh\n',
'common/params_keys.h': 'AutomaticUpdates TeslaWakeOnCAN\n',
'starpilot/common/starpilot_variables.py': 'automatic_updates = AutomaticUpdates\n',
'system/updated/updated.py': 'automatic_updates_enabled\n',
'system/hardware/tici/agnos.json': '[{"name":"system", "hash":"fixture"}]\n',
'feature.txt': 'historical\n',
'selfdrive/pandad/panda_firmware.py': inspect.getsource(installer._selected_firmware_name).replace(
'def _selected_firmware_name(', 'def get_selected_firmware_name(', 1),
'panda/board/obj/panda_tesla_wake.bin.signed': b'synthetic firmware fixture',
'panda/board/obj/panda_h7_tesla_wake.bin.signed': b'synthetic firmware fixture',
}
# Deliberately minimal headers: test the native preflight predicate only.
elf = bytearray(64)
elf[:6], elf[18:20] = b'\x7fELF\x02\x01', b'\xb7\x00'
for name in ('common/params_pyx.so', 'selfdrive/pandad/pandad', 'system/camerad/camerad'):
files[name] = bytes(elf)
for name, content in files.items():
write(seed, name, content)
git(seed, 'add', '.')
git(seed, 'commit', '-m', 'Historical fixture without Galaxy module')
old = git(seed, 'rev-parse', 'HEAD')
write(seed, 'feature.txt', 'latest\n')
write(seed, 'starpilot/system/the_galaxy/version_install.py', (MODULE_DIR / 'version_install.py').read_bytes())
git(seed, 'add', '.')
git(seed, 'commit', '-m', 'Latest fixture with Galaxy recovery')
latest = git(seed, 'rev-parse', 'HEAD')
origin, repo = root / 'origin.git', root / 'checkout'
git(root, 'clone', '--bare', str(seed), str(origin))
git(root, 'clone', '--depth=1', '--branch', 'Dom', origin.as_uri(), str(repo))
assert git(repo, 'rev-parse', '--is-shallow-repository') == 'true'
assert subprocess.run(['git', '-C', str(repo), 'cat-file', '-e', old], capture_output=True).returncode != 0
write(repo, 'feature.txt', 'valuable local changes\n')
write(repo, 'local-tool.txt', 'untracked local tool\n')
data = root / 'fake-data'
for name, value in {'IsOnroad': '0', 'IsOffroad': '1', 'AutomaticUpdates': '1',
'ExampleSetting': 'preserve me', 'TeslaWakeOnCAN': '1'}.items():
write(data, 'params/d/' + name, value)
write(data, 'safe_staging/finalized/.overlay_consistent', '')
version_file = write(root, 'VERSION', '19.6.20\n')
monkeypatch.setattr(installer, 'OS_VERSION_FILE', version_file)
monkeypatch.setattr(installer, '_processes', lambda: {})
def no_signal(*args):
raise AssertionError('A local rehearsal must never signal a real process')
monkeypatch.setattr(installer.os, 'kill', no_signal)
# Preserve the actual resolver/branch validation/ancestry checks. Only the
# external metadata endpoint is represented by our local origin transport.
monkeypatch.setattr(history, '_repository', lambda path: 'local-rehearsal')
requests = []
def local_metadata(url):
requests.append(url)
if url == 'local-rehearsal/branches/Dom':
return {'name': 'Dom', 'commit': {'sha': git(origin, 'rev-parse', 'refs/heads/Dom')}}
prefix = 'local-rehearsal/compare/'
assert url.startswith(prefix), url
chosen, head = url.removeprefix(prefix).split('...')
ancestor = git(origin, 'merge-base', chosen, head)
return {'status': 'ahead', 'merge_base_commit': {'sha': ancestor}}
monkeypatch.setattr(history, '_get_json', local_metadata)
tree = ast.parse((MODULE_DIR / 'the_galaxy.py').read_text())
fetch_node = next(node for node in tree.body if isinstance(node, ast.FunctionDef)
and node.name == '_build_shallow_fetch_commit_args')
namespace = {}
exec(compile(ast.Module(body=[fetch_node], type_ignores=[]), str(MODULE_DIR / 'the_galaxy.py'), 'exec'), namespace)
(data / 'starpilot').mkdir()
connection = sqlite3.connect(data / 'starpilot/model_stats.sqlite')
connection.execute('PRAGMA journal_mode=WAL')
connection.execute('PRAGMA wal_autocheckpoint=0')
connection.execute('CREATE TABLE events (id INTEGER PRIMARY KEY, note TEXT)')
connection.execute('INSERT INTO events VALUES (1, "before install")')
connection.commit()
assert Path(str(data / 'starpilot/model_stats.sqlite') + '-wal').stat().st_size > 0
state = SimpleNamespace(root=root, repo=repo, data=data, origin=origin, old=old, latest=latest,
installer=installer, history=history, connection=connection,
requests=requests, version_file=version_file,
fetch_args=namespace['_build_shallow_fetch_commit_args'])
try:
yield state
finally:
connection.close()
assert not root.exists(), 'Disposable install environment was not removed'
print('CLEANUP VERIFIED: ' + str(root))
def fetch_target(state, selection):
target = state.history.resolve_version(state.repo, 'Dom', selection)
git(state.repo, *state.fetch_args(target['commit']))
assert git(state.repo, 'rev-parse', 'FETCH_HEAD^{commit}') == target['commit']
return target
def install_target(state, target):
progress = []
with state.installer.suspend_updater() as control:
result = state.installer.install(
state.repo, target, data_root=state.data,
check_parked=lambda: state.installer.require_parked(state.data),
progress=lambda *args: progress.append(args), require_device_binaries=True)
assert control.restart
assert progress[-1][2] == 100
return Path(result['backup'])
def test_real_shallow_install_external_recovery_and_return_latest(rehearsal):
state = rehearsal
backup = install_target(state, fetch_target(state, state.old))
assert git(state.repo, 'rev-parse', 'HEAD') == state.old
assert (state.repo / 'feature.txt').read_text() == 'historical\n'
assert not (state.repo / 'starpilot/system/the_galaxy/version_install.py').exists()
assert state.installer.read_pin(state.repo, state.data)['commit'] == state.old
assert (state.data / 'params/d/AutomaticUpdates').read_bytes() == b'0'
assert (state.data / 'params/d/ExampleSetting').read_bytes() == b'preserve me'
assert (state.data / 'params/d/TeslaWakeOnCAN').read_bytes() == b'1'
assert not (state.data / 'safe_staging/finalized/.overlay_consistent').exists()
with sqlite3.connect(backup / 'model_stats.sqlite') as saved:
assert saved.execute('PRAGMA integrity_check').fetchone() == ('ok',)
assert saved.execute('SELECT * FROM events').fetchall() == [(1, 'before install')]
assert (backup / 'params/AutomaticUpdates').read_bytes() == b'1'
state.connection.execute('INSERT INTO events VALUES (2, "after install")')
state.connection.commit()
write(state.data, 'params/d/ExampleSetting', 'newer preference')
# Load only the saved helper in an isolated interpreter. Its real standalone
# restore needs no checkout imports; redirect its parked root and process
# discovery because CLI defaults intentionally refer to an actual device.
recovery_script = '''
import importlib.util, pathlib, sys
backup = pathlib.Path(sys.argv[1])
spec = importlib.util.spec_from_file_location('saved_recovery', backup / 'recover.py')
helper = importlib.util.module_from_spec(spec)
spec.loader.exec_module(helper)
helper._processes = lambda: {}
def no_signal(*args):
raise AssertionError('Recovery rehearsal must never signal a process')
helper.os.kill = no_signal
with helper.suspend_updater() as control:
restored = helper.restore(backup, check_parked=lambda: helper.require_parked(pathlib.Path(sys.argv[2])))
assert control.restart
print(restored['commit'])
'''
result = subprocess.run([sys.executable, '-I', '-c', recovery_script, str(backup), str(state.data)],
cwd=state.root, text=True, capture_output=True, timeout=60, check=True)
assert result.stdout.strip() == state.latest
assert git(state.repo, 'rev-parse', 'HEAD') == state.latest
assert (state.repo / 'feature.txt').read_text() == 'valuable local changes\n'
assert (state.repo / 'local-tool.txt').read_text() == 'untracked local tool\n'
assert state.installer.read_pin(state.repo, state.data) is None
assert state.connection.execute('SELECT * FROM events').fetchall() == [(1, 'before install'), (2, 'after install')]
assert (state.data / 'params/d/ExampleSetting').read_text() == 'newer preference'
install_target(state, fetch_target(state, state.old))
target = fetch_target(state, 'latest')
assert target == {'branch': 'Dom', 'commit': state.latest, 'head': state.latest, 'pinned': False}
assert state.requests.count('local-rehearsal/branches/Dom') == 3
install_target(state, target)
assert git(state.repo, 'rev-parse', 'HEAD') == state.latest
assert (state.repo / 'feature.txt').read_text() == 'latest\n'
assert state.installer.read_pin(state.repo, state.data) is None
assert not (state.data / 'starpilot/version_selection.json').exists()
assert (state.data / 'params/d/AutomaticUpdates').read_bytes() == b'0'
assert (state.data / 'params/d/ExampleSetting').read_text() == 'newer preference'
assert state.connection.execute('SELECT count(*) FROM events').fetchone() == (2,)
@pytest.mark.parametrize('guard', ['installed_os', 'firmware_variant', 'native_artifact'])
def test_native_preflight_guards_leave_local_source_and_data_untouched(rehearsal, guard):
state = rehearsal
target = fetch_target(state, state.old)
if guard == 'installed_os':
state.version_file.write_text('18.0\n')
expected = 'running device has AGNOS'
else:
# Create a deliberately incompatible target in the LOCAL origin and fetch
# it through the same exact-SHA path, retaining the previous local checkout.
seed = state.root / 'seed'
git(seed, 'checkout', '--detach', state.old)
name = ('panda/board/obj/panda_h7_tesla_wake.bin.signed' if guard == 'firmware_variant'
else 'system/camerad/camerad')
write(seed, name, b'')
git(seed, 'add', name)
git(seed, 'commit', '-m', 'Deliberately incompatible local target')
bad = git(seed, 'rev-parse', 'HEAD')
git(seed, 'push', str(state.origin), 'HEAD:refs/heads/guard-fixture')
git(state.repo, *state.fetch_args(bad))
target = dict(target, commit=bad)
expected = 'firmware settings' if guard == 'firmware_variant' else 'compatible ARM64 artifact'
with pytest.raises(state.installer.InstallError, match=expected):
install_target(state, target)
assert git(state.repo, 'rev-parse', 'HEAD') == state.latest
assert (state.repo / 'feature.txt').read_text() == 'valuable local changes\n'
assert (state.repo / 'local-tool.txt').read_text() == 'untracked local tool\n'
assert (state.data / 'params/d/AutomaticUpdates').read_bytes() == b'1'
assert (state.data / 'params/d/TeslaWakeOnCAN').read_bytes() == b'1'
assert (state.data / 'params/d/ExampleSetting').read_bytes() == b'preserve me'
assert state.connection.execute('SELECT * FROM events').fetchall() == [(1, 'before install')]
assert (state.data / 'safe_staging/finalized/.overlay_consistent').exists()
assert not (state.data / 'starpilot/version-backups').exists()
@@ -0,0 +1,125 @@
"""Exercise the real route/worker bodies without loading vehicle daemons."""
import ast
from contextlib import nullcontext
import importlib.util
from pathlib import Path
import threading
from types import SimpleNamespace
import time
from unittest.mock import Mock
from flask import Flask, jsonify, request
import pytest
MODULE_DIR = Path(__file__).resolve().parents[1]
def load_module(name):
spec = importlib.util.spec_from_file_location(name, MODULE_DIR / (name + '.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def server():
app = Flask(__name__)
history, installer = load_module('version_history'), load_module('version_install')
ns = dict(app=app, jsonify=jsonify, request=request, time=time, re=__import__('re'), Path=Path,
version_history=history, version_install=installer, threading=SimpleNamespace(Thread=Mock()),
_fast_update_lock=threading.Lock(), _fast_update_state={'running': False}, _FAST_UPDATE_TOTAL_STEPS=5,
_get_openpilot_root=lambda: '/repo', _is_valid_git_branch_name=lambda repo, branch: branch in ('Dom','StarPilot','feature/test'),
_get_fast_update_state=lambda: {}, _set_fast_update_state=Mock(), _set_fast_update_progress=Mock(),
_set_fast_update_error_state=Mock(), _remote_git_check_allowed=lambda: True,
_git_stdout=Mock(return_value='a'*40), _run_git_with_progress=Mock(return_value=(0,'')),
_build_shallow_fetch_commit_args=lambda sha: ['fetch','--depth=1','origin',sha],
_save_rollback_target=Mock(), update_starpilot_toggles=Mock(), HARDWARE=SimpleNamespace(reboot=Mock()),
_FAST_UPDATE_REBOOT_NOTICE_SECONDS=0)
history.resolve_version=Mock(return_value={'branch':'Dom','commit':'a'*40,'head':'b'*40,'pinned':True})
history.list_versions=Mock(return_value={'branch':'Dom','head':'b'*40,'page':1,'hasMore':False,'commits':[]})
installer.require_parked=Mock()
installer.updater_control=SimpleNamespace(restart_after_install=Mock())
installer.suspend_updater=lambda: nullcontext(installer.updater_control)
installer.check_repository_idle=Mock()
installer.install=Mock(return_value={'backup':'/data/test','commit':'a'*40,'branch':'Dom','pinned':True})
tree=ast.parse((MODULE_DIR/'the_galaxy.py').read_text())
names={'get_update_versions','run_version_install','_version_install_worker'}
found=[node for node in ast.walk(tree) if isinstance(node,ast.FunctionDef) and node.name in names]
assert len(found)==len(names),'Version routes and worker not implemented'
for node in sorted(found,key=lambda n:n.name):
exec(compile(ast.Module(body=[node],type_ignores=[]),str(MODULE_DIR/'the_galaxy.py'),'exec'),ns)
return app.test_client(),ns
def test_history_uses_branch_and_pinned_pagination(server):
client,ns=server
reply=client.get('/api/update/versions?branch=feature/test&page=2&head='+'b'*40)
assert reply.status_code==200
ns['version_history'].list_versions.assert_called_once_with('/repo','feature/test',page=2,head='b'*40)
@pytest.mark.parametrize('body',[{},[],{'branch':'Dom','commit':'a'*40}, {'branch':'Dom','commit':'HEAD','confirmed':True}, {'branch':'-evil','commit':'latest','confirmed':True}, {'branch':'Dom','commit':'latest','confirmed':'true'}])
def test_invalid_install_never_starts_worker(server,body):
client,ns=server
assert client.post('/api/update/version',json=body).status_code==400
ns['threading'].Thread.assert_not_called()
def test_install_requires_known_parked_state(server):
client,ns=server
ns['version_install'].require_parked.side_effect=ns['version_install'].InstallError('Park first')
assert client.post('/api/update/version',json={'branch':'Dom','commit':'latest','confirmed':True}).status_code==409
ns['threading'].Thread.assert_not_called()
def test_concurrent_install_rejected(server):
client,ns=server
ns['_fast_update_state']['running']=True
assert client.post('/api/update/version',json={'branch':'Dom','commit':'latest','confirmed':True}).status_code==409
ns['threading'].Thread.assert_not_called()
def test_accepted_selection_keeps_exact_sha(server):
client,ns=server
assert client.post('/api/update/version',json={'branch':'Dom','commit':'c'*40,'confirmed':True}).status_code==202
assert ns['_fast_update_state']['running'] is True
assert ns['threading'].Thread.call_args.kwargs['args']==('Dom','c'*40)
def test_worker_refuses_fetch_mismatch_before_checkout_or_reboot(server):
_,ns=server
ns['_git_stdout'].return_value='d'*40
ns['_version_install_worker']('Dom','a'*40)
ns['version_install'].install.assert_not_called()
ns['HARDWARE'].reboot.assert_not_called()
ns['_set_fast_update_error_state'].assert_called_once()
def test_worker_preserves_selected_sha_and_rechecks_parked(server):
_,ns=server
ns['_version_install_worker']('Dom','a'*40)
ns['version_history'].resolve_version.assert_called_once_with('/repo','Dom','a'*40)
assert ns['_run_git_with_progress'].call_args.args[1][-1]=='a'*40
assert ns['version_install'].install.call_args.args[1]['commit']=='a'*40
assert ns['version_install'].require_parked.call_count>=3
ns['HARDWARE'].reboot.assert_called_once()
def test_resolution_failure_does_not_fetch_or_change_source(server):
_,ns=server
ns['version_history'].resolve_version.side_effect=RuntimeError('Branch rewritten')
ns['_version_install_worker']('Dom','a'*40)
ns['_run_git_with_progress'].assert_not_called()
ns['version_install'].install.assert_not_called()
ns['HARDWARE'].reboot.assert_not_called()
def test_post_install_failure_reports_installed_revision_and_restarts_updater(server):
_,ns=server
ns['HARDWARE'].reboot.side_effect=RuntimeError('reboot denied')
ns['_version_install_worker']('Dom','a'*40)
ns['version_install'].updater_control.restart_after_install.assert_called_once()
assert 'is installed' in ns['_set_fast_update_error_state'].call_args.args[0]
ns['_set_fast_update_state'].assert_called_with(recoveryBackup='/data/test')
def test_install_failure_never_marks_success_or_reboots(server):
_,ns=server
ns['version_install'].install.side_effect=RuntimeError('disk full')
ns['_version_install_worker']('Dom','a'*40)
ns['version_install'].updater_control.restart_after_install.assert_not_called()
ns['HARDWARE'].reboot.assert_not_called()
def test_history_has_no_artificial_page_depth_cutoff(server):
client,ns=server
reply=client.get('/api/update/versions?branch=Dom&page=1201&head='+'b'*40)
assert reply.status_code==200
ns['version_history'].list_versions.assert_called_once_with('/repo','Dom',page=1201,head='b'*40)
+113
View File
@@ -77,6 +77,7 @@ from openpilot.starpilot.common.model_lab import (
)
from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH, THEME_COMPONENT_PARAMS
from openpilot.starpilot.common import param_profiles
from openpilot.starpilot.system.the_galaxy import version_history, version_install
from openpilot.starpilot.common.accel_profile import (
A_CRUISE_MAX_BP_CUSTOM,
CUSTOM_ACCEL_PROFILE_BREAKPOINT_PARAM_KEYS,
@@ -3014,6 +3015,60 @@ def _collect_fast_update_info(include_remote=True):
**rollback_data,
}
def _version_install_worker(branch, selection):
"""Resolve, back up and install one immutable revision after explicit confirmation."""
repo_path = str(_get_openpilot_root())
installed = None
try:
version_install.require_parked()
_set_fast_update_progress(1, "Resolving selected version", 10.0, branch)
target = version_history.resolve_version(repo_path, branch, selection)
version_install.require_parked()
# The normal updater owns staging for its whole lifetime. Pause only that
# daemon and its children, then refuse any still-held Git locks.
with version_install.suspend_updater() as updater:
version_install.require_parked()
version_install.check_repository_idle(repo_path)
rc, detail = _run_git_with_progress(
repo_path, _build_shallow_fetch_commit_args(target["commit"]),
timeout=240, step=1, label="Fetching selected version",
)
if rc:
raise version_install.InstallError(detail or "Unable to fetch the selected revision")
if _git_stdout(repo_path, ["rev-parse", "FETCH_HEAD^{commit}"]) != target["commit"]:
raise version_install.InstallError("Fetched revision does not match the selected version")
version_install.require_parked()
previous_branch = _git_stdout(repo_path, ["branch", "--show-current"])
previous_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
result = version_install.install(
repo_path, target, check_parked=version_install.require_parked,
progress=_set_fast_update_progress,
)
installed = result
updater.restart_after_install()
_save_rollback_target(repo_path, previous_branch, previous_commit)
update_starpilot_toggles()
version_install.require_parked()
_set_fast_update_progress(5, "Rebooting device", 100.0, "Selected revision installed. Automatic updates remain paused.")
# Keep the action locked until shutdown. Another request must not start
# during the reboot notice, or if the hardware reboot call returns.
_set_fast_update_state(
running=True, stage="rebooting", finishedAt=time.time(),
message=f"Installed {branch} @ {target['commit'][:10]}. Rebooting now.",
recoveryBackup=result["backup"],
)
time.sleep(_FAST_UPDATE_REBOOT_NOTICE_SECONDS)
version_install.require_parked()
HARDWARE.reboot()
except Exception as exception:
if installed is not None:
_set_fast_update_error_state(
"The selected version is installed, but the device could not finish restarting. Park and reboot to activate it.", exception,
)
_set_fast_update_state(recoveryBackup=installed["backup"])
else:
_set_fast_update_error_state("Selected version installation failed.", exception)
def _fast_update_worker():
started_at = time.time()
repo_path = str(_get_openpilot_root())
@@ -8555,6 +8610,7 @@ def setup(app):
**git_data,
"isOnroad": _safe_params_get_bool("IsOnroad"),
"automaticUpdates": _safe_params_get_bool("AutomaticUpdates"),
"versionPin": version_install.read_pin(repo_path),
"interruptedUpdateRecovery": _get_interrupted_update_recovery(repo_path, state_data),
"warning": "Fast update skips backup creation and finalization safeguards.",
}), 200
@@ -8622,6 +8678,61 @@ def setup(app):
"running": state_data.get("running", False),
}), 200
@app.route("/api/update/versions", methods=["GET"])
def get_update_versions():
branch = request.args.get("branch", "")
repo_path = str(_get_openpilot_root())
if not branch or len(branch) > 255 or not _is_valid_git_branch_name(repo_path, branch):
return jsonify({"error": "Choose a valid branch to browse its versions."}), 400
try:
page = int(request.args.get("page", "1"))
except (TypeError, ValueError):
return jsonify({"error": "Invalid history page."}), 400
head = request.args.get("head") or None
if page < 1 or (head is not None and not re.fullmatch(r"[0-9a-f]{40}", head)) or (page > 1 and head is None):
return jsonify({"error": "Invalid history page or history revision."}), 400
if not _remote_git_check_allowed():
return jsonify({"error": "Version history will be available once the device clock is synchronized."}), 503
try:
return jsonify(version_history.list_versions(repo_path, branch, page=page, head=head)), 200
except version_history.VersionHistoryError as exception:
return jsonify({"error": str(exception)}), 422
except Exception:
return jsonify({"error": "Unable to load version history. Please try again."}), 503
@app.route("/api/update/version", methods=["POST"])
def run_version_install():
payload = request.get_json(silent=True)
if not isinstance(payload, dict) or payload.get("confirmed") is not True:
return jsonify({"error": "Confirm the selected version before installing."}), 400
branch, commit = payload.get("branch"), payload.get("commit")
repo_path = str(_get_openpilot_root())
if not isinstance(branch, str) or not branch or len(branch) > 255 or not _is_valid_git_branch_name(repo_path, branch):
return jsonify({"error": "Invalid branch name."}), 400
if not isinstance(commit, str) or (commit != "latest" and not re.fullmatch(r"[0-9a-f]{40}", commit)):
return jsonify({"error": "Choose Latest or an exact version from this branch's history."}), 400
try:
version_install.require_parked()
except version_install.InstallError as exception:
return jsonify({"error": str(exception)}), 409
with _fast_update_lock:
if _fast_update_state.get("running"):
return jsonify({"error": "Another update action is already in progress."}), 409
_fast_update_state.update({
"running": True, "stage": "starting", "message": "Preparing selected version...",
"lastError": "", "lastBranch": branch, "lastMode": "version-install",
"startedAt": time.time(), "finishedAt": 0.0,
"progressStep": 1, "progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
"progressStepPercent": 0.0, "progressPercent": 0.0,
"progressLabel": "Preparing selected version", "progressDetail": "Checking branch history and compatibility...",
})
try:
threading.Thread(target=_version_install_worker, args=(branch, commit), daemon=True).start()
except Exception as exception:
_set_fast_update_error_state("Unable to start version installation.", exception)
return jsonify({"error": "Unable to start version installation."}), 503
return jsonify({"message": "Version installation started. The device will reboot when complete."}), 202
@app.route("/api/update/agnos_status", methods=["GET"])
def get_agnos_update_status():
state_data = _get_fast_update_state()
@@ -8671,6 +8782,8 @@ def setup(app):
def run_fast_update():
if params.get_bool("IsOnroad"):
return jsonify({"error": "Cannot run a fast update while driving."}), 409
if version_install.read_pin(str(_get_openpilot_root())):
return jsonify({"error": "A historical version is installed. Choose Latest in the version selector to update."}), 409
with _fast_update_lock:
if _fast_update_state.get("running"):
@@ -0,0 +1,483 @@
"""Bounded public GitHub metadata for the configured origin; never mutates Git."""
import copy
import hashlib
import os
import tempfile
import json
import re
import subprocess
import threading
import time
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from http.client import HTTPException
from math import ceil
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
PAGE_SIZE = 25
CACHE_TTL = 60
IMMUTABLE_CACHE_TTL = 6 * 60 * 60
MAX_CACHE_ENTRIES = 64
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
HTTP_TIMEOUT = 10
_BLOCK_SIZE = 100
_cache = OrderedDict()
_cache_lock = threading.Lock()
_api_backoff_until = 0.0
_api_retry_at = 0.0
VERSION_CACHE_TTL = 6 * 60 * 60
MAX_VERSION_CACHE_ENTRIES = 1024
MAX_VERSION_BYTES = 64 * 1024
_VERSION_WORKERS = 4
_version_cache = OrderedDict()
_version_slots = threading.BoundedSemaphore(_VERSION_WORKERS)
SNAPSHOT_CACHE_DIR = Path('/data/starpilot/cache/version-history')
MAX_SNAPSHOT_FILES = 256
MAX_SNAPSHOT_BYTES = 128 * 1024
MAX_SNAPSHOT_TOTAL_BYTES = 16 * 1024 * 1024
SNAPSHOT_RETENTION = 30 * 86400
_snapshot_lock = threading.Lock()
class VersionHistoryError(Exception):
"""An invalid selection or actionable public metadata lookup failure."""
class HistoryUnavailable(VersionHistoryError):
"""A transport or quota failure that permits a labelled browsing snapshot."""
class _GitHubRedirectHandler(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
target = urlsplit(newurl)
if target.scheme != 'https' or target.netloc != 'api.github.com':
raise VersionHistoryError('GitHub returned an unsupported metadata redirect; retry later.')
return super().redirect_request(req, fp, code, msg, headers, newurl)
_open_url = build_opener(_GitHubRedirectHandler()).open
class _RawGitHubRedirectHandler(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
target = urlsplit(newurl)
if target.scheme != 'https' or target.netloc != 'raw.githubusercontent.com':
raise VersionHistoryError('GitHub returned an unsupported version metadata redirect; retry later.')
return super().redirect_request(req, fp, code, msg, headers, newurl)
_open_raw_url = build_opener(_RawGitHubRedirectHandler()).open
def _is_quota_failure(error):
headers = error.headers or {}
wait = headers.get('Retry-After', '').strip()
return error.code == 429 or (error.code == 403 and
(headers.get('X-RateLimit-Remaining') == '0' or (wait.isdigit() and len(wait) <= 6)))
def _get_display_version(url):
"""Read only the numeric version literal; never import or execute remote code."""
request = Request(url, headers={'User-Agent': 'StarPilot-Galaxy-VersionPicker'})
try:
with _open_raw_url(request, timeout=HTTP_TIMEOUT) as response:
raw = response.read(MAX_VERSION_BYTES + 1)
if len(raw) > MAX_VERSION_BYTES:
raise VersionHistoryError('GitHub version metadata is too large; retry later.')
source = raw.decode('utf-8')
assignments = re.findall(r'^STARPILOT_DISPLAY_VERSION\b[^\r\n]*', source, re.MULTILINE)
if len(assignments) != 1:
raise ValueError()
match = re.fullmatch(r"""STARPILOT_DISPLAY_VERSION[ \t]*=[ \t]*(['"])((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))\1[ \t]*(?:#[^\r\n]*)?""", assignments[0])
if match is None:
raise ValueError()
return match.group(2)
except HTTPError as error:
error.close()
if error.code == 404:
return None # Older builds can predate the display-version file.
if error.code in (403, 429):
failure = HistoryUnavailable if _is_quota_failure(error) else VersionHistoryError
raise failure('GitHub version metadata access is limited; retry later.') from None
failure = HistoryUnavailable if 500 <= error.code < 600 else VersionHistoryError
raise failure('GitHub version metadata is unavailable; retry later.') from None
except (URLError, TimeoutError, OSError, HTTPException):
raise HistoryUnavailable('Cannot reach GitHub version metadata; check connectivity and retry.') from None
except (ValueError, UnicodeError):
raise VersionHistoryError('GitHub returned invalid version metadata; retry later.') from None
def _display_version(base, sha):
# The validated origin and full SHA make this an immutable public file URL.
repository = base.removeprefix('https://api.github.com/repos/')
url = f'https://raw.githubusercontent.com/{repository}/{sha}/selfdrive/ui/lib/starpilot_version.py'
# Share the limit across requests, and recheck the cache after acquiring a slot.
with _version_slots:
with _cache_lock:
entry = _version_cache.get(url)
if entry is not None and time.monotonic() - entry[0] < VERSION_CACHE_TTL:
_version_cache.move_to_end(url)
return entry[1]
value = _get_display_version(url)
with _cache_lock:
_version_cache[url] = (time.monotonic(), value)
_version_cache.move_to_end(url)
while len(_version_cache) > MAX_VERSION_CACHE_ENTRIES:
_version_cache.popitem(last=False)
return value
def _rate_limit_message(retry_at):
seconds = max(0, ceil(retry_at - time.time()))
if seconds >= 120:
suffix = f' in about {ceil(seconds / 60)} minutes'
elif seconds:
suffix = f' in {seconds} seconds'
else:
suffix = ' later'
return f'GitHub rate limit or public access restriction; retry{suffix}.'
def _get_json(url):
"""Fetch one bounded public API response. Kept separate for offline tests."""
global _api_backoff_until, _api_retry_at
with _cache_lock:
if time.monotonic() < _api_backoff_until:
raise HistoryUnavailable(_rate_limit_message(_api_retry_at))
request = Request(url, headers={'Accept': 'application/vnd.github+json',
'User-Agent': 'StarPilot-Galaxy-VersionPicker',
'X-GitHub-Api-Version': '2022-11-28'})
try:
with _open_url(request, timeout=HTTP_TIMEOUT) as response:
raw = response.read(MAX_RESPONSE_BYTES + 1)
if len(raw) > MAX_RESPONSE_BYTES:
raise VersionHistoryError('GitHub metadata response is too large; retry later.')
return json.loads(raw)
except HTTPError as error:
error.close()
if error.code in (403, 429):
headers = error.headers or {}
wait = headers.get('Retry-After', '').strip()
reset = headers.get('X-RateLimit-Reset', '').strip()
retry_at = 0.0
if wait.isdigit() and len(wait) <= 6:
retry_at = time.time() + int(wait)
elif reset.isdigit() and len(reset) <= 12 and int(reset) > time.time():
retry_at = float(reset)
# Share only a short quota backoff; unrelated 403 responses do not block
# other lookups. Valid cached responses remain available during backoff.
if _is_quota_failure(error):
delay = max(1, min(60, retry_at - time.time())) if retry_at else 60
with _cache_lock:
_api_backoff_until = time.monotonic() + delay
_api_retry_at = retry_at
failure = HistoryUnavailable if _is_quota_failure(error) else VersionHistoryError
raise failure(_rate_limit_message(retry_at)) from None
if error.code == 404:
raise VersionHistoryError('GitHub repository, branch, or commit not found; refresh and retry.') from None
failure = HistoryUnavailable if 500 <= error.code < 600 else VersionHistoryError
raise failure('GitHub metadata is unavailable; retry later.') from None
except (URLError, TimeoutError, OSError, HTTPException):
raise HistoryUnavailable('Cannot reach GitHub metadata; check connectivity and retry.') from None
except (ValueError, UnicodeError, RecursionError):
raise VersionHistoryError('GitHub returned invalid metadata; retry later.') from None
def _json(url, fresh=False, ttl=CACHE_TTL):
now = time.monotonic()
if not fresh:
with _cache_lock:
entry = _cache.get(url)
if entry is not None and now - entry[0] < ttl:
_cache.move_to_end(url)
return copy.deepcopy(entry[1])
value = _get_json(url)
with _cache_lock:
_cache[url] = (time.monotonic(), copy.deepcopy(value))
_cache.move_to_end(url)
while len(_cache) > MAX_CACHE_ENTRIES:
_cache.popitem(last=False)
return value
def _git(repo_path, *args):
try:
result = subprocess.run(['git', '-C', str(repo_path), *args], capture_output=True, text=True, timeout=5, check=True)
return result.stdout.strip()
except (subprocess.SubprocessError, OSError, ValueError):
raise VersionHistoryError('Cannot read Git origin or validate branch in this repository.') from None
def _branch(repo_path, branch):
if not isinstance(branch, str) or not branch or len(branch) > 255 or branch.startswith('-') or '@{' in branch:
raise VersionHistoryError('Invalid branch name.')
if _git(repo_path, 'check-ref-format', '--branch', branch) != branch:
raise VersionHistoryError('Invalid branch name.')
return branch
def _sha(value, label='commit'):
if not isinstance(value, str) or re.fullmatch(r'[0-9a-fA-F]{40}', value) is None:
raise VersionHistoryError(f'Invalid {label}; expected a full 40-character commit SHA.')
return value.lower()
def _repository(repo_path):
remote = _git(repo_path, 'config', '--get', 'remote.origin.url')
ssh = re.fullmatch(r'git@github\.com:([^/]+)/([^/]+)', remote)
if ssh:
owner, name = ssh.groups()
else:
try:
parsed = urlsplit(remote)
except ValueError:
raise VersionHistoryError('Configured origin must be a public GitHub repository.') from None
allowed = ((parsed.scheme == 'https' and parsed.netloc == 'github.com') or
(parsed.scheme == 'ssh' and parsed.netloc == 'git@github.com'))
parts = parsed.path.strip('/').split('/')
if not allowed or parsed.query or parsed.fragment or len(parts) != 2:
raise VersionHistoryError('Configured origin must be a public GitHub repository.')
owner, name = parts
name = name.removesuffix('.git')
if not re.fullmatch(r'[A-Za-z0-9-]+', owner) or not re.fullmatch(r'[A-Za-z0-9_.-]+', name) or name in ('.', '..'):
raise VersionHistoryError('Configured origin must be a public GitHub repository.')
return f'https://api.github.com/repos/{owner}/{name}'
def _head(base, branch, fresh=False):
data = _json(f'{base}/branches/{quote(branch, safe="")}', fresh=fresh)
try:
if data['name'] != branch:
raise ValueError()
return _sha(data['commit']['sha'], 'GitHub branch head')
except (KeyError, TypeError, ValueError):
raise VersionHistoryError('GitHub returned invalid branch metadata; retry later.') from None
def _ancestor(base, chosen, head):
if chosen == head:
return
data = _json(f'{base}/compare/{chosen}...{head}', ttl=IMMUTABLE_CACHE_TTL)
try:
valid = data['status'] in ('ahead', 'identical') and _sha(data['merge_base_commit']['sha'], 'GitHub merge base') == chosen
except (KeyError, TypeError, ValueError):
raise VersionHistoryError('GitHub returned invalid branch ancestry; retry later.') from None
if not valid:
raise VersionHistoryError('Selected commit is no longer in this branch history. Refresh the branch and choose again.')
def _block(base, head, page):
data = _json(f'{base}/commits?{urlencode({"sha": head, "per_page": _BLOCK_SIZE, "page": page})}', ttl=IMMUTABLE_CACHE_TTL)
if not isinstance(data, list) or len(data) > _BLOCK_SIZE:
raise VersionHistoryError('GitHub returned invalid commit history; retry later.')
rows = []
seen = set()
try:
for item in data:
sha = _sha(item['sha'], 'GitHub commit')
message = item['commit']['message']
date = item['commit']['committer']['date']
if not isinstance(message, str) or not message.strip() or not isinstance(date, str) or len(date) > 64 or sha in seen:
raise ValueError()
if datetime.fromisoformat(date.replace('Z', '+00:00')).tzinfo is None:
raise ValueError()
rows.append({'sha': sha, 'subject': message.splitlines()[0][:300], 'date': date})
seen.add(sha)
if page == 1 and (not rows or rows[0]['sha'] != head):
raise ValueError()
except (KeyError, IndexError, TypeError, ValueError):
raise VersionHistoryError('GitHub returned invalid commit metadata; retry later.') from None
return rows
def _snapshot_path(base, branch, page, requested_head):
key = json.dumps([base, branch, page, requested_head], separators=(',', ':')).encode()
return SNAPSHOT_CACHE_DIR / (hashlib.sha256(key).hexdigest() + '.json')
def _snapshot_time(value):
if not isinstance(value, str) or len(value) > 64:
raise ValueError()
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
if parsed.tzinfo is None:
raise ValueError()
return parsed.timestamp()
def _validate_snapshot(data, base, branch, page, requested_head):
if (data['repository'], data['branch'], data['page'], data['requestedHead']) != (base, branch, page, requested_head):
raise ValueError()
if type(page) is not int or page < 1 or type(data['page']) is not int or (page > 1 and requested_head is None):
raise ValueError()
if requested_head is not None:
_sha(requested_head)
stamp = _snapshot_time(data['cachedAt'])
if not -60 <= time.time() - stamp <= SNAPSHOT_RETENTION:
raise ValueError()
result = data['result']
head = _sha(result['head'])
if (result['branch'] != branch or type(result['page']) is not int or result['page'] != page or
type(result['hasMore']) is not bool or result.get('cached') or (requested_head is not None and head != requested_head)):
raise ValueError()
rows = result['commits']
if not isinstance(rows, list) or len(rows) > PAGE_SIZE:
raise ValueError()
selected, seen = [], set()
for row in rows:
sha = _sha(row['sha'])
if sha in seen or not isinstance(row['subject'], str) or not row['subject'].strip() or len(row['subject']) > 300:
raise ValueError()
_snapshot_time(row['date'])
clean = {'sha': sha, 'subject': row['subject'], 'date': row['date']}
if branch.lower() == 'starpilot' or 'version' in row:
version = row['version']
if version is not None and (not isinstance(version, str) or len(version) > 64 or
re.fullmatch(r'(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)', version) is None):
raise ValueError()
clean['version'] = version
selected.append(clean)
seen.add(sha)
if page == 1 and (not selected or selected[0]['sha'] != head):
raise ValueError()
return {'branch': branch, 'head': head, 'page': page, 'hasMore': result['hasMore'], 'commits': selected, 'cachedAt': data['cachedAt']}
def _load_history_snapshot(base, branch, page, requested_head):
"""Return a validated disk snapshot, or None; never validates installation."""
try:
path = _snapshot_path(base, branch, page, requested_head)
if SNAPSHOT_CACHE_DIR.is_symlink() or path.is_symlink():
return None
with path.open('rb') as stream:
raw = stream.read(MAX_SNAPSHOT_BYTES + 1)
if len(raw) > MAX_SNAPSHOT_BYTES:
return None
return _validate_snapshot(json.loads(raw), base, branch, page, requested_head)
except (OSError, ValueError, TypeError, KeyError, OverflowError, RecursionError, VersionHistoryError):
return None
def _prune_history_snapshots():
files = []
for path in SNAPSHOT_CACHE_DIR.glob('*.json'):
if re.fullmatch(r'[0-9a-f]{64}\.json', path.name) is None or path.is_symlink():
continue
stat = path.stat()
if stat.st_size > MAX_SNAPSHOT_BYTES or time.time() - stat.st_mtime > SNAPSHOT_RETENTION:
path.unlink()
else:
files.append((stat.st_mtime, path, stat.st_size))
files.sort()
total = sum(entry[2] for entry in files)
while len(files) > MAX_SNAPSHOT_FILES or total > MAX_SNAPSHOT_TOTAL_BYTES:
_, path, size = files.pop(0)
path.unlink()
total -= size
def _save_history_snapshot(base, branch, page, requested_head, result, saved_at=None):
"""Best-effort atomic snapshot. saved_at is the original timezone-aware ISO timestamp."""
temporary = None
try:
if saved_at is None:
saved_at = datetime.fromtimestamp(time.time(), timezone.utc).isoformat()
data = {'repository': base, 'branch': branch, 'page': page, 'requestedHead': requested_head,
'cachedAt': saved_at, 'result': result}
_validate_snapshot(data, base, branch, page, requested_head)
raw = json.dumps(data, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
if len(raw) > MAX_SNAPSHOT_BYTES:
return False
with _snapshot_lock:
if SNAPSHOT_CACHE_DIR.is_symlink():
return False
SNAPSHOT_CACHE_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
SNAPSHOT_CACHE_DIR.chmod(0o700)
fd, temporary = tempfile.mkstemp(prefix='.history-', suffix='.tmp', dir=SNAPSHOT_CACHE_DIR)
with os.fdopen(fd, 'wb') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(raw)
stamp = _snapshot_time(saved_at)
os.utime(temporary, (stamp, stamp))
os.replace(temporary, _snapshot_path(base, branch, page, requested_head))
temporary = None
_prune_history_snapshots()
return True
except (OSError, ValueError, TypeError, KeyError, OverflowError, RecursionError, VersionHistoryError):
return False
finally:
if temporary is not None:
try:
os.unlink(temporary)
except OSError:
pass
def list_versions(repo_path, branch, page=1, head=None):
"""Return 25 commits per page; later pages require the first page's head.
Browsing caches branch heads for up to 60 seconds, then rejects pinned heads
removed by a force push. Immutable SHA history is cached for six hours.
Transport/quota failures may return a labelled disk snapshot up to 30 days old.
Installation uses resolve_version, which always fetches the branch head fresh.
"""
branch = _branch(repo_path, branch)
if type(page) is not int or page < 1:
raise VersionHistoryError('Invalid history page; expected a positive integer.')
if page > 1 and head is None:
raise VersionHistoryError('A pinned history head is required for subsequent pages.')
pinned_head = _sha(head, 'history head') if head is not None else None
base = _repository(repo_path)
try:
result = _list_versions(base, branch, page, pinned_head)
except HistoryUnavailable as error:
snapshot = _load_history_snapshot(base, branch, page, pinned_head)
if snapshot is None:
raise
return dict(snapshot, cached=True, cacheReason=str(error))
_save_history_snapshot(base, branch, page, pinned_head, result)
return result
def _list_versions(base, branch, page, pinned_head):
current = _head(base, branch)
if pinned_head is not None:
_ancestor(base, pinned_head, current)
else:
pinned_head = current
block_page, offset = divmod((page - 1) * PAGE_SIZE, _BLOCK_SIZE)
rows = _block(base, pinned_head, block_page + 1)
end = offset + PAGE_SIZE
has_more = len(rows) > end
if end == _BLOCK_SIZE and len(rows) == _BLOCK_SIZE:
has_more = bool(_block(base, pinned_head, block_page + 2))
selected = rows[offset:end]
if branch.lower() == 'starpilot' and selected:
executor = ThreadPoolExecutor(max_workers=_VERSION_WORKERS)
try:
futures = {executor.submit(_display_version, base, row['sha']): index for index, row in enumerate(selected)}
for future in as_completed(futures):
index = futures[future]
selected[index] = dict(selected[index], version=future.result())
finally:
# Surface the first failure promptly and cancel queued work. At most four
# already-running downloads can finish under their existing HTTP timeout.
executor.shutdown(wait=False, cancel_futures=True)
return {'branch': branch, 'head': pinned_head, 'page': page, 'hasMore': has_more, 'commits': selected}
def resolve_version(repo_path, branch, commit):
"""Resolve latest freshly, or prove an exact commit belongs to that branch."""
branch = _branch(repo_path, branch)
pinned = commit != 'latest'
chosen = _sha(commit) if pinned else None
base = _repository(repo_path)
head = _head(base, branch, fresh=True)
if pinned:
_ancestor(base, chosen, head)
return {'branch': branch, 'commit': chosen if pinned else head, 'head': head, 'pinned': pinned}
@@ -0,0 +1,466 @@
"""Exact-revision checkout and local recovery. No vehicle-control dependencies.
A copy of this module is saved outside the checkout as recover.py before reset.
Run that copy with --restore while parked if the installed Galaxy lacks history.
"""
import argparse
import ast
import inspect
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import signal
import stat
import tempfile
import sqlite3
import subprocess
import tarfile
import time
OS_VERSION_FILE = Path("/VERSION")
_ACTIVE_UPDATER = ContextVar("version_install_updater", default=None)
class InstallError(RuntimeError):
pass
def git(repo, *args, binary=False, timeout=120):
result = subprocess.run(['git', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false', '-C', str(repo), *args],
capture_output=True, timeout=timeout, env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'})
if result.returncode:
raise InstallError(result.stderr.decode(errors='replace').strip()[-2000:] or 'Git operation failed')
return result.stdout if binary else result.stdout.decode(errors='replace').strip()
def atomic_write(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600
fd, name = tempfile.mkstemp(prefix=path.name + '.version-', dir=path.parent)
temporary = Path(name)
try:
with os.fdopen(fd, 'wb') as stream:
os.fchmod(stream.fileno(), mode)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
temporary.unlink(missing_ok=True)
def require_parked(data_root=Path('/data')):
params = Path(data_root) / 'params/d'
try:
parked = (params / 'IsOnroad').read_bytes() == b'0' and (params / 'IsOffroad').read_bytes() == b'1'
except OSError:
parked = False
if not parked:
raise InstallError('A confirmed parked device is required.')
def validate_target(target):
if not isinstance(target, dict):
raise InstallError('Invalid target')
branch, commit = target.get('branch'), target.get('commit')
if not isinstance(branch, str) or not branch or branch.startswith('-'):
raise InstallError('Invalid target branch')
check = subprocess.run(['git', 'check-ref-format', '--branch', branch], capture_output=True)
if check.returncode or not isinstance(commit, str) or not re.fullmatch(r'[0-9a-f]{40}', commit):
raise InstallError('An exact validated commit and branch are required')
def read_pin(repo, data_root=Path('/data')):
try:
value = json.loads((Path(data_root) / 'starpilot/version_selection.json').read_text())
if isinstance(value, dict) and value.get('commit') == git(repo, 'rev-parse', 'HEAD') and value.get('branch') == git(repo, 'branch', '--show-current'):
return value
except (OSError, ValueError, InstallError):
pass
return None
def _agnos_version(text):
match = re.search(r'^\s*(?:export\s+)?AGNOS_VERSION=[\"\']?([0-9][0-9A-Za-z._-]*)', text, re.M)
if not match:
raise InstallError('Unable to determine the selected version\'s AGNOS requirement')
return match.group(1)
def _selected_firmware_name(app_fn, remote_start, hkg_remote_start, ignore_ignition_line, tesla_wake=False):
if not remote_start and not hkg_remote_start and not ignore_ignition_line and not tesla_wake:
return app_fn
name_parts = ["panda_h7" if app_fn == "panda_h7.bin.signed" else "panda"]
if tesla_wake:
name_parts.extend(["tesla", "wake"])
elif hkg_remote_start:
name_parts.extend(["hkg", "remote"])
elif remote_start:
name_parts.append("remote")
if ignore_ignition_line:
name_parts.append("can_ignition_only")
return "_".join(name_parts) + ".bin.signed"
def _check_firmware_settings(repo, commit, data_root):
keys = ('TeslaWakeOnCAN', 'RemoteStartBootsComma', 'HKGRemoteStartBootsComma', 'IgnoreIgnitionLine',
'RemoteStart', 'HkgRemoteStart')
enabled = {key for key in keys if (Path(data_root) / 'params/d' / key).is_file()
and (Path(data_root) / 'params/d' / key).read_bytes() == b'1'}
if not enabled:
return
label = ', '.join(sorted(enabled))
if 'TeslaWakeOnCAN' in enabled and enabled & {'RemoteStartBootsComma', 'HKGRemoteStartBootsComma', 'RemoteStart', 'HkgRemoteStart'}:
raise InstallError('Tesla wake firmware cannot be combined with remote-start firmware')
try:
source = git(repo, 'show', commit + ':selfdrive/pandad/panda_firmware.py')
# Compare syntax with a reviewed pure selector; never execute selected source.
def normalized(node):
node.name = 'selector'
node.returns = None
for arg in node.args.args:
arg.annotation = None
return ast.dump(node, include_attributes=False)
selected = next(node for node in ast.parse(source).body
if isinstance(node, ast.FunctionDef) and node.name == 'get_selected_firmware_name')
expected = ast.parse(inspect.getsource(_selected_firmware_name)).body[0]
reviewed = {normalized(expected)}
# Current Dom adds this exact conflict guard to the earlier reviewed selector.
# Accept both known trees; never evaluate code from the selected revision.
expected.body.insert(0, ast.parse('if tesla_wake and (remote_start or hkg_remote_start):\n raise ValueError("Tesla wake firmware cannot be combined with remote-start firmware")').body[0])
reviewed.add(normalized(expected))
if normalized(selected) not in reviewed:
raise ValueError('unrecognized firmware selection logic')
target_keys = git(repo, 'show', commit + ':common/params_keys.h')
if any(key not in target_keys for key in enabled):
raise ValueError('target does not preserve the enabled parameter')
flags = (bool(enabled & {'RemoteStartBootsComma', 'RemoteStart'}),
bool(enabled & {'HKGRemoteStartBootsComma', 'HkgRemoteStart'}),
'IgnoreIgnitionLine' in enabled, 'TeslaWakeOnCAN' in enabled)
for app_fn in ('panda.bin.signed', 'panda_h7.bin.signed'):
filename = _selected_firmware_name(app_fn, *flags)
if not git(repo, 'show', commit + ':panda/board/obj/' + filename, binary=True):
raise ValueError('empty firmware image: ' + filename)
except (InstallError, SyntaxError, StopIteration, ValueError) as error:
raise InstallError(f'The selected revision cannot preserve enabled firmware settings ({label}): {error}') from error
def preflight(repo, commit, require_device_binaries=True, data_root=Path('/data')):
target_version = _agnos_version(git(repo, 'show', commit + ':launch_env.sh'))
if require_device_binaries:
try:
installed_version = OS_VERSION_FILE.read_text().strip()
except OSError as error:
raise InstallError('Cannot verify the installed AGNOS version') from error
if installed_version != target_version:
raise InstallError(f'The running device has AGNOS {installed_version}; selected revision requires {target_version}')
current_version = _agnos_version((Path(repo) / 'launch_env.sh').read_text())
if target_version != current_version:
raise InstallError(f'This revision requires AGNOS {target_version}; current software requires {current_version}. OS changes are not supported by historical installation.')
manifest = 'system/hardware/tici/agnos.json'
try:
target_manifest = json.loads(git(repo, 'show', commit + ':' + manifest))
current_manifest = json.loads((Path(repo) / manifest).read_text())
except (OSError, ValueError) as error:
raise InstallError('Cannot verify AGNOS compatibility') from error
if target_manifest != current_manifest:
raise InstallError('The selected revision has a different AGNOS firmware manifest. Install a revision compatible with the current OS.')
# Reject generations which would ignore the persistent update-pause setting.
if ('AutomaticUpdates' not in git(repo, 'show', commit + ':common/params_keys.h') or
'automatic_updates' not in git(repo, 'show', commit + ':starpilot/common/starpilot_variables.py') or
'automatic_updates_enabled' not in git(repo, 'show', commit + ':system/updated/updated.py')):
raise InstallError('This revision does not support pausing automatic updates safely')
git(repo, 'cat-file', '-e', commit + ':launch_chffrplus.sh')
_check_firmware_settings(repo, commit, data_root)
if require_device_binaries:
for name in ('common/params_pyx.so', 'selfdrive/pandad/pandad', 'system/camerad/camerad'):
data = git(repo, 'show', commit + ':' + name, binary=True)
if data[:4] != b'\x7fELF' or data[4:6] != b'\x02\x01' or data[18:20] != b'\xb7\x00':
raise InstallError(f'The selected revision lacks a compatible ARM64 artifact: {name}')
return {'agnos': target_version}
def check_repository_idle(repo):
for name in ('index.lock', 'shallow.lock', 'config.lock', 'packed-refs.lock', 'HEAD.lock',
'MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD',
'rebase-merge', 'rebase-apply', 'sequencer', 'BISECT_LOG'):
path = Path(git(repo, 'rev-parse', '--git-path', name))
if not path.is_absolute():
path = Path(repo) / path
if path.exists():
if name.endswith('.lock'):
raise InstallError(f'Repository is busy ({name}); wait for the existing Git operation to finish')
raise InstallError(f'Repository has a Git operation in progress ({name}); finish or abort it before installing or restoring')
# Ordinary patches cannot represent the multiple index stages of a conflict.
# Check independently of operation markers, which may be missing or stale.
if git(repo, 'ls-files', '--unmerged'):
raise InstallError('Repository has unmerged index entries; resolve them before installing or restoring')
def _check_submodules(repo):
# A superproject patch cannot preserve modified or untracked submodule files.
if not (Path(repo) / '.gitmodules').is_file():
return
if any(line.startswith(('+', 'U')) for line in git(repo, 'submodule', 'status', '--recursive').splitlines()):
raise InstallError('A submodule checkout differs from the recorded revision; save it before installing')
dirty = git(repo, 'submodule', 'foreach', '--quiet', '--recursive',
'git status --porcelain --untracked-files=all --ignore-submodules=none')
if dirty:
raise InstallError('A submodule has local changes; save them before installing')
def _backup(repo, target, data_root):
data_root, repo = Path(data_root), Path(repo).resolve()
folder = data_root / 'starpilot/version-backups' / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S') + '-' + os.urandom(3).hex())
params = data_root / 'params/d'
# HEAD -> index and index -> worktree are separate states. A combined HEAD
# diff can be empty even when the index contains valuable staged-only edits.
index_patch = git(repo, 'diff', '--cached', '--binary', 'HEAD', binary=True)
patch = git(repo, 'diff', '--binary', binary=True)
files = [name for name in git(repo, 'ls-files', '--others', '--exclude-standard', '-z', binary=True).decode().split('\0') if name]
for name in files:
if not stat.S_ISREG((repo / name).lstat().st_mode):
raise InstallError(f'Untracked recovery entry must be a regular file (no symlinks): {name}')
size = len(index_patch) + len(patch) + sum((repo / name).lstat().st_size for name in files)
if size > 128 * 1024 * 1024:
raise InstallError('Local source changes exceed the recovery backup limit (128 MiB)')
if shutil.disk_usage(data_root).free < size + 512 * 1024 * 1024:
raise InstallError('At least 512 MiB of free space beyond local changes is required for recovery')
folder.mkdir(parents=True, exist_ok=False)
atomic_write(folder / 'index.patch', index_patch)
atomic_write(folder / 'working.patch', patch)
with tarfile.open(folder / 'untracked.tar', 'w') as archive:
for name in files:
archive.add(repo / name, arcname=name, recursive=False)
shutil.copytree(params, folder / 'params', symlinks=False)
stats = data_root / 'starpilot/model_stats.sqlite'
if stats.is_file():
deadline = time.monotonic() + 60
def progress(*_):
if time.monotonic() > deadline:
raise InstallError('Statistics backup timed out; no source files were changed')
with sqlite3.connect(stats.as_uri() + '?mode=ro', uri=True, timeout=10) as source, sqlite3.connect(folder / 'model_stats.sqlite') as destination:
source.backup(destination, pages=256, progress=progress)
old = {'repo': str(repo), 'dataRoot': str(data_root), 'branch': git(repo, 'branch', '--show-current'),
'commit': git(repo, 'rev-parse', 'HEAD'), 'target': target, 'createdAt': datetime.now(timezone.utc).isoformat(),
'oldPin': read_pin(repo, data_root)}
if not old['branch']:
raise InstallError('Detached local checkout cannot be recovered by this installer')
git(repo, 'update-ref', 'refs/starpilot/version-backups/' + folder.name, old['commit'])
atomic_write(folder / 'recovery.json', json.dumps(old, indent=2).encode())
shutil.copyfile(__file__, folder / 'recover.py')
atomic_write(folder / 'README.txt', b'Park the car, then run: python3 recover.py --restore\nThis returns to the saved source and local edits. Current settings and statistics are kept.\nSaved params and statistics are available for manual recovery; they are not automatically overwritten.\n')
os.sync()
return folder
def _clear_staging(data_root):
# Invalidate the boot-time swap; never delete staged source or drive data.
(Path(data_root) / 'safe_staging/finalized/.overlay_consistent').unlink(missing_ok=True)
os.sync()
def restore(folder, *, check_parked, restore_data=False):
if restore_data:
raise InstallError('Settings/statistics restoration is manual to preserve newer data')
folder = Path(folder)
old = json.loads((folder / 'recovery.json').read_text())
repo, data_root = Path(old['repo']), Path(old['dataRoot'])
validate_target(old)
check_parked()
check_repository_idle(repo)
# Validate the whole archive before changing the checkout.
with tarfile.open(folder / 'untracked.tar') as archive:
for member in archive.getmembers():
if member.name.startswith('/') or '..' in Path(member.name).parts or not member.isfile():
raise InstallError('Unsafe recovery archive entry')
atomic_write(data_root / 'params/d/AutomaticUpdates', b'0')
updater = _ACTIVE_UPDATER.get()
if updater is not None:
updater.restart_after_install()
git(repo, 'checkout', '--force', '-B', old['branch'], old['commit'])
git(repo, 'reset', '--hard', old['commit'])
if (repo / '.gitmodules').is_file():
git(repo, 'submodule', 'sync', '--recursive')
git(repo, 'submodule', 'update', '--init', '--recursive', '--depth=1', timeout=240)
# Older backups contain only a combined HEAD -> worktree patch. New backups
# first restore staged content and modes, then apply only unstaged changes.
index_patch = folder / 'index.patch'
if index_patch.is_file() and index_patch.stat().st_size:
git(repo, 'apply', '--index', '--binary', str(index_patch))
patch = folder / 'working.patch'
if patch.stat().st_size:
git(repo, 'apply', '--binary', str(patch))
# Archive members come from the local Git untracked list. Reject path escape
# and links rather than letting a modified recovery archive overwrite /data.
with tarfile.open(folder / 'untracked.tar') as archive:
for member in archive.getmembers():
destination = repo / member.name
if member.name.startswith('/') or '..' in Path(member.name).parts or member.issym() or member.islnk():
raise InstallError('Unsafe recovery archive entry')
if member.isfile():
if not destination.resolve().is_relative_to(repo.resolve()):
raise InstallError('Recovery destination escapes the checkout')
destination.parent.mkdir(parents=True, exist_ok=True)
with archive.extractfile(member) as source:
atomic_write(destination, source.read())
destination.chmod(member.mode)
pin = data_root / 'starpilot/version_selection.json'
if old.get('oldPin'):
atomic_write(pin, json.dumps(old['oldPin']).encode())
else:
pin.unlink(missing_ok=True)
_clear_staging(data_root)
return old
def install(repo, target, *, data_root=Path('/data'), check_parked, progress, require_device_binaries=True):
repo, data_root = Path(repo), Path(data_root)
validate_target(target)
check_parked()
sha = target['commit']
if git(repo, 'rev-parse', sha + '^{commit}') != sha:
raise InstallError('Fetched revision does not match the selected commit')
progress(2, 'Checking compatibility', 100, sha[:10])
check_repository_idle(repo)
_check_submodules(repo)
preflight(repo, sha, require_device_binaries, data_root)
check_parked()
progress(3, 'Saving recovery backup', 0, 'Preserving local changes, settings and model statistics')
backup = _backup(repo, target, data_root)
check_parked()
atomic_write(data_root / 'params/d/AutomaticUpdates', b'0')
updater = _ACTIVE_UPDATER.get()
if updater is not None:
updater.restart_after_install()
_clear_staging(data_root)
try:
progress(4, 'Installing selected revision', 10, target['branch'] + ' @ ' + sha[:10])
git(repo, 'checkout', '--force', '-B', target['branch'], sha)
git(repo, 'reset', '--hard', sha)
git(repo, 'config', 'branch.' + target['branch'] + '.remote', 'origin')
git(repo, 'config', 'branch.' + target['branch'] + '.merge', 'refs/heads/' + target['branch'])
if git(repo, 'rev-parse', 'HEAD') != sha:
raise InstallError('Installed revision failed verification')
modules = repo / '.gitmodules'
if modules.is_file() and '[submodule ' in modules.read_text():
check_parked()
git(repo, 'submodule', 'sync', '--recursive')
git(repo, 'submodule', 'update', '--init', '--recursive', '--depth=1', timeout=240)
for name in ('.sconsign.dblite', 'cereal/gen'):
path = repo / name
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink(missing_ok=True)
pin = data_root / 'starpilot/version_selection.json'
if target['pinned']:
atomic_write(pin, json.dumps({'branch': target['branch'], 'commit': sha, 'installedAt': datetime.now(timezone.utc).isoformat(), 'backup': str(backup)}).encode())
else:
pin.unlink(missing_ok=True)
atomic_write(data_root / 'starpilot/last-version-recovery.json', json.dumps({'backup': str(backup)}).encode())
updater = _ACTIVE_UPDATER.get()
if updater is not None:
updater.restart_after_install()
progress(4, 'Installing selected revision', 100, 'Exact commit verified; automatic updates paused')
return {'backup': str(backup), 'branch': target['branch'], 'commit': sha, 'pinned': target['pinned']}
except Exception as error:
try:
restore(backup, check_parked=check_parked)
except Exception as recovery_error:
raise InstallError(f'Installation failed: {error}. Recovery required: {backup}/recover.py ({recovery_error})') from error
raise InstallError(f'Installation failed; previous source restored: {error}. Backup: {backup}') from error
def _is_updater(argv):
return any(arg in (b'system.updated.updated', b'openpilot.system.updated.updated') or
arg.endswith(b'/system/updated/updated.py') for arg in argv)
class UpdaterMaintenance:
def __init__(self):
self.restart = False
def restart_after_install(self):
self.restart = True
def _processes():
records = {}
for folder in Path('/proc').iterdir():
if not folder.name.isdigit():
continue
try:
fields = (folder / 'stat').read_text().rsplit(')', 1)[1].split()
records[int(folder.name)] = (int(fields[1]), fields[19], fields[0], (folder / 'cmdline').read_bytes().split(b'\0'))
except (OSError, ValueError, IndexError):
continue
return records
@contextmanager
def suspend_updater():
"""Suspend only the background updater and its children for maintenance."""
stopped = []
control = UpdaterMaintenance()
token = _ACTIVE_UPDATER.set(control)
try:
records = _processes()
parents = {pid for pid, (_, _, _, argv) in records.items() if _is_updater(argv)}
pending = parents
while pending:
for pid in pending:
entry = records.get(pid)
if entry and entry[2] not in ('T', 't'):
try:
os.kill(pid, signal.SIGSTOP)
stopped.append((pid, entry[1]))
except ProcessLookupError:
pass
records = _processes()
pending = {pid for pid, entry in records.items() if entry[0] in pending}
deadline = time.monotonic() + 2
while True:
records = _processes()
if all(pid not in records or records[pid][1] != start or records[pid][2] in ('T', 't', 'Z')
for pid, start in stopped):
break
if time.monotonic() >= deadline:
raise InstallError('Updater did not stop; no installation was started')
time.sleep(0.01)
yield control
finally:
_ACTIVE_UPDATER.reset(token)
records = _processes()
for pid, start in reversed(stopped):
if pid in records and records[pid][1] == start:
try:
# A stopped updater caches AutomaticUpdates. After source mutation,
# let manager restart it so it reads the persisted pause setting.
os.kill(pid, signal.SIGKILL if control.restart else signal.SIGCONT)
except ProcessLookupError:
pass
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--restore', action='store_true', required=True)
args = parser.parse_args()
saved = Path(__file__).resolve().parent
with suspend_updater() as updater:
result = restore(saved, check_parked=require_parked)
updater.restart_after_install()
print('Previous source restored. Settings and statistics retained. Reboot the device when ready.')