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
7 changes: 7 additions & 0 deletions .github/workflows/coding-hosts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand Down Expand Up @@ -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
Expand Down
31 changes: 27 additions & 4 deletions scripts/accept-hot-sync-production.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,39 @@ 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);
const clients=[],managers=[],checks=[];
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});
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;
};
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()<deadline);throw last??new Error('Production convergence deadline exceeded');};
const sync=async(m,predicate)=>{
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()<deadline);
throw last??new Error('Production convergence deadline exceeded');
};
const scope=m=>m.status().scopes.find(s=>s.scope.startsWith('ws-codex-'))?.scope;
let m, primaryFailure=null;
try{
Expand Down Expand Up @@ -59,7 +82,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;
Expand Down
38 changes: 37 additions & 1 deletion scripts/smoke-coding-hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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) => {
Expand Down Expand Up @@ -84,6 +86,32 @@ try {
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');
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,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');
// 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=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]);
baselineCli=oldCli;
}

const { generateSecretKey, getPublicKey, nip19 } = fromInstalled('nostr-tools');
const persistence = fromInstalled('./dist/runtime/runtime-persistence.js');
const { LocalRuntimeClient } = fromInstalled('./dist/runtime/local-client.js');
Expand All @@ -92,9 +120,17 @@ 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 = 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]);
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/hosted-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
139 changes: 139 additions & 0 deletions tests/fixtures/published-alpha6-install.sh
Original file line number Diff line number Diff line change
@@ -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 <downloaded-tarball> --repo $RELEASE_ATTESTATION_REPOSITORY"
Loading
Loading