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
50 changes: 50 additions & 0 deletions .github/workflows/coding-hosts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Coding host customer journey

on:
workflow_dispatch:
pull_request:
paths:
- 'src/runtime/**'
- 'scripts/smoke-coding-hosts.mjs'
- '.github/workflows/coding-hosts.yml'
- 'package-lock.json'

permissions:
contents: read

concurrency:
group: coding-hosts-${{ github.ref }}
cancel-in-progress: true

jobs:
customer-journey:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run release:build
- name: Install pinned real coding hosts with required native installers
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_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
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: coding-host-journey-${{ matrix.os }}
path: .coding-host-test-logs/
include-hidden-files: true
retention-days: 14
99 changes: 99 additions & 0 deletions .github/workflows/hot-sync-production.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Production hot sync acceptance
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: production-hot-sync-acceptance
cancel-in-progress: false
jobs:
linux-offline:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: {persist-credentials: false}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with: {node-version: 22, cache: npm}
- run: npm ci && npm run build
- name: Capture online and after a real Docker network disconnect
env:
CROSSHEAD_HN_FIXTURE_NSEC: ${{ secrets.CROSSHEAD_HN_20261106_NSEC }}
run: |
set -euo pipefail
mkdir -m 700 fixture-state
fixture_container="crosshead-hn-${GITHUB_RUN_ID}-a"
cleanup() {
result=$?
trap - EXIT
if ! docker rm -f -v "$fixture_container" >/dev/null; then
echo 'Owned fixture container removal failed' >&2
exit 1
fi
if docker inspect "$fixture_container" >/dev/null 2>&1; then
echo 'Owned fixture container still exists' >&2
exit 1
fi
exit "$result"
}
trap cleanup EXIT
docker run -d --name "$fixture_container" --user "$(id -u):$(id -g)" --cpus 1 --memory 512m --memory-swap 512m --tmpfs /tmp:rw,size=64m --env CROSSHEAD_HN_FIXTURE_NSEC --mount "type=bind,src=$PWD,dst=/crosshead,readonly" --mount "type=bind,src=$PWD/fixture-state,dst=/fixture" node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d sleep infinity
docker exec "$fixture_container" node /crosshead/scripts/accept-hot-sync-production.mjs --phase a-online --home /fixture/state --run-id "$GITHUB_RUN_ID" --allow-production
docker network disconnect bridge "$fixture_container"
docker exec "$fixture_container" node /crosshead/scripts/accept-hot-sync-production.mjs --phase a-offline --home /fixture/state --run-id "$GITHUB_RUN_ID" --allow-production
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with: {name: synthetic-linux-state, path: fixture-state/, retention-days: 7}
macos-recovery:
needs: linux-offline
runs-on: macos-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: {persist-credentials: false}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with: {node-version: 22, cache: npm}
- run: npm ci && npm run build
- name: Recover, reconnect, isolate and reset through production
env:
CROSSHEAD_HN_FIXTURE_NSEC: ${{ secrets.CROSSHEAD_HN_20261106_NSEC }}
run: node scripts/accept-hot-sync-production.mjs --phase b-reconnect-reset --home fixture-macos --run-id "$GITHUB_RUN_ID" --allow-production
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: always()
with: {name: synthetic-macos-results, path: fixture-macos/result-*.json, retention-days: 7}
linux-reconnect:
needs: macos-recovery
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: {persist-credentials: false}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with: {node-version: 22, cache: npm}
- run: npm ci && npm run build
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with: {name: synthetic-linux-state, path: fixture-state}
- name: Reconnect stale offline state after the other machine reset
env:
CROSSHEAD_HN_FIXTURE_NSEC: ${{ secrets.CROSSHEAD_HN_20261106_NSEC }}
run: node scripts/accept-hot-sync-production.mjs --phase a-recover --home fixture-state/state --run-id "$GITHUB_RUN_ID" --allow-production
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: always()
with: {name: synthetic-linux-recovery-results, path: fixture-state/state/result-a-recover.json, retention-days: 7}
cleanup:
needs: [linux-offline, macos-recovery, linux-reconnect]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: {persist-credentials: false}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with: {node-version: 22, cache: npm}
- run: npm ci && npm run build
- name: Clear purpose-owned production memory and revoke this session
env:
CROSSHEAD_HN_FIXTURE_NSEC: ${{ secrets.CROSSHEAD_HN_20261106_NSEC }}
run: node scripts/accept-hot-sync-production.mjs --phase cleanup --home fixture-cleanup --run-id "$GITHUB_RUN_ID" --allow-production
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
if: always()
with: {name: synthetic-cleanup-result, path: fixture-cleanup/result-cleanup.json, retention-days: 7}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ release/
.native-host-test-logs/
*.tgz
.DS_Store

.coding-host-test-logs/
44 changes: 37 additions & 7 deletions docs/HOT-SYNC.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
# Local hot memory with background sync

The `feat/local-first-hot-memory` branch provides the opt-in implementation of
hosted Task 159 / PRD 007. Enable `CROSSHEAD_HOT_SYNC_ENABLED=true` in the daemon's
environment, with a server running migration 0034 and the same flag. Start with
a disposable runtime home. This alpha candidate is not a published release.
Local hot sync is the default beginning with v0.5.0-alpha.7. The server must
run migration 0034 and enable `CROSSHEAD_HOT_SYNC_ENABLED=true`; participating
devices use protocol 2. The daemon owns this decision. Set
`CROSSHEAD_HOT_SYNC_ENABLED=false` in its environment only for an intentional
rollback to the previous hosted hook path.

## What happens locally

A completed lifecycle capture is signed and written atomically, with file and
directory fsync, before it is acknowledged. Hooks read the verified local view;
ordinary hot-context delivery does not wait for authentication or the cloud.
Explicit paid recall remains a hosted operation. Local-only profiles retain
Explicit paid recall remains a hosted operation. Ordinary hooks do not request
billable context packs or reflection, and do not wait for hosted inbox/dream HUD
refreshes. Archive uploads run outside the lifecycle mutation lock and cannot
block local capture or hot sync. Local-only profiles retain
their existing behavior.

Pending checkpoints have a separate durable outbox. They survive restart,
Expand Down Expand Up @@ -75,6 +79,15 @@ continuity. Legacy writers use a separate endpoint and cannot overwrite the
revisioned store. Unmapped remote workspaces remain inspectable until linked
locally and never enter an unrelated workspace's prompt.

Unsent legacy queue checkpoints are imported into the fsynced outbox before
new uploads. Their exact original snapshots remain in private
`legacy-recovery-*.json` files, listed by `legacyRecoverySnapshots` in sync
status. These bounded recovery copies protect ambiguous work if first discovery
reveals a reset on another machine. Unsupported snapshots and snapshots with a
known reset remain queued for explicit recovery; they do not block other
profiles' local hooks. Do not automatically re-upload recovery copies after a
reset without deciding whether they predate the deletion.

Deploy the server capability before releasing the client. If the server gate is
disabled or the server is rolled back, leave the client gate enabled: local
capture/read continues, pending uploads remain durable, and status reports the
Expand All @@ -90,8 +103,7 @@ Retry-After; and conflict withholding. The compiled daemon test uses SIGKILL
between capture and read, then verifies Codex, Claude Code, and Pi context through
its authenticated local API, including reset and ownership controls.

This branch still requires public release and deployment validation before a
launch: tagged artifacts, production installer/update/rollback checks, a real
Each launch candidate requires public release and deployment validation: tagged artifacts, production installer/update/rollback checks, a real
two-device production run, and authenticated production capacity measurements.
Local test latency is development evidence, not a production capacity claim.

Expand Down Expand Up @@ -140,3 +152,21 @@ allowance before authentication (including invalid credentials) and a verified
identity allowance after authentication; creating sessions cannot multiply it.
These counters are per server process, as with the existing limiter; aggregate
multi-instance capacity still requires deployment measurement.

## Requesting hosted private-memory deletion

Email `support@crosshead.sh` with the agent's public `npub`. Support sends a
short-lived deletion challenge. Run the supplied command on the machine holding
that identity:

```sh
crosshead sign-deletion-challenge '<challenge>' --confirm erase-private-memory --json
```

The CLI checks the challenge's purpose, identity, server and expiry against the
local profile. Send the resulting signed proof to support; the private key stays
on your machine. This authorizes deletion of that identity's hosted private
memory across profiles and namespaces, and permanently disables the cloud
identity so an old device cannot upload it again. Local files remain on your
machines. Shared organizations, public publications and required financial
records have separate ownership and retention rules.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@crosshead/runtime",
"version": "0.5.0-alpha.6",
"version": "0.5.0-alpha.7",
"description": "Crosshead local runtime: persistent memory for Claude Code, Codex, Pi, Hermes and OpenCode. CLI, daemon, host adapters and hooks.",
"license": "Apache-2.0",
"private": true,
Expand Down
74 changes: 74 additions & 0 deletions scripts/accept-hot-sync-production.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Disposable production customer identity only. State artifacts contain signed
// synthetic memory, never the private key or bearer tokens.
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdirSync, readFileSync, readdirSync, writeFileSync, chmodSync } from 'node:fs';
import path from 'node:path';
const require=createRequire(import.meta.url);
const {HotSyncManager}=require('../dist/runtime/hot-sync-manager.js');
const {HostedOnlyBotsClient}=require('../dist/runtime/hosted-client.js');
const {generateSecretKey,getPublicKey,nip19}=require('nostr-tools');
const option=(name,fallback)=>{const i=process.argv.indexOf(name);return i<0?fallback:process.argv[i+1];};
const phase=option('--phase'),home=path.resolve(option('--home','fixture-state')),run=option('--run-id');
const origin='https://crosshead.sh',profileName=`hn-fixture-${run}`,workspace='repo:/synthetic-crosshead-project';
assert(run&&process.argv.includes('--allow-production'),'Explicit run ID and --allow-production required');
const nsec=process.env.CROSSHEAD_HN_FIXTURE_NSEC;assert(nsec,'Missing purpose-owned synthetic identity');
const decoded=nip19.decode(nsec);assert.equal(decoded.type,'nsec');
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=[];
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 scope=m=>m.status().scopes.find(s=>s.scope.startsWith('ws-codex-'))?.scope;
let m, primaryFailure=null;
try{
m=make();
if(phase==='a-online'){
capture(m,'a-base');await sync(m,()=>m.status().pendingCount===0);assert(has(m,'a-base'));checks.push('Linux A uploaded signed synthetic finding');
}else if(phase==='a-offline'){
assert(has(m,'a-base'));const ms=capture(m,'a-stale');m.tick();const failedBy=Date.now()+30000;while(!m.status().error&&Date.now()<failedBy)await new Promise(r=>setTimeout(r,100));await m.stop();assert.equal(m.status().error,'sync_unavailable');assert.equal(m.status().pendingCount,1);assert(has(m,'a-stale'));checks.push('Docker network disconnected; capture fsynced and remained pending');checks.push(`offline capture ${ms.toFixed(2)} ms`);
}else if(phase==='b-reconnect-reset'){
await sync(m,()=>has(m,'a-base'));checks.push('Fresh macOS B recovered Linux A finding from production');
capture(m,'b-online');await sync(m,()=>m.status().pendingCount===0);
offline=true;capture(m,'b-offline');await m.stop();m=make();assert(has(m,'b-offline'));assert.equal(m.status().pendingCount,1);checks.push('macOS offline transport capture survived restart');
offline=false;await sync(m,()=>m.status().pendingCount===0);
const fresh=make(path.join(home,'fresh-recovery'));await sync(fresh,()=>has(fresh,'b-offline')&&has(fresh,'a-base'));
const other=make(path.join(home,'other-identity'),nip19.nsecEncode(generateSecretKey()));await sync(other,()=>!other.status().initialSyncPending);assert.equal(findings(other).length,0);
const otherProfile=make(path.join(home,'other-profile'),nsec,`${profileName}-other`);await sync(otherProfile,()=>!otherProfile.status().initialSyncPending);assert.equal(findings(otherProfile).length,0);checks.push('Fresh recovery and identity/profile isolation verified');
m.reset(scope(m));await sync(m,()=>!m.status().scopes.some(s=>s.pendingReset));assert.equal(findings(m).length,0);checks.push('macOS reset reached production');
}else if(phase==='a-recover'){
assert(has(m,'a-stale'));assert.equal(m.status().pendingCount,1);
await sync(m,()=>m.status().pendingCount===0);assert(!has(m,'a-stale'));assert.equal(findings(m).length,0);assert(m.status().scopes.some(s=>s.epoch>0&&s.discardedByReset>0));checks.push('Linux stale offline capture discarded after remote reset, without resurrection');
capture(m,'after-reset');await sync(m,()=>m.status().pendingCount===0);
const fresh=make(path.join(home,'fresh-after-reset'));await sync(fresh,()=>has(fresh,'after-reset'));assert(!has(fresh,'a-stale'));checks.push('New epoch accepts new work and fresh recovery');
}else if(phase==='cleanup'){
await sync(m,()=>!m.status().initialSyncPending);
for(const row of m.status().scopes){m.reset(row.scope);await sync(m,()=>!m.status().scopes.some(s=>s.pendingReset));}
assert.equal(findings(m).length,0);checks.push('All purpose-owned production scopes cleared');
}else throw new Error('Unknown acceptance phase');
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 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;
try{const response=await originalFetch(`${origin}/v1/auth/revoke`,{method:'POST',headers:{authorization:`Bearer ${session.sessionToken}`},signal:AbortSignal.timeout(10000)});const body=await response.json();assert(response.ok&&body.revoked===true);}
catch{failures.push(`session revocation failed for fixture client ${index}`);}
}
globalThis.fetch=originalFetch;
// Enforce the artifact contract even if transport cleanup failed.
const inspect=dir=>{for(const e of readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,e.name);if(e.isDirectory())inspect(file);else{const content=readFileSync(file,'utf8');assert(!content.includes(nsec)&&!content.includes('nsec1')&&!content.includes('ob_sess_'),'Secret found in fixture artifact');}}};
try{inspect(home);}catch(error){failures.push(error.message);}
if(failures.length){console.error('Fixture cleanup failures: '+failures.join('; '));writeFileSync(path.join(home,'cleanup-errors.json'),JSON.stringify({failures},null,2)+'\n');if(!primaryFailure)throw new Error(failures.join('; '));}
}
Loading
Loading