From 1844abe20b821b6472394fc4cd11697a7599a435 Mon Sep 17 00:00:00 2001 From: keegreil Date: Mon, 14 Sep 2026 06:37:18 -0400 Subject: [PATCH 1/2] fix: align StartOS onboarding and diagnostics with appliance runtime --- .github/workflows/check.yml | 1 + .github/workflows/release-candidate.yml | 1 + STARTOS-UMBREL-PARITY-REVIEW.md | 52 +++++++++++++++++++++++++ instructions.md | 17 +++++++- package.json | 1 + release-images.json | 4 +- scripts/package-health.test.mjs | 45 +++++++++++++++++++++ startos/actions/configure.ts | 20 +++++++++- startos/actions/connect.ts | 36 +++++++++++++++++ startos/actions/index.ts | 3 +- startos/fileModels/settings.json.ts | 2 + startos/main.ts | 31 +++++++++++---- startos/manifest/index.ts | 2 +- startos/versions/current.ts | 4 +- 14 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 STARTOS-UMBREL-PARITY-REVIEW.md create mode 100644 scripts/package-health.test.mjs create mode 100644 startos/actions/connect.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ef1555b..b9a6087 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index f1ece7c..50101c6 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -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 diff --git a/STARTOS-UMBREL-PARITY-REVIEW.md b/STARTOS-UMBREL-PARITY-REVIEW.md new file mode 100644 index 0000000..62cfc9f --- /dev/null +++ b/STARTOS-UMBREL-PARITY-REVIEW.md @@ -0,0 +1,52 @@ +# StartOS / Umbrel parity review + +Candidate: 0.1.0:20. 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 pinned SV2 image still has the previously reported PCRE2 vulnerability + scan findings. Rebuild/scan it before claiming supply-chain gates passed. +- 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. diff --git a/instructions.md b/instructions.md index dec852a..aaebc14 100644 --- a/instructions.md +++ b/instructions.md @@ -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. diff --git a/package.json b/package.json index 570756a..0fc4215 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/release-images.json b/release-images.json index eabda21..8137aa8 100644 --- a/release-images.json +++ b/release-images.json @@ -1,7 +1,7 @@ { "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", diff --git a/scripts/package-health.test.mjs b/scripts/package-health.test.mjs new file mode 100644 index 0000000..ab71dcd --- /dev/null +++ b/scripts/package-health.test.mjs @@ -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 }) } +}) diff --git a/startos/actions/configure.ts b/startos/actions/configure.ts index 2af59f9..4e4833c 100644 --- a/startos/actions/configure.ts +++ b/startos/actions/configure.ts @@ -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: @@ -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) }, ) diff --git a/startos/actions/connect.ts b/startos/actions/connect.ts new file mode 100644 index 0000000..c9463b2 --- /dev/null +++ b/startos/actions/connect.ts @@ -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, + }, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts index 99e118d..1e78c58 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -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) diff --git a/startos/fileModels/settings.json.ts b/startos/fileModels/settings.json.ts index 42c2dca..55006d1 100644 --- a/startos/fileModels/settings.json.ts +++ b/startos/fileModels/settings.json.ts @@ -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), }), ) diff --git a/startos/main.ts b/startos/main.ts index 6f94fd6..00ab9f6 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -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, @@ -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 = { @@ -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, } @@ -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\')', @@ -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\')', @@ -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 }, diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 1d805d9..1034fe9 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -14,7 +14,7 @@ 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'], }, diff --git a/startos/versions/current.ts b/startos/versions/current.ts index a3cb473..0c8e376 100644 --- a/startos/versions/current.ts +++ b/startos/versions/current.ts @@ -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 () => {}, From 987ab21fe80d00185385ca7018f86c30177e34c0 Mon Sep 17 00:00:00 2001 From: keegreil Date: Mon, 14 Sep 2026 06:39:25 -0400 Subject: [PATCH 2/2] release: pin refreshed SV2 image for StartOS beta.6 --- STARTOS-UMBREL-PARITY-REVIEW.md | 7 ++++--- release-images.json | 4 ++-- startos/manifest/index.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/STARTOS-UMBREL-PARITY-REVIEW.md b/STARTOS-UMBREL-PARITY-REVIEW.md index 62cfc9f..614d484 100644 --- a/STARTOS-UMBREL-PARITY-REVIEW.md +++ b/STARTOS-UMBREL-PARITY-REVIEW.md @@ -1,6 +1,6 @@ # StartOS / Umbrel parity review -Candidate: 0.1.0:20. Source review and local checks are not an appliance test. +Candidate: 0.2.2:5 (GitHub beta.6). Source review and local checks are not an appliance test. ## Applicable changes @@ -34,8 +34,9 @@ part of this package. Existing settings obtain safe defaults for new fields. - 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 pinned SV2 image still has the previously reported PCRE2 vulnerability - scan findings. Rebuild/scan it before claiming supply-chain gates passed. +- 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. diff --git a/release-images.json b/release-images.json index 8137aa8..c70400d 100644 --- a/release-images.json +++ b/release-images.json @@ -4,7 +4,7 @@ "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" } } diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 1034fe9..85b8366 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -21,7 +21,7 @@ export const manifest = setupManifest({ 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'], },