From 7b91c56535b01ed480a67062e41998897f1544d4 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:52:25 -0500 Subject: [PATCH 1/5] Honor retry budgets and retain initial production sync errors --- scripts/accept-hot-sync-production.mjs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/accept-hot-sync-production.mjs b/scripts/accept-hot-sync-production.mjs index ebfc91c..8b844bc 100644 --- a/scripts/accept-hot-sync-production.mjs +++ b/scripts/accept-hot-sync-production.mjs @@ -20,7 +20,7 @@ mkdirSync(home,{recursive:true,mode:0o700}); const privatize=dir=>{chmodSync(dir,0o700);for(const e of readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);assert(!e.isSymbolicLink(),'Fixture artifacts must not contain symlinks');if(e.isDirectory())privatize(p);else chmodSync(p,0o600);}};privatize(home); const originalFetch=globalThis.fetch;let offline=false; globalThis.fetch=(...args)=>offline?Promise.reject(new Error('fixture offline transport')):originalFetch(...args); -const clients=[],managers=[],checks=[]; +const clients=[],managers=[],checks=[],diagnostics=[]; const make=(stateDir=home,key=nsec,name=profileName)=>{ const d=nip19.decode(key),pub=nip19.npubEncode(getPublicKey(d.data)),client=new HostedOnlyBotsClient({apiBase:origin,npub:pub,nsec:key}); const m=new HotSyncManager({stateDir,origin,npub:pub,nsec:key,profileName:name},client);m.link('/synthetic-crosshead-project',`hn-fixture-${run}`);clients.push(client);managers.push(m);return m; @@ -28,7 +28,21 @@ const make=(stateDir=home,key=nsec,name=profileName)=>{ const findings=m=>[...new Map(m.read().scopes.flatMap(s=>JSON.parse(s.content).continuityCheckpoints).map(c=>[c.id,c])).values()]; const capture=(m,id)=>{const begin=performance.now();m.capture('codex',{id:`${run}:${id}`,runtime:'codex',workspaceId:workspace,occurredAt:new Date().toISOString(),goal:'Synthetic cross-machine launch acceptance',summary:`Synthetic ${id}; canonical duration zero is valid, negatives rejected.`});return performance.now()-begin;}; const has=(m,id)=>findings(m).some(c=>c.id===`${run}:${id}`); -const sync=async(m,predicate)=>{const deadline=Date.now()+90000;let last;do{try{await m.flush(false);if(predicate())return;}catch(error){last=error;}await new Promise(r=>setTimeout(r,1500));}while(Date.now(){ + const deadline=performance.now()+90000;let last,attempt=0; + do{ + let wait=1500; + try{await m.flush(false);if(predicate())return;} + catch(error){ + last=error;wait=Math.max(wait,Number(error.retryAfterMs)||0); + if(diagnostics.length<20)diagnostics.push({phase,attempt,status:error.status??null,code:error.code??null,message:String(error.message).slice(0,180),retryAfterMs:wait,clientTime:new Date().toISOString(),node:process.version}); + if(String(error.message).startsWith('Hosted auth challenge'))throw error; + } + attempt++;const remaining=deadline-performance.now();if(wait>=remaining)break; + await new Promise(r=>setTimeout(r,wait)); + }while(performance.now()m.status().scopes.find(s=>s.scope.startsWith('ws-codex-'))?.scope; let m, primaryFailure=null; try{ @@ -59,7 +73,7 @@ try{ const result={phase,platform:process.platform,run,npub,checks,pending:m.status().pendingCount,scopes:m.status().scopes.map(s=>({scope:s.scope,epoch:s.epoch,revision:s.remoteRevision,discarded:s.discardedByReset}))}; writeFileSync(path.join(home,`result-${phase}.json`),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result)); }catch(error){primaryFailure=error;throw error;}finally{ - const failures=[];offline=false; + const failures=[];offline=false;writeFileSync(path.join(home,`diagnostics-${phase}.json`),JSON.stringify({phase,diagnostics},null,2)+'\n');if(diagnostics.length)console.error(JSON.stringify({phase,diagnostics})); const stopped=await Promise.allSettled(managers.map(m=>m.stop()));for(const result of stopped)if(result.status==='rejected')failures.push('manager stop failed'); for(const [index,client] of clients.entries()){ const session=client.session();if(!session)continue; From b432962e383ef385582e310e6b675da5b9b2752f Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:54:44 -0500 Subject: [PATCH 2/5] Validate auth lifetime independently of allowed clock skew --- scripts/accept-hot-sync-production.mjs | 11 +++++++- src/runtime/hosted-client.ts | 2 +- tests/runtime/hosted-client-security.test.ts | 27 +++++++++++++++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/scripts/accept-hot-sync-production.mjs b/scripts/accept-hot-sync-production.mjs index 8b844bc..934511b 100644 --- a/scripts/accept-hot-sync-production.mjs +++ b/scripts/accept-hot-sync-production.mjs @@ -19,7 +19,16 @@ const npub=nip19.npubEncode(getPublicKey(decoded.data)); mkdirSync(home,{recursive:true,mode:0o700}); const privatize=dir=>{chmodSync(dir,0o700);for(const e of readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);assert(!e.isSymbolicLink(),'Fixture artifacts must not contain symlinks');if(e.isDirectory())privatize(p);else chmodSync(p,0o600);}};privatize(home); const originalFetch=globalThis.fetch;let offline=false; -globalThis.fetch=(...args)=>offline?Promise.reject(new Error('fixture offline transport')):originalFetch(...args); +globalThis.fetch=async(...args)=>{ + if(offline)throw new Error('fixture offline transport'); + const response=await originalFetch(...args); + if(new URL(String(args[0])).pathname==='/v1/auth/challenge'&&response.ok){ + try{const body=await response.clone().json(),challenge=JSON.parse(Buffer.from(body.challenge,'base64url').toString('utf8')); + if(diagnostics.length<20)diagnostics.push({phase,kind:'challenge-clock',issuedAheadMs:Date.parse(challenge.issued_at)-Date.now(),lifetimeMs:Date.parse(challenge.expires_at)-Date.parse(challenge.issued_at)}); + }catch{} + } + return response; +}; const clients=[],managers=[],checks=[],diagnostics=[]; const make=(stateDir=home,key=nsec,name=profileName)=>{ const d=nip19.decode(key),pub=nip19.npubEncode(getPublicKey(d.data)),client=new HostedOnlyBotsClient({apiBase:origin,npub:pub,nsec:key}); diff --git a/src/runtime/hosted-client.ts b/src/runtime/hosted-client.ts index eab9638..f9e7ff9 100644 --- a/src/runtime/hosted-client.ts +++ b/src/runtime/hosted-client.ts @@ -115,7 +115,7 @@ const decodeAuthChallenge = (input: { } if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || !Number.isFinite(responseExpiresAt) || expiresAt !== responseExpiresAt || issuedAt > now + 30_000 || issuedAt < now - 5 * 60_000 || - expiresAt <= now || expiresAt > now + 5 * 60_000 || expiresAt <= issuedAt) { + expiresAt <= now || expiresAt - issuedAt > 5 * 60_000 || expiresAt <= issuedAt) { throw new Error("Hosted auth challenge timestamps are invalid"); } return bytes; diff --git a/tests/runtime/hosted-client-security.test.ts b/tests/runtime/hosted-client-security.test.ts index 732b604..9f09c34 100644 --- a/tests/runtime/hosted-client-security.test.ts +++ b/tests/runtime/hosted-client-security.test.ts @@ -2,12 +2,13 @@ import { createServer, type Server } from "node:http"; import { schnorr } from "@noble/curves/secp256k1"; import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { HostedOnlyBotsClient } from "../../src/runtime/hosted-client"; const servers: Server[] = []; afterEach(async () => { + vi.restoreAllMocks(); await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); }); @@ -99,6 +100,30 @@ describe("hosted client security boundaries", () => { expect(await schnorr.verify(receivedSignature, challengeBytes, getPublicKey(signer.secret))).toBe(true); }); + it.each([ + { skew: 20_000, lifetime: 300_000, valid: true }, + { skew: -20_000, lifetime: 300_000, valid: true }, + { skew: 31_000, lifetime: 60_000, valid: false }, + { skew: 0, lifetime: 300_001, valid: false }, + { skew: -310_000, lifetime: 300_000, valid: false }, + { skew: -100_000, lifetime: 60_000, valid: false }, + ])("bounds challenge lifetime independently of tolerated clock skew: $skew ms / $lifetime ms", async ({skew,lifetime,valid}) => { + const now=Date.now(),signer=identity();let base="",verifies=0; + vi.spyOn(Date,"now").mockReturnValue(now); + base=await listen(async(request,response)=>{ + if(request.url==="/v1/auth/challenge"){ + const issued=new Date(now+skew).toISOString(),expires=new Date(now+skew+lifetime).toISOString(); + const challenge={version:"crosshead_auth_v1",purpose:"crosshead_hosted_auth",audience:base,npub:signer.npub,nonce:Buffer.alloc(24,3).toString("base64url"),issued_at:issued,expires_at:expires}; + return json(response,200,{challenge:Buffer.from(JSON.stringify(challenge)).toString("base64url"),expires_at:expires}); + } + verifies++;return json(response,200,{session_token:"session-test",expires_at:new Date(now+3600000).toISOString(),scopes:[]}); + }); + const client=new HostedOnlyBotsClient({apiBase:base,npub:signer.npub,nsec:signer.nsec}); + if(valid)await expect(client.authenticate()).resolves.toMatchObject({sessionToken:"session-test"}); + else await expect(client.authenticate()).rejects.toThrow("timestamps are invalid"); + expect(verifies).toBe(valid?1:0); + }); + it("never uses the hosted service for handoff state or verification", async () => { const signer = identity(); let requests = 0; From 0ed60cf266011e0a10496d1dbe043c5e1e4b8c8d Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:55:49 -0500 Subject: [PATCH 3/5] Exercise published installer and alpha6 upgrades on both coding hosts --- .github/workflows/coding-hosts.yml | 7 ++ scripts/smoke-coding-hosts.mjs | 25 +++- tests/fixtures/published-alpha6-install.sh | 139 +++++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/published-alpha6-install.sh diff --git a/.github/workflows/coding-hosts.yml b/.github/workflows/coding-hosts.yml index 1fc8d00..9d9e0b5 100644 --- a/.github/workflows/coding-hosts.yml +++ b/.github/workflows/coding-hosts.yml @@ -2,6 +2,12 @@ name: Coding host customer journey on: workflow_dispatch: + inputs: + install-source: + description: Candidate archive or deployed public installer plus alpha.6 upgrade + type: choice + options: [candidate, public] + default: candidate pull_request: paths: - 'src/runtime/**' @@ -38,6 +44,7 @@ jobs: run: npm install --prefix "$RUNNER_TEMP/coding-hosts" --no-audit --no-fund @openai/codex@0.164.0 @anthropic-ai/claude-code@2.1.319 - name: Exercise clean candidate install and real host hooks env: + CROSSHEAD_TEST_INSTALL_SOURCE: ${{ inputs.install-source || 'candidate' }} CROSSHEAD_TEST_CLAUDE_BIN: ${{ runner.temp }}/coding-hosts/node_modules/.bin/claude CROSSHEAD_TEST_CODEX_BIN: ${{ runner.temp }}/coding-hosts/node_modules/.bin/codex run: node scripts/smoke-coding-hosts.mjs diff --git a/scripts/smoke-coding-hosts.mjs b/scripts/smoke-coding-hosts.mjs index 00973af..05feb91 100644 --- a/scripts/smoke-coding-hosts.mjs +++ b/scripts/smoke-coding-hosts.mjs @@ -28,6 +28,8 @@ const env = { PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`, TMP CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', DISABLE_AUTOUPDATER: '1', DISABLE_TELEMETRY: '1', ANTHROPIC_API_KEY: 'fixture-only', ANTHROPIC_AUTH_TOKEN: 'fixture-only', FIXTURE_API_KEY: 'fixture-only' }; let stage = 'capture', requests = [], cleanup = async () => {}; +const publicInstall=process.env.CROSSHEAD_TEST_INSTALL_SOURCE==='public'; +let publishedCli=null; const marker = 'duration-fixture-8427'; const finding = `Finding ${marker}: serialize durations as decimal nanosecond strings. Zero is valid; negative durations are rejected. Do not convert to floating-point milliseconds. Next validate the transport round trip.`; const server = createServer(async (req, res) => { @@ -83,6 +85,27 @@ try { await tar.x({ file: archive, cwd: installed }); results.hosts = { claude: (await run('claude-version', nativeClaude, ['--version'])).trim(), codex: (await run('codex-version', nativeCodex, ['--version'])).trim() }; await run('runtime-dependencies', 'npm', ['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], installed); + if(publicInstall){ + const script=await (await fetch('https://crosshead.sh/install',{signal:AbortSignal.timeout(30000)})).text(); + assert(script.includes(`RELEASE_ID="${manifest.releaseId}"`),'Production installer is not serving this release candidate'); + const publishedScript=path.join(root,'public-install.sh');writeFileSync(publishedScript,script); + const freshHome=path.join(root,'fresh-public');env.CROSSHEAD_HOME=env.ONLYBOTS_HOME=freshHome; + await run('public-fresh-install','bash',[publishedScript]); + const freshCli=path.join(freshHome,'lib/runtime',manifest.releaseId,'dist/runtime/cli.js'); + await run('public-fresh-core',process.execPath,[freshCli,'install','--home',freshHome]); + const status=JSON.parse(await run('public-fresh-status',process.execPath,[freshCli,'status','--home',freshHome,'--json'])); + assert.equal(status.coreVersion,manifest.version); + env.CROSSHEAD_HOME=env.ONLYBOTS_HOME=home; + const baseline=path.resolve('tests/fixtures/published-alpha6-install.sh'); + await run('published-alpha6-install','bash',[baseline]); + const match=readFileSync(baseline,'utf8').match(/^RELEASE_ID="([^"]+)"/m);assert(match); + const oldCli=path.join(home,'lib/runtime',match[1],'dist/runtime/cli.js'); + await run('published-alpha6-core',process.execPath,[oldCli,'install','--home',home]); + await run('public-alpha6-to-candidate-update',process.execPath,[oldCli,'update','--home',home,'--base-url','https://crosshead.sh','--sha256',manifest.artifacts.install.sha256,'--no-restart']); + publishedCli=path.join(home,'lib/runtime',manifest.releaseId,'dist/runtime/cli.js'); + assert.equal(JSON.parse(await run('public-updated-status',process.execPath,[publishedCli,'status','--home',home,'--json'])).coreVersion,manifest.version); + results.checks.push('actual public installer fresh install', 'actual alpha.6 installation updated using independently built candidate digest'); + } const fromInstalled = createRequire(path.join(installed, 'package.json')); const { generateSecretKey, getPublicKey, nip19 } = fromInstalled('nostr-tools'); const persistence = fromInstalled('./dist/runtime/runtime-persistence.js'); @@ -94,7 +117,7 @@ try { persistence.saveSecret(paths, 'default', { nsec: nip19.nsecEncode(key), session: null }); writeFileSync(path.join(claudeHome, 'settings.json'), JSON.stringify({ env: { FIXTURE_PRESERVED: 'yes' } })); writeFileSync(path.join(codexHome, 'config.toml'), `model = "crosshead-fixture"\nmodel_provider = "crosshead_fixture"\n[model_providers.crosshead_fixture]\nname = "Local deterministic fixture"\nbase_url = "${base}/v1"\nenv_key = "FIXTURE_API_KEY"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n`); - const cli = path.join(installed, 'dist/runtime/cli.js'); + const cli = publishedCli ?? path.join(installed, 'dist/runtime/cli.js'); const installClaude = ['install', 'claude-code', '--home', home, '--claude-home', claudeHome, '--claude-bin', nativeClaude, '--workspace-root', project]; const installCodex = ['install', 'codex', '--home', home, '--codex-home', codexHome, '--codex-bin', nativeCodex]; await run('install-claude', process.execPath, [cli, ...installClaude]); diff --git a/tests/fixtures/published-alpha6-install.sh b/tests/fixtures/published-alpha6-install.sh new file mode 100644 index 0000000..ea1b8a7 --- /dev/null +++ b/tests/fixtures/published-alpha6-install.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="https://crosshead.sh" +RELEASE_ID="0.5.0-alpha.6-b82fbb2e5e85" +RELEASE_URL="https://crosshead.sh/install/releases/onlybots-runtime.tgz" +CROSSHEAD_RELEASE_URL="https://crosshead.sh/install/releases/crosshead-runtime.tgz" +RELEASE_SHA256="b82fbb2e5e85674f975c9f4d6fdf530ba29b8b1d9a5114f520384c194003532c" +RELEASE_CHECKSUMS_URL="https://github.com/FriendMachine/crosshead/releases/download/v0.5.0-alpha.6/SHA256SUMS" +RELEASE_SOURCE_URL="https://github.com/FriendMachine/crosshead/tree/f48a7feabcb63447e36f511279b62798e59d983c" +RELEASE_ATTESTATION_REPOSITORY="FriendMachine/crosshead" +ADD_TO_PATH=0 +for ARG in "$@"; do + case "$ARG" in + --add-to-path) ADD_TO_PATH=1 ;; + *) echo "Unknown installer option: $ARG" >&2; exit 1 ;; + esac +done +INSTALL_ROOT="${CROSSHEAD_HOME:-${CROSSHEAD_RUNTIME_HOME:-${ONLYBOTS_HOME:-${ONLYBOTS_RUNTIME_HOME:-}}}}" +if [ -z "$INSTALL_ROOT" ]; then + if [ -e "$HOME/.crosshead" ] && [ -e "$HOME/.onlybots" ] && ! [ "$HOME/.crosshead" -ef "$HOME/.onlybots" ]; then + echo "Separate ~/.crosshead and ~/.onlybots installations found. Set CROSSHEAD_HOME to choose; no state was moved." >&2 + exit 1 + fi + if [ ! -e "$HOME/.crosshead" ] && [ ! -L "$HOME/.crosshead" ] && [ -d "$HOME/.onlybots" ]; then + ln -s "$HOME/.onlybots" "$HOME/.crosshead" + fi + INSTALL_ROOT="$HOME/.crosshead" + if [ -L "$INSTALL_ROOT" ] && [ ! -e "$INSTALL_ROOT" ]; then + echo "Runtime home is a broken symlink: $INSTALL_ROOT" >&2; exit 1 + fi +fi +BIN_DIR="$INSTALL_ROOT/bin" +LIB_DIR="$INSTALL_ROOT/lib/runtime/$RELEASE_ID" +TMP_DIR="$(mktemp -d)" +STAGE_DIR="$TMP_DIR/release" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Crosshead installer requires '$1' on PATH" >&2 + exit 1 + fi +} + +need_cmd curl +need_cmd tar +need_cmd node +need_cmd npm + +if ! node -e 'const version=process.argv[1]||process.versions.node;const parts=version.split(".").map(Number);const [major,minor,patch]=parts;const minimum=major===22?[23,4]:major===24?[22,1]:major===26?[12,1]:null;const valid=parts.length===3&&parts.every(Number.isInteger);const safe=valid&&(major>26||(minimum!==null&&(minor>minimum[0]||(minor===minimum[0]&&patch>=minimum[1]))));process.exit(safe?0:1)'; then + echo "Crosshead installer requires Node 22.23.4+, 24.22.1+, or 26.12.1+" >&2 + exit 1 +fi + +mkdir -p "$BIN_DIR" "$STAGE_DIR" "$(dirname "$LIB_DIR")" + +echo "Downloading Crosshead runtime release $RELEASE_ID from $CROSSHEAD_RELEASE_URL" +curl -fsSL --proto '=https' --proto-redir '=https' --max-redirs 3 --max-time 120 --max-filesize 67108864 "$CROSSHEAD_RELEASE_URL" -o "$TMP_DIR/crosshead-runtime.tgz" +ACTUAL_SHA256="$(node -e 'const c=require("crypto"),f=require("fs");process.stdout.write(c.createHash("sha256").update(f.readFileSync(process.argv[1])).digest("hex"))' "$TMP_DIR/crosshead-runtime.tgz")" +if [ "$ACTUAL_SHA256" != "$RELEASE_SHA256" ]; then + echo "Crosshead runtime release checksum verification failed" >&2 + exit 1 +fi +tar -xzf "$TMP_DIR/crosshead-runtime.tgz" -C "$STAGE_DIR" --no-same-owner --no-same-permissions + +if [ ! -f "$STAGE_DIR/package.json" ] || [ ! -f "$STAGE_DIR/package-lock.json" ] || [ ! -f "$STAGE_DIR/dist/runtime/cli.js" ] || [ ! -f "$STAGE_DIR/dist/runtime/daemon.js" ]; then + echo "Crosshead runtime release is missing required files" >&2 + exit 1 +fi + +echo "Installing runtime dependencies" +( + cd "$STAGE_DIR" + npm ci --omit=dev --ignore-scripts --no-audit --no-fund +) + +rm -rf "$LIB_DIR" +mv "$STAGE_DIR" "$LIB_DIR" + +# Generate shell wrappers with literal, safely quoted paths. +node - "$INSTALL_ROOT" "$LIB_DIR" "$BIN_DIR" <<'NODE' +const fs = require('fs'); +const path = require('path'); +const [root, lib, bin] = process.argv.slice(2); +const q = value => "'" + value.split("'").join("'" + String.fromCharCode(34) + "'" + String.fromCharCode(34) + "'") + "'"; +for (const name of ['crosshead', 'xhd', 'onlybotsctl', 'crossheadd', 'onlybotsd']) { + const entry = ['crossheadd', 'onlybotsd'].includes(name) ? 'daemon.js' : 'cli.js'; + const body = '#!/usr/bin/env bash\nset -euo pipefail\nexport CROSSHEAD_HOME=' + q(root) + '\nnode ' + q(path.join(lib, 'dist/runtime', entry)) + ' "$@"\n'; + fs.writeFileSync(path.join(bin, name), body, { mode: 0o755 }); + fs.chmodSync(path.join(bin, name), 0o755); +} +NODE + +echo +echo "Crosshead CLI installed." +echo "Add this to your shell if needed:" +printf ' export PATH=%q:$PATH\n' "$BIN_DIR" +echo +if [ "$ADD_TO_PATH" = 1 ]; then + case "${SHELL:-}" in + */zsh) PROFILE_FILE="${ZDOTDIR:-$HOME}/.zshrc" ;; + */bash) + if [ "$(uname -s)" = Darwin ]; then + PROFILE_FILE="$HOME/.bash_profile" + else + PROFILE_FILE="$HOME/.bashrc" + fi + ;; + *) echo "Automatic PATH setup supports bash and zsh. Use the PATH command above." >&2; exit 1 ;; + esac + # Node quotes the literal path safely and avoids duplicate entries. Never eval paths. + node - "$PROFILE_FILE" "$BIN_DIR" <<'NODE' +const fs = require('fs'); +const [profile, bin] = process.argv.slice(2); +const quoted = "'" + bin.split("'").join("'" + String.fromCharCode(34) + "'" + String.fromCharCode(34) + "'") + "'"; +const line = 'export PATH=' + quoted + ':"$PATH"'; +const prior = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf8') : ''; +if (!prior.split('\n').includes(line)) fs.appendFileSync(profile, '\n# Crosshead CLI\n' + line + '\n'); +console.log('PATH entry saved in ' + profile + '. Open a new shell or use the command above.'); +NODE +fi + +echo "Next steps (choose your tool):" +echo " crosshead quickstart --api-base $BASE_URL --adapter claude-code --workspace-root ." +echo " crosshead quickstart --api-base $BASE_URL --adapter codex" +echo " crosshead quickstart --api-base $BASE_URL --adapter hermes" +echo " crosshead quickstart --api-base $BASE_URL --adapter opencode" +echo " crosshead status" +echo " crosshead doctor" +echo " crosshead update --pin # optional: disable automatic updates" +echo "Setup and data controls: $BASE_URL/getting-started" +echo "Release source: $RELEASE_SOURCE_URL" +echo "Independent checksums: $RELEASE_CHECKSUMS_URL" +echo "Provenance: gh attestation verify --repo $RELEASE_ATTESTATION_REPOSITORY" From 59bc0adb15cb7bcec758f1d8a187cd52b2ad91ba Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:57:28 -0500 Subject: [PATCH 4/5] Pin baseline installer archive to immutable alpha6 release --- scripts/smoke-coding-hosts.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/smoke-coding-hosts.mjs b/scripts/smoke-coding-hosts.mjs index 05feb91..4e606e0 100644 --- a/scripts/smoke-coding-hosts.mjs +++ b/scripts/smoke-coding-hosts.mjs @@ -96,15 +96,20 @@ try { const status=JSON.parse(await run('public-fresh-status',process.execPath,[freshCli,'status','--home',freshHome,'--json'])); assert.equal(status.coreVersion,manifest.version); env.CROSSHEAD_HOME=env.ONLYBOTS_HOME=home; - const baseline=path.resolve('tests/fixtures/published-alpha6-install.sh'); + const originalBaseline=readFileSync(path.resolve('tests/fixtures/published-alpha6-install.sh'),'utf8'); + assert.equal(createHash('sha256').update(originalBaseline).digest('hex'),'b8af3866f720577fed81630769dddae1128cb11cb8973614f11e58b9d10635da'); + // The original public archive alias moves on deploy. Substitute only the + // immutable GitHub alpha.6 archive URL; retain its original digest check. + const baseline=path.join(root,'alpha6-immutable-install.sh'); + writeFileSync(baseline,originalBaseline.replace(/^CROSSHEAD_RELEASE_URL=.+$/m,'CROSSHEAD_RELEASE_URL="https://github.com/FriendMachine/crosshead/releases/download/v0.5.0-alpha.6/crosshead-runtime-install-0.5.0-alpha.6.tgz"')); await run('published-alpha6-install','bash',[baseline]); - const match=readFileSync(baseline,'utf8').match(/^RELEASE_ID="([^"]+)"/m);assert(match); + const match=originalBaseline.match(/^RELEASE_ID="([^"]+)"/m);assert(match); const oldCli=path.join(home,'lib/runtime',match[1],'dist/runtime/cli.js'); await run('published-alpha6-core',process.execPath,[oldCli,'install','--home',home]); await run('public-alpha6-to-candidate-update',process.execPath,[oldCli,'update','--home',home,'--base-url','https://crosshead.sh','--sha256',manifest.artifacts.install.sha256,'--no-restart']); publishedCli=path.join(home,'lib/runtime',manifest.releaseId,'dist/runtime/cli.js'); assert.equal(JSON.parse(await run('public-updated-status',process.execPath,[publishedCli,'status','--home',home,'--json'])).coreVersion,manifest.version); - results.checks.push('actual public installer fresh install', 'actual alpha.6 installation updated using independently built candidate digest'); + results.checks.push('actual public installer fresh install', 'alpha.6 installer with immutable archive URL and original digest, updated using independently built candidate digest'); } const fromInstalled = createRequire(path.join(installed, 'package.json')); const { generateSecretKey, getPublicKey, nip19 } = fromInstalled('nostr-tools'); From abd766859a465e9293f9eb97db34b57d810cf41a Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Fri, 6 Nov 2026 16:58:33 -0500 Subject: [PATCH 5/5] Check internal ABI version and preserve profiles through public upgrade --- scripts/smoke-coding-hosts.mjs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/scripts/smoke-coding-hosts.mjs b/scripts/smoke-coding-hosts.mjs index 4e606e0..d78f40b 100644 --- a/scripts/smoke-coding-hosts.mjs +++ b/scripts/smoke-coding-hosts.mjs @@ -29,7 +29,7 @@ const env = { PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`, TMP ANTHROPIC_API_KEY: 'fixture-only', ANTHROPIC_AUTH_TOKEN: 'fixture-only', FIXTURE_API_KEY: 'fixture-only' }; let stage = 'capture', requests = [], cleanup = async () => {}; const publicInstall=process.env.CROSSHEAD_TEST_INSTALL_SOURCE==='public'; -let publishedCli=null; +let publishedCli=null, baselineCli=null; const marker = 'duration-fixture-8427'; const finding = `Finding ${marker}: serialize durations as decimal nanosecond strings. Zero is valid; negative durations are rejected. Do not convert to floating-point milliseconds. Next validate the transport round trip.`; const server = createServer(async (req, res) => { @@ -85,6 +85,8 @@ try { await tar.x({ file: archive, cwd: installed }); results.hosts = { claude: (await run('claude-version', nativeClaude, ['--version'])).trim(), codex: (await run('codex-version', nativeCodex, ['--version'])).trim() }; await run('runtime-dependencies', 'npm', ['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], installed); + const fromInstalled = createRequire(path.join(installed, 'package.json')); + const expectedCoreVersion=fromInstalled('./dist/runtime/runtime-persistence.js').ONLYBOTS_RUNTIME_CORE_VERSION; if(publicInstall){ const script=await (await fetch('https://crosshead.sh/install',{signal:AbortSignal.timeout(30000)})).text(); assert(script.includes(`RELEASE_ID="${manifest.releaseId}"`),'Production installer is not serving this release candidate'); @@ -94,7 +96,8 @@ try { const freshCli=path.join(freshHome,'lib/runtime',manifest.releaseId,'dist/runtime/cli.js'); await run('public-fresh-core',process.execPath,[freshCli,'install','--home',freshHome]); const status=JSON.parse(await run('public-fresh-status',process.execPath,[freshCli,'status','--home',freshHome,'--json'])); - assert.equal(status.coreVersion,manifest.version); + assert.equal(status.coreVersion,expectedCoreVersion); + assert.equal(JSON.parse(readFileSync(path.join(freshHome,'lib/runtime',manifest.releaseId,'package.json'),'utf8')).version,manifest.version); env.CROSSHEAD_HOME=env.ONLYBOTS_HOME=home; const originalBaseline=readFileSync(path.resolve('tests/fixtures/published-alpha6-install.sh'),'utf8'); assert.equal(createHash('sha256').update(originalBaseline).digest('hex'),'b8af3866f720577fed81630769dddae1128cb11cb8973614f11e58b9d10635da'); @@ -106,12 +109,9 @@ try { const match=originalBaseline.match(/^RELEASE_ID="([^"]+)"/m);assert(match); const oldCli=path.join(home,'lib/runtime',match[1],'dist/runtime/cli.js'); await run('published-alpha6-core',process.execPath,[oldCli,'install','--home',home]); - await run('public-alpha6-to-candidate-update',process.execPath,[oldCli,'update','--home',home,'--base-url','https://crosshead.sh','--sha256',manifest.artifacts.install.sha256,'--no-restart']); - publishedCli=path.join(home,'lib/runtime',manifest.releaseId,'dist/runtime/cli.js'); - assert.equal(JSON.parse(await run('public-updated-status',process.execPath,[publishedCli,'status','--home',home,'--json'])).coreVersion,manifest.version); - results.checks.push('actual public installer fresh install', 'alpha.6 installer with immutable archive URL and original digest, updated using independently built candidate digest'); + baselineCli=oldCli; } - const fromInstalled = createRequire(path.join(installed, 'package.json')); + const { generateSecretKey, getPublicKey, nip19 } = fromInstalled('nostr-tools'); const persistence = fromInstalled('./dist/runtime/runtime-persistence.js'); const { LocalRuntimeClient } = fromInstalled('./dist/runtime/local-client.js'); @@ -120,6 +120,14 @@ try { const key = generateSecretKey(), now = new Date().toISOString(), paths = persistence.ensureRuntimeHomeLayout(home); persistence.saveProfile(paths, { name: 'default', apiBase: 'https://crosshead.invalid', npub: nip19.npubEncode(getPublicKey(key)), memoryMode: 'assist', workspaceScope: 'repo', features: { feedback: false, relay: false, coldSync: false, autoWarmPromotion: false }, secureStoreMode: 'file', createdAt: now, updatedAt: now }); persistence.saveSecret(paths, 'default', { nsec: nip19.nsecEncode(key), session: null }); + if(publicInstall){ + const profileBefore=readFileSync(path.join(paths.profiles,'default.json'),'utf8'); + await run('public-alpha6-to-candidate-update',process.execPath,[baselineCli,'update','--home',home,'--base-url','https://crosshead.sh','--sha256',manifest.artifacts.install.sha256,'--no-restart']); + publishedCli=path.join(home,'lib/runtime',manifest.releaseId,'dist/runtime/cli.js'); + assert.equal(JSON.parse(await run('public-updated-status',process.execPath,[publishedCli,'status','--home',home,'--json'])).coreVersion,expectedCoreVersion); + assert.equal(readFileSync(path.join(paths.profiles,'default.json'),'utf8'),profileBefore,'Upgrade changed the existing profile'); + results.checks.push('actual public installer fresh install','alpha.6 installer with immutable archive URL and original digest, upgraded with independently built candidate digest; profile preserved'); + } writeFileSync(path.join(claudeHome, 'settings.json'), JSON.stringify({ env: { FIXTURE_PRESERVED: 'yes' } })); writeFileSync(path.join(codexHome, 'config.toml'), `model = "crosshead-fixture"\nmodel_provider = "crosshead_fixture"\n[model_providers.crosshead_fixture]\nname = "Local deterministic fixture"\nbase_url = "${base}/v1"\nenv_key = "FIXTURE_API_KEY"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n`); const cli = publishedCli ?? path.join(installed, 'dist/runtime/cli.js');