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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check
- run: npm test
- run: npm run build
- name: Verify immutable images and repository hygiene
run: ./scripts/verify-release-inputs.sh
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- run: npm ci
- run: npm run check && npm run build
- run: npm test
- run: ./scripts/verify-release-inputs.sh
- run: ./scripts/audit-build-dependencies.sh
- name: Initialize StartOS workspace
Expand Down
53 changes: 53 additions & 0 deletions STARTOS-UMBREL-PARITY-REVIEW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# StartOS / Umbrel parity review

Candidate: 0.2.2:5 (GitHub beta.6). Source review and local checks are not an appliance test.

## Applicable changes

- Runtime advanced from 9ac862a to be0f0b1, with its verified multi-architecture
OCI index pinned in release-images.json. This includes the Umbrel-era runtime
bootstrap and dashboard changes; it does not deploy any running node.
- Native SV2 enablement, port, public authority key, and optional LAN host are
now supplied to the dashboard. A StartOS action exposes connection guidance
independently of dashboard availability, without revealing the secret key.
- Private diagnostic access is explicitly opt-in. A service interface marked
private is not sufficient evidence that every browser visitor is trusted.
- Unreadable persisted identity/token/authority files no longer silently trigger
replacement on permission errors. The main volume backup still covers them.
- Health scripts now fail when curl fails, instead of returning success after
a pipeline error. A recorded pulse/relay timestamp no longer gets the
misleading idle suffix just because the accepted counter is zero.

## Not applicable

StartOS uses Actions > Configure GridPool and sdk.restart, not Umbrel's init
container or Razor setup redirect. Umbrel's preserved template migration is not
part of this package. Existing settings obtain safe defaults for new fields.

## Outstanding release gates

- Real StartOS install/upgrade/restart and miner accepted-share verification
remain untested for this candidate because the test appliance failed.
- Test interface address/port assignment on LAN and ensure it matches the miner
connection card. Browser proxy/Tor hostnames must not be used for the ASIC.
- Test backup/restore with persistent node ID and SV2 authority unchanged.
- Configure's address check is only a syntax screen; runtime validation remains
authoritative. Include a bad-checksum address in appliance acceptance and
verify no mining work is issued.
- The SV2 runtime OS libraries were refreshed in image sha-1e8adcd while
verifying the mining binary was unchanged. Publication requires the updated
image scan to pass; earlier PCRE2-affected image pins must not be reused.
- Health scripts still parse selected fields using sed. Missing fields mean
unavailable telemetry, not proof of node health; replace this with typed JSON
parsing in a future package/runtime health contract.

Do not distribute old .s9pk files left from earlier builds as this candidate.
Rebuild/checksum artifacts after review. No stable-release claim is made.

## Local validation

Passed: TypeScript check, ncc package JavaScript build, two executable shell
health regression tests (HTTP failure and multiline JSON), image digest and
architecture verification, and git whitespace checks. Dependency auditing
returned only the previously documented Start SDK build-tool advisories; this
is not a claim of zero advisories. No new .s9pk was built or deployed in this pass.
17 changes: 16 additions & 1 deletion instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,22 @@
1. Wait for Bitcoin Core or Bitcoin Knots to finish synchronizing.
2. Open **Actions > Configure GridPool** and enter a mainnet payout address.
3. Start GridPool.
4. Open **Properties > Native Stratum V2** for the miner endpoint.
4. Open **Actions > Connect a native SV2 miner** for the public Noise authority
key and instructions. Use the host and external port of the **Native
Stratum V2** interface, not the Web UI or Tor address. Preferred port: 34265.
5. Select native SV2 Standard/Noise on the ASIC, paste the public authority key,
and use a worker label (such as `miner`) to use your configured payout address.
Check accepted shares, not just a connected socket, before filming a demo.

In **Configure GridPool**, optionally set a miner-reachable LAN hostname/IP so
the dashboard's connection card does not infer it from a browser proxy address.
Setup is a StartOS action and restarts the service after saving; there is no
separate web setup form or web redirect to wait for.

For a trusted private installation, enable **Trust dashboard visitors** in that
action to remove the separate operator unlock and show read-only local details.
Leave it off if untrusted users can reach the service UI. This setting is not
authentication and does not enable administrative mutation APIs.

GridPool is non-custodial. Backups preserve node identity, consensus state,
native SV2 authority keys, and queued proofs.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "gridpool-startos",
"scripts": {
"test": "node --test scripts/*.test.mjs",
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
"check": "tsc --noEmit",
"prettier": "prettier --write startos"
Expand Down
8 changes: 4 additions & 4 deletions release-images.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"gridpool": {
"reference": "ghcr.io/gridlabs-science/boot-protocol:v0.2.2-beta.2",
"indexDigest": "sha256:8672c835b14f8fcad8a358e5b80ae945d6973b5039c706437b3b1c4bdff6f5d5"
"reference": "ghcr.io/gridlabs-science/boot-protocol:sha-be0f0b1",
"indexDigest": "sha256:c83601e3fd051b834b02376dbd7827652fa96826b9454050c5d2ae933f9475bc"
},
"sv2": {
"reference": "ghcr.io/gridlabs-science/gridpool-sv2-pool:sha-1151f92",
"indexDigest": "sha256:3de54e9c51953d2b5089ae90a25b9a0fa046c18c4ae0139b768b77a4cf0ed50e"
"reference": "ghcr.io/gridlabs-science/gridpool-sv2-pool:sha-1e8adcd",
"indexDigest": "sha256:894b04939da127188d97dd53aaf5e3a6514de959c827a6ba34161ed5a2fc6fb0"
}
}
45 changes: 45 additions & 0 deletions scripts/package-health.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { runInNewContext } from 'node:vm'
import { test } from 'node:test'

// Execute the actual shell snippets shipped in the package with a fake curl.
const source = readFileSync(new URL('../startos/main.ts', import.meta.url), 'utf8')
const scripts = [...source.matchAll(/(\[\s*'set -eu',[\s\S]*?\])\.join\('; '\)/g)]
.map((match) => runInNewContext(match[1]).join('; '))

test('both summary health scripts fail closed when HTTP fails', () => {
assert.equal(scripts.length, 2)
const dir = mkdtempSync(join(tmpdir(), 'gridpool-health-'))
try {
writeFileSync(join(dir, 'curl'), '#!/bin/sh\nexit 22\n', { mode: 0o700 })
for (const script of scripts) {
const result = spawnSync('sh', ['-c', script], { env: { ...process.env, PATH: `${dir}:${process.env.PATH}` } })
assert.equal(result.status, 22)
assert.equal(result.stdout.toString(), '')
}
} finally { rmSync(dir, { recursive: true, force: true }) }
})

test('network and pulse fields survive multiline JSON', () => {
const dir = mkdtempSync(join(tmpdir(), 'gridpool-health-'))
try {
writeFileSync(join(dir, 'curl'), '#!/bin/sh\nprintf "%s" "$FIXTURE"\n', { mode: 0o700 })
const env = { ...process.env, PATH: `${dir}:${process.env.PATH}`, FIXTURE: JSON.stringify({
peerCount: 5, currentTipBlockHeight: 961000, localMiningSourceCount: 1,
localMiningHashrateDisplay: '1 TH/s', bitcoinNotification: { rpc: { reachable: true, synced: true } },
localPulseAcceptedCount: 3, lastLocalPulseUtc: '2026-09-13T00:00:00Z',
lastSuccessfulOutboundRelayUtc: '2026-09-13T00:00:01Z', outboundRelayHealthy: true,
pulseProofsEnabled: true,
}, null, 2) }
const network = spawnSync('sh', ['-c', scripts[0]], { env, encoding: 'utf8' })
assert.equal(network.status, 0)
assert.match(network.stdout, /peers=5; tip=961000; local sources=1; hashrate=1 TH\/s; Bitcoin RPC reachable=true synced=true/)
const relay = spawnSync('sh', ['-c', scripts[1]], { env, encoding: 'utf8' })
assert.equal(relay.status, 0)
assert.match(relay.stdout, /accepted=3; last pulse=2026/)
} finally { rmSync(dir, { recursive: true, force: true }) }
})
20 changes: 18 additions & 2 deletions startos/actions/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ export const configure = sdk.Action.withInput(
visibility: 'enabled',
}),
InputSpec.of({
minerHost: Value.text({
name: 'Miner-reachable host (optional)',
description: 'LAN IP or hostname of this StartOS server, without a scheme or port. Use the Native Stratum V2 interface address, not a Tor or UI-proxy hostname.',
default: null,
required: false,
placeholder: '192.168.1.223',
}),
trustedPrivateDashboard: Value.toggle({
name: 'Trust dashboard visitors',
description: 'Show read-only operator details without a separate password. Enable only if every visitor to the service UI is trusted; this can expose local client and peer details. Does not enable administrative mutation APIs.',
default: false,
}),
payoutAddress: Value.text({
name: 'Mainnet payout address',
description:
Expand All @@ -23,12 +35,16 @@ export const configure = sdk.Action.withInput(
placeholder: 'bc1q...',
}),
}),
async () => (await settingsJson.read().once()) ?? { payoutAddress: '' },
async () => (await settingsJson.read().once()) ?? { payoutAddress: '', minerHost: '', trustedPrivateDashboard: false },
async ({ effects, input }) => {
if (!/^(bc1|1|3)[A-Za-z0-9]{20,90}$/.test(input.payoutAddress)) {
throw new Error('Enter a valid-looking mainnet Bitcoin address')
}
await settingsJson.write(effects, input)
const minerHost = (input.minerHost ?? '').trim()
if (minerHost && !/^[A-Za-z0-9.-]+$/.test(minerHost)) {
throw new Error('Use a LAN hostname or IPv4 address without a scheme, port, or path')
}
await settingsJson.write(effects, { ...input, minerHost })
await sdk.restart(effects)
},
)
36 changes: 36 additions & 0 deletions startos/actions/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { readFile } from 'node:fs/promises'
import { settingsJson } from '../fileModels/settings.json'
import { sdk } from '../sdk'

export const connect = sdk.Action.withoutInput(
'connect-miner',
async () => ({
name: 'Connect a native SV2 miner',
description: 'Show the mining port and public Noise authority key. No private keys are displayed.',
warning: null,
allowedStatuses: 'any',
group: 'Mining',
visibility: 'enabled',
}),
async () => {
const settings = await settingsJson.read().once()
const authority = await readFile('/media/startos/volumes/main/sv2/authority.env', 'utf8')
.catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return ''
throw new Error('Unable to read the saved SV2 authority')
})
const publicKey = authority.match(/^authority_public_key=([^\r\n]+)$/m)?.[1]
return {
version: '1' as const,
title: 'Connect a native SV2 miner',
message: 'Use the LAN address and external port shown by Interfaces > Native Stratum V2. A browser/Tor UI address is not necessarily reachable by your ASIC. Start GridPool once to generate the authority key. Use a worker label to use the configured payout address; this is not an SV1 endpoint.',
result: {
type: 'single' as const,
value: `Host: ${settings?.minerHost || 'See Native Stratum V2 interface'}\nPreferred port: 34265 (use the interface port if remapped)\nProtocol: Stratum V2 Standard / Noise\nAuthority public key: ${publicKey || 'Not generated yet'}\nUsername: miner\n`,
copyable: true,
qr: false,
masked: false,
},
}
},
)
3 changes: 2 additions & 1 deletion startos/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { sdk } from '../sdk'
import { configure } from './configure'
import { connect } from './connect'

export const actions = sdk.Actions.of().addAction(configure)
export const actions = sdk.Actions.of().addAction(configure).addAction(connect)
2 changes: 2 additions & 0 deletions startos/fileModels/settings.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ export const settingsJson = FileHelper.json(
{ base: sdk.volumes.main, subpath: '/settings.json' },
z.object({
payoutAddress: z.string().catch(''),
minerHost: z.string().default(''),
trustedPrivateDashboard: z.boolean().default(false),
}),
)
31 changes: 24 additions & 7 deletions startos/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,18 @@ export const main = sdk.setupMain(async ({ effects }) => {
const tokenPath = `${volumeRoot}/shared/local-adapter.token`
try {
await readFile(tokenPath)
} catch {
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new Error('Cannot read the stored adapter token; refusing to replace it')
}
await writeFile(tokenPath, randomBytes(32).toString('hex'), { mode: 0o600 })
}

const authorityPath = `${volumeRoot}/sv2/authority.env`
let authority = await readFile(authorityPath, 'utf8').catch(() => '')
let authority = await readFile(authorityPath, 'utf8').catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return ''
throw new Error('Cannot read the stored SV2 authority; refusing to replace it')
})
if (!authority) {
authority = await sdk.SubContainer.withTemp(
effects,
Expand Down Expand Up @@ -123,8 +129,10 @@ export const main = sdk.setupMain(async ({ effects }) => {
)
.map((key) => [key, existing[key] as string]),
)
} catch {
// First start has no identity yet. The node creates and persists it here.
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new Error('Cannot read the stored GridPool identity. Restore or repair the existing configuration; it will not be overwritten.')
}
}

const bootConfig = {
Expand Down Expand Up @@ -154,6 +162,11 @@ export const main = sdk.setupMain(async ({ effects }) => {
work_set_reserve_multiplier: 3,
local_adapter_token_file: '/data/shared/local-adapter.token',
local_sv2_api_url: 'http://127.0.0.1:34290/api/v1/global',
native_sv2_enabled: true,
native_sv2_public_host: settings.minerHost,
native_sv2_public_port: sv2Port,
native_sv2_authority_public_key: authorityPublic,
trusted_private_dashboard_enabled: settings.trustedPrivateDashboard,
enable_admin_api: false,
...persistedIdentity,
}
Expand Down Expand Up @@ -254,7 +267,9 @@ min_interval = 5
'sh',
'-c',
[
'json=$(curl -fsS --max-time 5 http://127.0.0.1:5000/api/network/summary | tr -d "\\r\\n")',
'set -eu',
'json=$(curl -fsS --max-time 5 http://127.0.0.1:5000/api/network/summary)',
'json=$(printf "%s" "$json" | tr -d "\\r\\n")',
'json_lc=$(printf "%s" "$json" | tr "[:upper:]" "[:lower:]")',
'peers=$(printf "%s" "$json_lc" | sed -nE \'s/.*"peercount"[[:space:]]*:[[:space:]]*([0-9]+).*/\\1/p\')',
'tip=$(printf "%s" "$json_lc" | sed -nE \'s/.*"currenttipblockheight"[[:space:]]*:[[:space:]]*([0-9]+).*/\\1/p\')',
Expand Down Expand Up @@ -283,7 +298,9 @@ min_interval = 5
'sh',
'-c',
[
'json=$(curl -fsS --max-time 5 http://127.0.0.1:5000/api/network/summary | tr -d "\\r\\n")',
'set -eu',
'json=$(curl -fsS --max-time 5 http://127.0.0.1:5000/api/network/summary)',
'json=$(printf "%s" "$json" | tr -d "\\r\\n")',
'json_lc=$(printf "%s" "$json" | tr "[:upper:]" "[:lower:]")',
'pulses=$(printf "%s" "$json_lc" | sed -nE \'s/.*"localpulseacceptedcount"[[:space:]]*:[[:space:]]*([0-9]+).*/\\1/p\')',
'lastPulse=$(printf "%s" "$json" | sed -nE \'s/.*"lastLocalPulseUtc"[[:space:]]*:[[:space:]]*"([^"]*)".*/\\1/p\')',
Expand All @@ -298,7 +315,7 @@ min_interval = 5
errorMessage: 'GridPool relay telemetry is unavailable',
message: (result) => {
const message = result.trim()
return message.includes('accepted=0')
return message.includes('accepted=0; last pulse=--; last outbound relay=--')
? `${message} (no pulse traffic yet; this is normal when idle)`
: message
},
Expand Down
4 changes: 2 additions & 2 deletions startos/manifest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ export const manifest = setupManifest({
images: {
gridpool: {
source: {
dockerTag: 'ghcr.io/gridlabs-science/boot-protocol:v0.2.2-beta.2',
dockerTag: 'ghcr.io/gridlabs-science/boot-protocol:sha-be0f0b1',
},
arch: ['x86_64', 'aarch64'],
},
sv2: {
source: {
dockerTag:
'ghcr.io/gridlabs-science/gridpool-sv2-pool:sha-1151f92',
'ghcr.io/gridlabs-science/gridpool-sv2-pool:sha-1e8adcd',
},
arch: ['x86_64', 'aarch64'],
},
Expand Down
4 changes: 2 additions & 2 deletions startos/versions/current.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'

export const current = VersionInfo.of({
version: '0.2.2:4',
version: '0.2.2:5',
releaseNotes: {
en_US:
'Early beta with V2.2 security hardening, native SV2 mining, authenticated UDP relay, and reproducible release artifacts.',
'Updates the runtime, advertises SV2 connection details, adds opt-in trusted private diagnostics, and preserves unreadable identity files for recovery.',
},
migrations: {
up: async () => {},
Expand Down
Loading