From a32fd586f966dcbb5bb727e33b3c9ceb0f84bda3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 21:07:43 -0500 Subject: [PATCH 01/18] Gate hosted relay storage behind signed approval The privacy terms for the hosted relay are not finished, so it must not keep anyone's chats or files yet. Every R2 write now refuses with 403 storage_off unless the account was approved or CAF_LIMITS.storageOpen is set; reads, pairing, the directory, presence and the live tunnel are unchanged, and frames already stored stay readable. POST /v1/admin/storage takes an approval signed by an operator key from CAF_ADMIN_KEYS (the public key is committed in wrangler.toml), bound to the request host, within ten minutes, and ordered by its timestamp. Stats report the account's storage state. A separate device-signed POST /v1/store/handoff lets a frozen identity pass an approval to one proven successor; the shared coordination evaluator and the committed rotation mutation are unchanged. Co-Authored-By: Claude Opus 5.5 --- relay/hosted/src/front.js | 2 + relay/hosted/src/identity.js | 19 +- relay/hosted/src/limits.js | 4 +- relay/hosted/src/raft/hosted.js | 17 ++ relay/hosted/src/routes.js | 4 +- relay/hosted/src/storage.js | 91 +++++++++ relay/hosted/src/store.js | 6 +- relay/hosted/src/tenant.js | 5 +- relay/hosted/src/wire.js | 3 +- relay/hosted/test/e2e.sh | 10 + relay/hosted/test/global.test.js | 2 +- relay/hosted/test/raft/directory.test.js | 2 +- relay/hosted/test/raft/effects.test.js | 6 +- relay/hosted/test/raft/hosted-fixture.js | 4 +- relay/hosted/test/raft/storage-live-worker.js | 31 +++ relay/hosted/test/raft/storage.test.js | 193 ++++++++++++++++++ relay/hosted/test/storage.test.js | 191 +++++++++++++++++ relay/hosted/test/store.test.js | 17 +- relay/hosted/wrangler.toml | 1 + scripts/relay-conformance.sh | 5 +- 20 files changed, 591 insertions(+), 22 deletions(-) create mode 100644 relay/hosted/src/storage.js create mode 100644 relay/hosted/test/raft/storage-live-worker.js create mode 100644 relay/hosted/test/raft/storage.test.js create mode 100644 relay/hosted/test/storage.test.js diff --git a/relay/hosted/src/front.js b/relay/hosted/src/front.js index 9e7ba58ee5..9275dfb30d 100644 --- a/relay/hosted/src/front.js +++ b/relay/hosted/src/front.js @@ -2,6 +2,7 @@ // Durable Object classes (which only load inside the Workers runtime). import { peerPath,peerLimits } from './raft/peers.js'; import { stripBase } from './base.js'; +import { serveStorageAdmin } from './storage.js'; import { clusterRequestLimit } from './raft/ingress.js'; import { sharedRuntime } from './raft/actor.js'; import { checkDeclared,readBody } from './body.js'; @@ -35,6 +36,7 @@ export function dispatch(outer, env) { const request = stripBase(outer, env); if (!request) throw notFound(); const { pathname } = new URL(request.url); + if (pathname === '/v1/admin/storage') return serveStorageAdmin(request, env); if (pathname.startsWith('/v1/link/')) return serveLink(request, env); if (pathname.startsWith('/v1/pair')) return servePair(request, env); if (isMachineDoor(pathname)) return serveMachineDoor(request, env); diff --git a/relay/hosted/src/identity.js b/relay/hosted/src/identity.js index 38ccea43fd..a99b4b11df 100644 --- a/relay/hosted/src/identity.js +++ b/relay/hosted/src/identity.js @@ -8,6 +8,8 @@ import { HostedIdentity } from './raft/hosted.js'; import { clusterIngress,clusterRequestLimit } from './raft/ingress.js'; import { sharedRuntime } from './raft/actor.js'; import { identityOf } from './verify.js'; +import { Meta } from './meta.js'; +import { StorageAccess } from './storage.js'; import { b64,unb64 } from './raft/storage.js'; import { fromBase64Url } from './codec.js'; import { signedUri } from './base.js'; @@ -21,7 +23,7 @@ import { policyOf } from './rules.js'; import { Tenant, HAS_WAIT_MS } from './tenant.js'; import { finishClose, Watchers } from './watch.js'; import { checkCert, checkRequest } from './verify.js'; -import { gone, ipOf, rateLimited, respond, unwrap, Wire } from './wire.js'; +import { gone, guarded, ipOf, rateLimited, respond, unwrap, Wire } from './wire.js'; const SIGNED = { identity: 'codeaf-identity', cert: 'codeaf-cert', time: 'codeaf-time', sig: 'codeaf-sig' }; const signedHeaders = (request) => Object.fromEntries(Object.entries(SIGNED).map(([k, h]) => [k, request.headers.get(h)])); @@ -51,6 +53,21 @@ export class IdentityDO extends DurableObject { this.deviceRate = new RateLimit(this.limits.requestsPerMinutePerDevice); } + // Approval creates only its own small record; first device contact still pays admission. + setStorage(approval) { + return guarded(() => { + new StorageAccess(new Meta(this.ctx.storage.sql), this.limits).set(approval); + }); + } + + inheritStorage(approval) { + return guarded(() => { + const storage = new StorageAccess(new Meta(this.ctx.storage.sql), this.limits); + if (approval) storage.set(approval, true); + return storage.state(); + }); + } + fetch(request) { return respond(() => this.#serve(request)); } diff --git a/relay/hosted/src/limits.js b/relay/hosted/src/limits.js index 81836b401c..c794b3dc88 100644 --- a/relay/hosted/src/limits.js +++ b/relay/hosted/src/limits.js @@ -18,6 +18,8 @@ export const PRESENCE = { maxBeat: 60, defaultBeat: 30, windowFactor: 2.5 }; export const windowMs = (beat) => beat * 1000 * PRESENCE.windowFactor; export const DEFAULTS = { + // Hosted storage remains closed until an account is approved or the operator opens it. + storageOpen: false, // One identity. storeBytes: 5 * GiB, // Maximum stored bytes per identity. storeObjects: 5_000_000, // the index is rows in the identity's SQLite, so this is a backstop; the byte cap rules @@ -68,7 +70,7 @@ export const DEFAULTS = { export function limitsOf(env) { const table = { ...DEFAULTS, ...JSON.parse(env.CAF_LIMITS ?? '{}') }; // A device may come back to its code until the code's life ends, so the default quarantine is one life. - return { ...table, plateQuarantineMs: table.plateQuarantineMs ?? table.pairTtlMs }; + return { ...table, storageOpen: [true, 1, 'true', '1'].includes(table.storageOpen), plateQuarantineMs: table.plateQuarantineMs ?? table.pairTtlMs }; } /** diff --git a/relay/hosted/src/raft/hosted.js b/relay/hosted/src/raft/hosted.js index 6563d06bb0..fe207911d6 100644 --- a/relay/hosted/src/raft/hosted.js +++ b/relay/hosted/src/raft/hosted.js @@ -1,3 +1,4 @@ +import { checkStorageHandoff } from '../storage.js'; import { Flight } from '../flight.js'; import { PersonalForward } from './forward.js'; import { PersonalPeers } from './peers.js'; @@ -831,7 +832,23 @@ export class HostedIdentity { entry.error=new Wire(code,code==='revoked'?401:410);entry.reject(entry.error); } } + // Handoff uses the same committed rotation view as GET, but its binding and + // approval belong only to this hosted object's meta, never to Raft commands. + async handoff(c,fresh){ + await this.ensure(fromBase64Url(c.request.headers.get('codeaf-identity'))); + const authority=await this.authorize(c.device,c.request.headers.get('codeaf-cert'),false,fresh); + checkTime(c.request.headers.get('codeaf-time'),this.readNow(authority.app.now)); + if(!['frozen','retired'].includes(authority.identity.rotation?.state))throw new Wire('not_rotating',409); + const successor=await checkStorageHandoff(this.tenant.identity,c.body); + await this.finishRead(authority.barrier,null); + this.tenant.storage.bindSuccessor(successor); + await this.ctx.storage.sync(); + const approval=this.tenant.limits.storageOpen?null:this.tenant.storage.approval(); + const storage=unwrap(await this.env.IDENTITY.get(this.env.IDENTITY.idFromName(successor)).inheritStorage(approval?.storage==='on'?approval:null)); + return json({successor,storage}); + } async dispatch(c,route){ + if(new URL(c.request.url).pathname==='/v1/store/handoff')return this.operation(()=>this.retryRead(fresh=>this.handoff(c,fresh))); if(!new URL(c.request.url).pathname.startsWith('/v1/store/'))return this.operation(()=>c.request.method==='GET'?this.retryRead(fresh=>this.serve(c,route,fresh)):this.serve(c,route)); const cert=c.request.headers.get('codeaf-cert'); const ticket=await this.operation(()=>this.retryRead(async fresh=>{ diff --git a/relay/hosted/src/routes.js b/relay/hosted/src/routes.js index 0a5f0fff0b..c40216c22a 100644 --- a/relay/hosted/src/routes.js +++ b/relay/hosted/src/routes.js @@ -93,6 +93,8 @@ const STORE_WRITE = { ...STORE, write: true }; const DIR_WRITE = { ...DIR, write: true }; const ROUTES = [ + // HostedIdentity serves this against committed read authority outside Raft. + ['POST', /^\/v1\/store\/handoff$/, { ...STORE, limit: 4096 }, () => { throw notFound(); }], ['POST', /^\/v1\/store\/frames$/, { ...STORE_WRITE, limit: MAX_FRAME, frames: true }, async (c) => json(await c.tenant.putFrame(c.body))], ['GET', /^\/v1\/store\/objects\/([^/]+)$/, STORE, async (c, [rid]) => { if (!isRid(rid)) throw new Wire('bad_rid', 400); @@ -102,7 +104,7 @@ const ROUTES = [ ['GET', /^\/v1\/store\/frames\/([^/]+)$/, STORE, frameOf], ['POST', /^\/v1\/store\/locate$/, STORE, async (c) => json({ at: await c.tenant.locate(asRids(object(c.body))) })], ['POST', /^\/v1\/store\/has$/, STORE, async (c) => json({ have: await c.tenant.has(asRids(object(c.body))) })], - ['GET', /^\/v1\/store\/stats$/, STORE, (c) => json({ ...c.tenant.stats.snapshot(), ...c.tenant.quota.snapshot(c.tenant.clock()) })], + ['GET', /^\/v1\/store\/stats$/, STORE, (c) => json({ storage: c.tenant.storage.state(), ...c.tenant.stats.snapshot(), ...c.tenant.quota.snapshot(c.tenant.clock()) })], ['GET', /^\/v1\/dir\/list$/, DIR, (c) => withVersion(json(c.tenant.dir.list()), c.tenant.dir.version)], ['GET', /^\/v1\/dir\/watch$/, DIR_WRITE, (c) => watching(c)], // C7: the client reads this promise before it can displace a holder. diff --git a/relay/hosted/src/storage.js b/relay/hosted/src/storage.js new file mode 100644 index 0000000000..942d68e936 --- /dev/null +++ b/relay/hosted/src/storage.js @@ -0,0 +1,91 @@ +// Storage permission is separate from directory authority, so disabling uploads +// leaves pairing, live connections and previously stored frames available. +import { Wire, json, unwrap } from './wire.js'; +import { readBody } from './body.js'; +import { hex, sha256, fromBase64Url } from './codec.js'; + +export const ADMIN_DOMAIN = 'codeaf/relay-admin/v1\n'; +export const ADMIN_WINDOW_MS = 10 * 60_000; +export const isIdentity = value => typeof value === 'string' && /^id_[0-9a-f]{32}$/.test(value); +const enc = new TextEncoder(); +const approvalKey = 'storage-approval'; + +export class StorageAccess { + constructor(meta, limits) { this.meta = meta; this.limits = limits; } + approval() { return this.meta.get(approvalKey); } + state() { return this.limits.storageOpen || this.approval()?.storage === 'on' ? 'on' : 'off'; } + assert() { if (this.state() !== 'on') throw new Wire('storage_off', 403); } + bindSuccessor(successor) { + const old = this.meta.get('storage-successor'); + if (old && old !== successor) throw new Wire('conflict', 409); + if (!old) this.meta.set('storage-successor', successor); + } + set(approval, inherited = false) { + const old = this.approval(); + if (inherited && (this.limits.storageOpen || approval.storage !== 'on' || old)) return; + if (old && approval.at <= old.at) { + if (inherited) return; + throw new Wire('stale', 409); + } + this.meta.set(approvalKey, { storage: approval.storage, at: approval.at }); + } +} + +// The successor proves its own key and binds consent to this source identity. +export async function checkStorageHandoff(source, raw) { + let body; + try { body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)); } + catch { throw new Wire('bad_request', 400); } + if (!body || !isIdentity(body.successor) || body.successor === source || + typeof body.successor_key !== 'string' || !/^[0-9a-f]{64}$/.test(body.successor_key) || + typeof body.proof !== 'string' || !/^[0-9a-f]{128}$/.test(body.proof)) throw new Wire('bad_request', 400); + const fromHex = text => Uint8Array.from(text.match(/../g), h => parseInt(h, 16)); + const bytes = fromHex(body.successor_key); + if ('id_' + hex((await sha256(bytes)).subarray(0, 16)) !== body.successor) throw new Wire('bad_request', 400); + let accepted = false; + try { + const key = await crypto.subtle.importKey('raw', bytes, 'Ed25519', false, ['verify']); + accepted = await crypto.subtle.verify('Ed25519', key, fromHex(body.proof), + enc.encode(`codeaf/storage-handoff/v1\n${source}\n${body.successor}`)); + } catch { /* An invalid successor key cannot authorize a handoff. */ } + if (!accepted) throw new Wire('bad_request', 400); + return body.successor; +} + +// Verify the exact bytes before crossing the identity boundary. Neither a +// device certificate nor newcomer admission participates in operator approval. +export async function serveStorageAdmin(request, env, clock = Date.now) { + if (request.method !== 'POST') throw new Wire('not_found', 404); + let raw; + try { raw = await readBody(request, 4096, 'bad_request'); } + catch { throw new Wire('bad_request', 400); } + let body; + try { body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)); } + catch { throw new Wire('bad_request', 400); } + if (!body || body.v !== 1 || !isIdentity(body.identity) || + !['on', 'off'].includes(body.storage) || typeof body.host !== 'string' || + !Number.isSafeInteger(body.at) || typeof body.nonce !== 'string' || !body.nonce.trim() || body.nonce.length > 256) { + throw new Wire('bad_request', 400); + } + const signatureText = request.headers.get('codeaf-admin-signature') ?? ''; + const signature = /^[A-Za-z0-9_-]+$/.test(signatureText) && fromBase64Url(signatureText); + const message = new Uint8Array(enc.encode(ADMIN_DOMAIN).length + raw.length); + message.set(enc.encode(ADMIN_DOMAIN)); message.set(raw, enc.encode(ADMIN_DOMAIN).length); + let accepted = false; + if (signature?.length === 64) for (const text of (env.CAF_ADMIN_KEYS ?? '').split(',')) { + const keyText = text.trim(); + if (!/^[A-Za-z0-9_-]{43}$/.test(keyText)) continue; + const bytes = fromBase64Url(keyText); + if (bytes?.length !== 32) continue; + try { + const key = await crypto.subtle.importKey('raw', bytes, 'Ed25519', false, ['verify']); + if (await crypto.subtle.verify('Ed25519', key, signature, message)) { accepted = true; break; } + } catch { /* An invalid configured key cannot authorize an approval. */ } + } + if (!accepted || body.host !== new URL(request.url).host || Math.abs(clock() - body.at) > ADMIN_WINDOW_MS) { + throw new Wire('unauthorized', 401); + } + const target = env.IDENTITY.get(env.IDENTITY.idFromName(body.identity)); + unwrap(await target.setStorage(body)); + return json({ identity: body.identity, storage: body.storage }); +} diff --git a/relay/hosted/src/store.js b/relay/hosted/src/store.js index 614cf5b050..a777b761af 100644 --- a/relay/hosted/src/store.js +++ b/relay/hosted/src/store.js @@ -11,6 +11,7 @@ import { decode } from './frame.js'; import { hex, sha256 } from './codec.js'; import { Serial } from './serial.js'; +export class StorageOff extends Error {} export class Conflict extends Error {} export class Damaged extends Error {} /** Unsatisfiable is a ranged get whose offset begins past the frame: the caller turns it into a refusal. */ @@ -43,7 +44,8 @@ export async function sealFrame(bytes) { export class R2Store { #writes = new Serial(); - constructor(bucket, sql, identity) { + constructor(bucket, sql, identity, assertStorage = () => { throw new StorageOff(); }) { + this.assertStorage = assertStorage; this.bucket = bucket; this.sql = sql; this.prefix = `${identity}/f/`; @@ -59,9 +61,11 @@ export class R2Store { */ put(frame, admit = () => {}) { return this.#writes.run(async () => { + this.assertStorage(); if (this.#indexed(frame.id)) return false; await this.#refuseConflicts(frame.objects); await admit(); + this.assertStorage(); await this.bucket.put(this.prefix + frame.id, frame.bytes); this.#index(frame); return true; diff --git a/relay/hosted/src/tenant.js b/relay/hosted/src/tenant.js index f527b8cf73..d59c9e8f13 100644 --- a/relay/hosted/src/tenant.js +++ b/relay/hosted/src/tenant.js @@ -4,6 +4,7 @@ import { LegacySource } from './raft/legacy.js'; import { Directory } from './directory.js'; import { Quota } from './limits.js'; import { Meta } from './meta.js'; +import { StorageAccess } from './storage.js'; import { Stats } from './stats.js'; import { encode } from './frame.js'; import { rotateBy, rotationView } from './rotation.js'; @@ -34,8 +35,9 @@ export class Tenant { this.watchers = watchers; this.dir = new Directory(sql, identity, clock, policy, watchers); this.quota = new Quota(sql, limits); - this.store = new R2Store(bucket, sql, identity); this.meta = new Meta(sql); + this.storage = new StorageAccess(this.meta, limits); + this.store = new R2Store(bucket, sql, identity, () => this.storage.assert()); this.stats = new Stats(this.meta, () => arm(clock() + limits.statsFlushMs)); } @@ -48,6 +50,7 @@ export class Tenant { * the store already holds costs the identity nothing, so a client that resends is never refused for it. */ async putFrame(bytes) { + this.storage.assert(); this.stats.add({ puts: 1, bytes_in: bytes.length }); const frame = await sealFrame(bytes); const cost = { size: frame.size, objects: frame.objects.length }; diff --git a/relay/hosted/src/wire.js b/relay/hosted/src/wire.js index a49ee408d9..a67360ca77 100644 --- a/relay/hosted/src/wire.js +++ b/relay/hosted/src/wire.js @@ -5,7 +5,7 @@ import { Refusal } from './verify.js'; import { BadFrame } from './frame.js'; import { RuleError } from './rules.js'; -import { Conflict, Damaged } from './store.js'; +import { Conflict, Damaged, StorageOff } from './store.js'; import { networkOf } from './network.js'; export class Wire extends Error { @@ -48,6 +48,7 @@ export function wireOf(e) { if (e instanceof Wire) return e; if (e instanceof Refusal) return new Wire(e.kind, 401); if (e instanceof BadFrame) return new Wire('bad_frame', 400); + if (e instanceof StorageOff) return new Wire('storage_off', 403); if (e instanceof Conflict) return new Wire('conflict', 409); if (e instanceof Damaged) return new Wire('damaged', 500); if (e instanceof RuleError) return new Wire(e.code, RULE_STATUS[e.code] ?? 500); diff --git a/relay/hosted/test/e2e.sh b/relay/hosted/test/e2e.sh index a8645e8616..ca3aea00af 100644 --- a/relay/hosted/test/e2e.sh +++ b/relay/hosted/test/e2e.sh @@ -23,6 +23,16 @@ JS start() { # port, extra wrangler args port=$1; shift + # Existing storage scenarios use an open service; targeted storage tests prove the closed default. + remaining=$# + while [ "$remaining" -gt 0 ]; do + argument=$1; shift + case "$argument" in + CAF_LIMITS:*) argument="CAF_LIMITS:$(node -e 'console.log(JSON.stringify({...JSON.parse(process.argv[1]), storageOpen:true}))' "${argument#CAF_LIMITS:}")" ;; + esac + set -- "$@" "$argument" + remaining=$((remaining - 1)) + done persist="$work/$port" if [ "$port" = "${scenario_port:-}" ]; then persist=$scenario_state; fi : >"$work/$port.log" diff --git a/relay/hosted/test/global.test.js b/relay/hosted/test/global.test.js index 8b4afa15b6..a0cd3ec22c 100644 --- a/relay/hosted/test/global.test.js +++ b/relay/hosted/test/global.test.js @@ -11,7 +11,7 @@ import { frame, rid, openBucket } from './helpers.js'; const empty = { list: async () => ({ objects: [], truncated: false }) }; const refusal = (code, status) => (e) => e.code === code && e.status === status; -const limits = (overrides) => ({ ...DEFAULTS, ...overrides }); +const limits = (overrides) => ({ ...DEFAULTS, storageOpen: true, ...overrides }); test('deployment defaults allow admission and storage above the former global limits', async () => { const sql = memorySql(); diff --git a/relay/hosted/test/raft/directory.test.js b/relay/hosted/test/raft/directory.test.js index 39b1484ac7..2f71b805e7 100644 --- a/relay/hosted/test/raft/directory.test.js +++ b/relay/hosted/test/raft/directory.test.js @@ -84,7 +84,7 @@ test('R1 R2 R4 R14 R16 R17 R21 hosted facade uses committed Go policy and native }, cluster); const stored=new Map(); let failPut=true; const bucket={async put(id,b) {if(failPut) throw new Error('blob write failed');stored.set(id,b.slice());},async get(id,opts) {const b=stored.get(id);if(!b)return null;const offset=opts?.range?.offset??0,length=opts?.range?.length??b.length;return {arrayBuffer:async()=>b.slice(offset,offset+length).buffer};}}; - const sqlHost=new Host();hostStores.push(sqlHost);const store=new R2Store(bucket,sqlHost.sql,cluster); + const sqlHost=new Host();hostStores.push(sqlHost);const store=new R2Store(bucket, sqlHost.sql, cluster, () => {}); const rid='1'.repeat(64), f=await sealFrame(encodeFrame('0'.repeat(32),[{rid,bytes:enc.encode('AGEO\x01sealed-object')}])); const receipt={device:'a',head:'sealed-one',head_rid:rid};let durable=0; await assert.rejects(store.put(f),/blob write failed/); diff --git a/relay/hosted/test/raft/effects.test.js b/relay/hosted/test/raft/effects.test.js index 50f019ea26..024b6e4071 100644 --- a/relay/hosted/test/raft/effects.test.js +++ b/relay/hosted/test/raft/effects.test.js @@ -14,7 +14,7 @@ function bucket() { } test('R21 an upload receipt waits for blob completion, indexing and the storage durability boundary',async()=>{ - const b=bucket(),store=new R2Store(b,memorySql(),'one'),f=await frame();let release,started; + const b=bucket(),store=new R2Store(b, memorySql(), 'one', () => {}),f=await frame();let release,started; const wait=new Promise(r=>{release=r;}),entered=new Promise(r=>{started=r;});const put=b.put; b.put=async(...args)=>{started();await wait;return put(...args);}; const writing=store.put(f);await entered;let ready=false,synced=false; @@ -26,7 +26,7 @@ test('R21 an upload receipt waits for blob completion, indexing and the storage }); test('R16 R21 a freeze or revoke during external I/O leaves bytes and returns no upload success',async()=>{ - const b=bucket(),store=new R2Store(b,memorySql(),'one'),f=await frame();let calls=0; + const b=bucket(),store=new R2Store(b, memorySql(), 'one', () => {}),f=await frame();let calls=0; const guarded=new RaftBlobTenant(store,async()=>{if(++calls===2)throw Error('revoked');},async()=>{}); await assert.rejects(guarded.putFrame(f),/revoked/); assert(b.data.has('one/f/'+f.id));assert.deepEqual(await store.get(rid),enc.encode('AGEO\x01sealed')); @@ -56,7 +56,7 @@ test('R17 expired public delivery records a terminal outcome and never invents a test('R21 an interrupted R2 index has no completion marker and a repeated immutable upload repairs it',async()=>{ const sql=memorySql(),original=sql.exec.bind(sql);let fail=true; sql.exec=(q,...args)=>{if(fail&&q.startsWith('INSERT OR IGNORE INTO objs')){fail=false;throw Error('index failed');}return original(q,...args);}; - const store=new R2Store(bucket(),sql,'one'),f=await frame(); + const store=new R2Store(bucket(),sql,'one',()=>{}),f=await frame(); await assert.rejects(store.put(f),/index failed/);assert.equal(await store.put(f),true); await store.checkReferences(rid,[f.id],async()=>{});assert.equal(await store.put(f),false); }); diff --git a/relay/hosted/test/raft/hosted-fixture.js b/relay/hosted/test/raft/hosted-fixture.js index 34d22ad1c6..02cb91af50 100644 --- a/relay/hosted/test/raft/hosted-fixture.js +++ b/relay/hosted/test/raft/hosted-fixture.js @@ -10,7 +10,7 @@ import { join } from 'node:path'; import { generateKeyPairSync, createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'; const sha=b=>createHash('sha256').update(b).digest('hex'); -export async function hostedFixture({fixture=false,base='',go=false,rootLabel,now,entryPoint}={}) { +export async function hostedFixture({fixture=false,base='',go=false,rootLabel,now,entryPoint,storageOpen=true,adminKeys='',limits={}}={}) { const prefix=prefixOf({BASE_PATH:base}); const out=process.env.CODEAF_RAFT_PROOF_DIR;assert(out,'build the native/WASI proof first'); const dir=await mkdtemp(join(tmpdir(),'p17b-')); @@ -47,7 +47,7 @@ export async function hostedFixture({fixture=false,base='',go=false,rootLabel,no }; const start=async(cut)=>{ if(mf)await mf.dispose();await compile(cut); - const worker={name:'hosted-proof',modulesRoot:dir,modules:[{type:'ESModule',path:join(dir,'worker.js')},{type:'CompiledWasm',path:join(dir,'core.wasm')}],compatibilityDate:'2026-09-01',durableObjects:Object.fromEntries(Object.entries({IDENTITY:'IdentityDO',RAFT_MEMBERS:'RaftMemberDO',NEWCOMERS:'NewcomerGate',PAIR_GATE:'PairGate',MAILBOX:'Mailbox',LINK_GATE:'LinkGate',LINK_REQUEST:'LinkRequest'}).map(([binding,className])=>[binding,{className,useSQLite:true}])),r2Buckets:['FRAMES'],bindings:{BASE_PATH:base,LEASE_POLICY:'amended',CAF_LIMITS:JSON.stringify({requestsPerMinute:100000,requestsPerMinutePerDevice:100000,minGraceMs:2000})},outboundService:()=>{throw Error('H4 outbound network forbidden');}}; + const worker={name:'hosted-proof',modulesRoot:dir,modules:[{type:'ESModule',path:join(dir,'worker.js')},{type:'CompiledWasm',path:join(dir,'core.wasm')}],compatibilityDate:'2026-09-01',durableObjects:Object.fromEntries(Object.entries({IDENTITY:'IdentityDO',RAFT_MEMBERS:'RaftMemberDO',NEWCOMERS:'NewcomerGate',PAIR_GATE:'PairGate',MAILBOX:'Mailbox',LINK_GATE:'LinkGate',LINK_REQUEST:'LinkRequest',MACHINE:'MachineDO'}).map(([binding,className])=>[binding,{className,useSQLite:true}])),r2Buckets:['FRAMES'],bindings:{BASE_PATH:base,LEASE_POLICY:'amended',CAF_ADMIN_KEYS:adminKeys,CAF_LIMITS:JSON.stringify({storageOpen,requestsPerMinute:100000,requestsPerMinutePerDevice:100000,minGraceMs:2000,...limits})},outboundService:()=>{throw Error('H4 outbound network forbidden');}}; mf=new Miniflare(convertV4MiniflareOptions({log:diagnostics,handleStructuredLogs:entry=>diagnostics.capture(entry.level,entry.message),host:'127.0.0.1',inspectorPort:0,resourcePersistencePath:join(dir,'persist'),telemetry:{enabled:false},cf:false,workers:[worker]})); await mf.ready;return mf; }; diff --git a/relay/hosted/test/raft/storage-live-worker.js b/relay/hosted/test/raft/storage-live-worker.js new file mode 100644 index 0000000000..16d59f0ecb --- /dev/null +++ b/relay/hosted/test/raft/storage-live-worker.js @@ -0,0 +1,31 @@ +import relay from '../../src/raft-worker.js'; +export * from '../../src/raft-worker.js'; +import { nameFor } from '../../src/engine/names.js'; +import { PROTOCOL, HEADER_NAME, HEADER_KEY, OP, frame as carrierFrame, readFrame, registrationProof } from '../../src/engine/carrier.js'; +const check=(ok,word)=>{if(!ok)throw Error(word)}; +const equal=(a,b)=>check(a===b,`expected ${b}, received ${a}`); +const same=(a,b)=>equal(JSON.stringify([...a]),JSON.stringify([...b])); +async function live(env,ctx){ + let engine,surface; + const receive=ws=>{const queue=[],waiting=[];ws.binaryType='arraybuffer';ws.addEventListener('message',async event=>{const raw=typeof event.data.arrayBuffer==='function'?await event.data.arrayBuffer():event.data;const value=new Uint8Array(raw);if(waiting.length)waiting.shift()(value);else queue.push(value)});ws.accept();return ()=>queue.length?Promise.resolve(queue.shift()):new Promise(resolve=>waiting.push(resolve))}; + try { + const pair=await crypto.subtle.generateKey('X25519',true,['deriveBits']); + const key=new Uint8Array(await crypto.subtle.exportKey('raw',pair.publicKey)),name=await nameFor(key); + const upgrade={upgrade:'websocket','sec-websocket-protocol':PROTOCOL}; + const registered=await call('http://local/v1/engine',{headers:{...upgrade,[HEADER_NAME]:name,[HEADER_KEY]:btoa(String.fromCharCode(...key))}}); + equal(registered.status,101);engine=registered.webSocket;const fromEngine=receive(engine); + const challenge=await fromEngine();equal(challenge.length,64); + const ephemeral=await crypto.subtle.importKey('raw',challenge.subarray(0,32),'X25519',false,[]); + const shared=new Uint8Array(await crypto.subtle.deriveBits({name:'X25519',public:ephemeral},pair.privateKey,256)); + engine.send(await registrationProof(shared,challenge.subarray(32),name,key));same([...await fromEngine()],[1,0]); + const dial=await call('http://local/v1/dial/'+name,{headers:upgrade});equal(dial.status,101);surface=dial.webSocket;const fromSurface=receive(surface); + const opened=readFrame(await fromEngine());equal(opened.op,OP.open); + const payload=Uint8Array.from([0,255,19,47]);surface.send(payload); + const sent=readFrame(await fromEngine());equal(sent.op,OP.data);same(sent.payload,payload); + engine.send(carrierFrame(OP.data,opened.stream,Uint8Array.from([91,0,255])));same(await fromSurface(),Uint8Array.from([91,0,255])); + + return Response.json({registered:true,surface_to_engine:[...sent.payload],engine_to_surface:[91,0,255]}); + }finally{try{surface?.close()}catch{}try{engine?.close()}catch{}} + function call(url,options){return relay.fetch(new Request(url,options),env,ctx)} +} +export default {fetch(request,env,ctx){return new URL(request.url).pathname==='/fixture/storage-live'?live(env,ctx):relay.fetch(request,env,ctx)}}; diff --git a/relay/hosted/test/raft/storage.test.js b/relay/hosted/test/raft/storage.test.js new file mode 100644 index 0000000000..09bd2c3288 --- /dev/null +++ b/relay/hosted/test/raft/storage.test.js @@ -0,0 +1,193 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign, createHash } from 'node:crypto'; +import { hostedFixture } from './hosted-fixture.js'; +import { frame, rid } from '../helpers.js'; +import { ADMIN_DOMAIN } from '../../src/storage.js'; + +test('R1 R3 R4 R6 R8 R9 production Worker approval, cold replay protection, rotation and revocation', {timeout:60000}, async () => { + const keys=generateKeyPairSync('ed25519'), pub=keys.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64url'); + const h=await hostedFixture({base:'/fabric',storageOpen:false,adminKeys:pub}), c=h.client(); + let at=Date.now(); + const admin=async(storage,identity=h.identity,stamp=++at)=>{ + const raw=JSON.stringify({v:1,host:'local',identity,storage,at:stamp,nonce:'test-random-'+stamp}); + const signature=sign(null,Buffer.from(ADMIN_DOMAIN+raw),keys.privateKey).toString('base64url'); + const r=await h.mf.dispatchFetch('http://local/fabric/v1/admin/storage',{method:'POST',headers:{'codeaf-admin-signature':signature},body:raw}); + return {status:r.status,body:await r.json()}; + }; + try { + await h.start(); + // This approval arrives before the namespace has ever authenticated a device. + const approval=await admin('on');assert.equal(approval.status,200,JSON.stringify(approval)); + const stamp=at; + assert.equal((await admin('off',h.identity,stamp)).status,409); + let stats=await h.call(c,'GET','/v1/store/stats');assert.equal(stats.status,200,JSON.stringify(stats));assert.equal(stats.body.storage,'on'); + const f=frame([rid(41)]); + assert.equal((await h.call(c,'POST','/v1/store/frames',f.bytes)).status,200); + assert.equal((await admin('off')).status,200); + stats=await h.call(c,'GET','/v1/store/stats');const used=stats.body.stored_bytes;assert(used>0); + assert.deepEqual(await h.call(c,'POST','/v1/store/frames',frame([rid(42)]).bytes),{status:403,body:{err:'storage_off'}}); + assert.equal((await h.call(c,'GET','/v1/store/stats')).body.stored_bytes,used); + assert.equal((await h.request(c,'GET','/v1/store/objects/'+rid(41))).status,200); + assert.equal((await h.call(c,'GET','/v1/dir/list')).status,200); + await h.start(); + assert.equal((await admin('on',h.identity,stamp)).status,409); + assert.equal((await h.call(c,'GET','/v1/store/stats')).body.storage,'off'); + assert.equal((await admin('on')).status,200); + const nextRoot=generateKeyPairSync('ed25519'),nextDevice=generateKeyPairSync('ed25519'); + const rootBytes=nextRoot.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const deviceBytes=nextDevice.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const digest=raw=>createHash('sha256').update(raw).digest('hex'); + const successor='id_'+digest(rootBytes).slice(0,32); + const cert={V:1,device:deviceBytes.toString('hex'),created:1000,sig:''}; + cert.sig=sign(null,Buffer.from('codeaf/device-cert/v1\n'+JSON.stringify(cert)),nextRoot.privateKey).toString('hex'); + const nextRequest=async(method,path,raw='')=>{ + const stamp=String(Date.now()),uri='/fabric'+path; + return h.mf.dispatchFetch('http://local'+uri,{method,headers:{ + 'Codeaf-Identity':rootBytes.toString('base64url'),'Codeaf-Cert':Buffer.from(JSON.stringify(cert)).toString('base64url'),'Codeaf-Time':stamp, + 'Codeaf-Sig':sign(null,Buffer.from('codeaf-req-v1\n'+method+'\n'+uri+'\n'+stamp+'\n'+digest(raw)),nextDevice.privateKey).toString('base64url')},...(method==='POST'?{body:raw}:{})}); + }; + const handoff={successor,successor_key:rootBytes.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),nextRoot.privateKey).toString('hex')}; + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',handoff),{status:409,body:{err:'not_rotating'}}); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + assert.equal((await h.call(c,'POST','/v1/store/handoff',{successor})).status,400); + assert.equal((await h.call(c,'POST','/v1/store/handoff',{...handoff,proof:''})).status,400); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + assert.equal((await (await nextRequest('GET','/v1/store/stats')).json()).storage,'off'); + assert.equal((await nextRequest('POST','/v1/store/frames',frame([rid(43)]).bytes)).status,403); + const freeze=await h.call(c,'POST','/v1/store/handoff',handoff); + assert.deepEqual(freeze,{status:200,body:{successor,storage:'on'}}); + assert.equal((await (await nextRequest('GET','/v1/store/stats')).json()).storage,'on'); + assert.equal((await nextRequest('POST','/v1/store/frames',frame([rid(43)]).bytes)).status,200); + // A newer direct revocation at the successor outranks inherited approval. + assert.equal((await admin('off',successor)).status,200); + const retry=await h.call(c,'POST','/v1/store/handoff',handoff); + assert.deepEqual(retry,{status:200,body:{successor,storage:'off'}}); + assert.equal((await admin('on',successor,stamp)).status,409); + assert.equal((await (await nextRequest('GET','/v1/store/stats')).json()).storage,'off'); + const otherRoot=generateKeyPairSync('ed25519'),otherKey=otherRoot.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const other='id_'+digest(otherKey).slice(0,32); + const second={successor:other,successor_key:otherKey.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+other),otherRoot.privateKey).toString('hex')}; + await h.start(); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'thaw'})).status,200); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',second),{status:409,body:{err:'not_rotating'}}); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + const refused=await h.call(c,'POST','/v1/store/handoff',second); + assert.deepEqual(refused,{status:409,body:{err:'conflict'}}); + // If inheritance had reached the second identity, this older direct + // command would be refused as stale instead of succeeding. + assert.equal((await admin('off',other,stamp)).status,200); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'retire'})).status,200); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',handoff),{status:200,body:{successor,storage:'off'}}); + const statsOff=await admin('off');assert.equal(statsOff.status,200); + } finally {await h.close();} +}); + +test('S2 first device after preapproval still pays IP and global newcomer budgets', {timeout:60000}, async () => { + for(const limits of [{newIdentitiesPerIpPerDay:0},{globalNewIdentitiesPerDay:0}]) { + const keys=generateKeyPairSync('ed25519'),pub=keys.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64url'); + const h=await hostedFixture({storageOpen:false,adminKeys:pub,limits}); + try { + await h.start(); + const raw=JSON.stringify({v:1,host:'local',identity:h.identity,storage:'on',at:Date.now(),nonce:'preapprove'}); + const r=await h.mf.dispatchFetch('http://local/v1/admin/storage',{method:'POST',body:raw,headers:{'codeaf-admin-signature':sign(null,Buffer.from(ADMIN_DOMAIN+raw),keys.privateKey).toString('base64url')}}); + assert.equal(r.status,200,await r.text()); + const first=await h.call(h.client(),'GET','/v1/store/stats'); + assert.equal(first.status,429,JSON.stringify(first)); + assert.equal(first.body.err,limits.newIdentitiesPerIpPerDay===0?'too_many_identities':'relay_admission_full'); + } finally { await h.close(); } + } +}); + +test('B1 off does not travel and a successor key must match its ID and source-bound proof', {timeout:60000}, async () => { + const keys=generateKeyPairSync('ed25519'),pub=keys.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64url'); + const h=await hostedFixture({storageOpen:false,adminKeys:pub}); + const at=Date.now(); + const admin=async(storage,identity,stamp)=>{ + const raw=JSON.stringify({v:1,host:'local',identity,storage,at:stamp,nonce:'handoff'}); + return h.mf.dispatchFetch('http://local/v1/admin/storage',{method:'POST',body:raw,headers:{'codeaf-admin-signature':sign(null,Buffer.from(ADMIN_DOMAIN+raw),keys.privateKey).toString('base64url')}}); + }; + try { + await h.start();const c=h.client(); + assert.equal((await admin('off',h.identity,at+10)).status,200); + const root=generateKeyPairSync('ed25519'),rawKey=root.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const successor='id_'+createHash('sha256').update(rawKey).digest('hex').slice(0,32); + const handoff={successor,successor_key:rawKey.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),root.privateKey).toString('hex')}; + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + assert.equal((await h.mf.dispatchFetch('http://local/v1/store/handoff',{method:'POST',body:JSON.stringify(handoff)})).status,401); + for(const body of [null,[],{successor},{...handoff,successor_key:''},{...handoff,successor:h.identity},{...handoff,successor:'id_'+'e'.repeat(32)}]) { + assert.equal((await h.call(c,'POST','/v1/store/handoff',body)).status,400,JSON.stringify(body)); + } + assert.equal((await h.call(c,'POST','/v1/store/handoff',{...handoff,proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\nwrong\n'+successor),root.privateKey).toString('hex')})).status,400); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',handoff),{status:200,body:{successor,storage:'off'}}); + // An inherited off at at+10 would make this older approval stale. + assert.equal((await admin('on',successor,at)).status,200); + assert.equal((await h.call(c,'POST','/v1/store/handoff',handoff)).status,200); + assert.equal((await admin('off',successor,at+1)).status,200); + }finally{await h.close()} +}); + +test('S2 real pairing mailbox exchange and machine tunnel bytes while storage is off', {timeout:60000}, async () => { + const h=await hostedFixture({storageOpen:false,entryPoint:'test/raft/storage-live-worker.js'}); + try{ + await h.start();const client=h.client(); + assert.equal((await h.call(client,'GET','/v1/store/stats')).body.storage,'off'); + const pairKey=Buffer.alloc(16,1).toString('base64url'),joinKey=Buffer.alloc(16,2).toString('base64url'); + const created=await h.mf.dispatchFetch('http://local/v1/pair',{method:'POST',headers:{'codeaf-pair-key':pairKey}}); + assert.equal(created.status,201);const {nameplate}=await created.json(); + const post=async(side,key,bytes)=>h.mf.dispatchFetch('http://local/v1/pair/'+nameplate+'/'+side,{method:'POST',headers:{'codeaf-pair-key':key},body:bytes}); + assert.equal((await post('a',pairKey,'sealed identity grant')).status,201); + assert.equal((await post('b',joinKey,'paired acknowledgement')).status,201); + for(const [side,expected]of [['a','sealed identity grant'],['b','paired acknowledgement']]){ + const read=await h.mf.dispatchFetch('http://local/v1/pair/'+nameplate+'/'+side+'?after=0');assert.equal(Buffer.from((await read.json()).msgs[0],'base64').toString(),expected); + } + assert.equal((await h.mf.dispatchFetch('http://local/v1/pair/'+nameplate,{method:'DELETE',headers:{'codeaf-pair-key':pairKey}})).status,204); + const carried=await h.mf.dispatchFetch('http://local/fixture/storage-live');assert.equal(carried.status,200,await carried.clone().text()); + assert.deepEqual(await carried.json(),{registered:true,surface_to_engine:[0,255,19,47],engine_to_surface:[91,0,255]}); + const stats=(await h.call(client,'GET','/v1/store/stats')).body;assert.equal(stats.storage,'off');assert.equal(stats.stored_bytes,0); + assert.equal((await h.call(client,'POST','/v1/admin/storage',{v:1,host:'local',identity:h.identity,storage:'on',at:Date.now(),nonce:'device-only'})).status,401); + const bypass=await h.api(client,{action:'setStorage',storage:'on'});assert(bypass.status>=400,JSON.stringify(bypass)); + assert.equal((await h.call(client,'GET','/v1/store/stats')).body.storage,'off'); + }finally{await h.close()} +}); + +test('R8 globally open storage binds the successor without copying an approval record', {timeout:60000}, async () => { + const keys=generateKeyPairSync('ed25519'),pub=keys.publicKey.export({type:'spki',format:'der'}).subarray(-32).toString('base64url'); + const h=await hostedFixture({storageOpen:true,adminKeys:pub}); + const at=Date.now(); + const admin=async(storage,identity,stamp)=>{ + const raw=JSON.stringify({v:1,host:'local',identity,storage,at:stamp,nonce:'globally-open'}); + return h.mf.dispatchFetch('http://local/v1/admin/storage',{method:'POST',body:raw,headers:{'codeaf-admin-signature':sign(null,Buffer.from(ADMIN_DOMAIN+raw),keys.privateKey).toString('base64url')}}); + }; + try { + await h.start();const c=h.client(); + assert.equal((await admin('on',h.identity,at+10)).status,200); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + const makeHandoff=()=>{ + const root=generateKeyPairSync('ed25519'),rawKey=root.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const successor='id_'+createHash('sha256').update(rawKey).digest('hex').slice(0,32); + return {successor,successor_key:rawKey.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),root.privateKey).toString('hex')}; + }; + const handoff=makeHandoff(); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',handoff),{status:200,body:{successor:handoff.successor,storage:'on'}}); + // An inherited on at at+10 would make this earlier direct off stale. + assert.equal((await admin('off',handoff.successor,at)).status,200); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',makeHandoff()),{status:409,body:{err:'conflict'}}); + } finally { await h.close(); } +}); + +test('R8 a handoff from an unfrozen identity is refused without binding a successor', {timeout:60000}, async () => { + const h=await hostedFixture({storageOpen:false}); + const makeHandoff=()=>{ + const root=generateKeyPairSync('ed25519'),key=root.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const successor='id_'+createHash('sha256').update(key).digest('hex').slice(0,32); + return {successor,successor_key:key.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),root.privateKey).toString('hex')}; + }; + try { + await h.start();const c=h.client(); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',makeHandoff()),{status:409,body:{err:'not_rotating'}}); + assert.equal((await h.call(c,'POST','/v1/identity/rotation',{op:'freeze'})).status,200); + const accepted=makeHandoff(); + assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',accepted),{status:200,body:{successor:accepted.successor,storage:'off'}}); + } finally {await h.close();} +}); diff --git a/relay/hosted/test/storage.test.js b/relay/hosted/test/storage.test.js new file mode 100644 index 0000000000..6c8b50ab8f --- /dev/null +++ b/relay/hosted/test/storage.test.js @@ -0,0 +1,191 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { generateKeyPairSync, sign } from 'node:crypto'; +import { dispatch } from '../src/front.js'; +import { StorageAccess, ADMIN_DOMAIN, ADMIN_WINDOW_MS } from '../src/storage.js'; +import { limitsOf } from '../src/limits.js'; +import { Meta } from '../src/meta.js'; +import { Tenant } from '../src/tenant.js'; +import { RaftBlobTenant } from '../src/raft/effects.js'; +import { sealFrame } from '../src/store.js'; +import { matchRoute } from '../src/routes.js'; +import { PROTOCOL, HEADER_NAME } from '../src/engine/carrier.js'; +import { Flight } from '../src/flight.js'; +import { NOBODY } from '../src/watch.js'; +import { policyOf } from '../src/rules.js'; +import { respond, guarded } from '../src/wire.js'; +import { memorySql } from './sql.js'; +import { frame, rid } from './helpers.js'; + +const account = 'id_' + 'a'.repeat(32); +const refusal = (code, status) => e => e.code === code && e.status === status; +function tenant() { + const data = new Map(), sql = memorySql(); let puts = 0, reserves = 0; + const bucket = { async put(key, value) { puts++; data.set(key, value.slice()); }, async get(key, options) { + const bytes = data.get(key); if (!bytes) return null; + const range = options?.range, off = range?.offset ?? 0; + return { size: bytes.length, body: bytes, arrayBuffer: async () => bytes.slice(off, off + (range?.length ?? bytes.length)).buffer }; + }}; + const t = new Tenant({ identity: account, sql, bucket, clock: () => 1, limits: limitsOf({}), policy: policyOf(), + flight: new Flight(), arm: () => {}, watchers: NOBODY, reserve: async () => { reserves++; } }); + return { t, sql, data, puts: () => puts, reserves: () => reserves }; +} +const call = (t, method, path, body) => { + const route = matchRoute(method, path); + return respond(() => route.handler({ tenant: t, body, device: 'device', request: new Request('https://relay.test' + path, { method }) }, route.args)); +}; + +test('R1 R2 both frame doors refuse before R2 and every quota or global reservation change', async () => { + const r = tenant(), bytes = frame([rid(1)]).bytes; + const before = r.t.quota.snapshot(1); + const response = await call(r.t, 'POST', '/v1/store/frames', bytes); + assert.equal(response.status, 403); assert.deepEqual(await response.json(), { err: 'storage_off' }); + const raft = new RaftBlobTenant(r.t.store, async () => {}, async () => {}); + let admits = 0; + await assert.rejects(raft.putFrame(await sealFrame(bytes), () => { admits++; }), refusal('storage_off', 403)); + assert.equal(r.puts(), 0); assert.equal(r.reserves(), 0); assert.equal(admits, 0); + assert.deepEqual(r.t.quota.snapshot(1), before); + assert.deepEqual(r.sql.exec('SELECT * FROM frames').toArray(), []); +}); + +test('R3 R4 R9 approval and revocation preserve reads, directory writes and stats', async () => { + const r = tenant(), f = frame([rid(2)]); + assert.equal((await (await call(r.t, 'GET', '/v1/store/stats')).json()).storage, 'off'); + r.t.storage.set({ storage: 'on', at: 1 }); + assert.equal((await call(r.t, 'POST', '/v1/store/frames', f.bytes)).status, 200); + assert.equal((await (await call(r.t, 'GET', '/v1/store/stats')).json()).storage, 'on'); + r.t.storage.set({ storage: 'off', at: 2 }); + assert.equal((await call(r.t, 'POST', '/v1/store/frames', f.bytes)).status, 403); + assert.deepEqual(await r.t.getObject(rid(2)), f.objs[0]); + assert.deepEqual(await r.t.has([rid(2)]), [true]); + assert.equal(Object.keys(await r.t.locate([rid(2)])).length, 1); + assert.equal((await r.t.getFrame((await sealFrame(f.bytes)).id)).size, f.bytes.length); + r.t.dir.create('chat', { head: 'h', class: 'chat' }, 'device'); + assert.equal(r.t.dir.cell('chat').cell.head, 'h'); + assert.equal((await (await call(r.t, 'GET', '/v1/store/stats')).json()).storage, 'off'); +}); + +test('R5 storageOpen accepts true/1 only and default is closed in every deployment', async () => { + for (const value of [undefined, false, 0, 'false', '0', 'yes']) assert.equal(limitsOf({ CAF_LIMITS: JSON.stringify({ storageOpen: value }) }).storageOpen, false); + for (const value of [true, 1, 'true', '1']) { + const r = tenant(); r.t.storage.limits = limitsOf({ CAF_LIMITS: JSON.stringify({ storageOpen: value }) }); + assert.equal(r.t.storage.state(), 'on'); + await r.t.putFrame(frame([rid(3)]).bytes); assert.equal(r.puts(), 1); + } + const config = readFileSync(new URL('../wrangler.toml', import.meta.url), 'utf8'); + assert(config.includes('CAF_ADMIN_KEYS = "aTuSKJXUX8oeetqpxdDIqyB7tHfetea0dmlHJzEfreQ"')); + assert(!/storageOpen\s*[":= ]+\s*(true|1)/.test(config)); + const generator = readFileSync(new URL('../../../scripts/relay-wrangler-config.sh', import.meta.url), 'utf8'); + assert(generator.includes('print $0')); + for (const channel of ['stable', 'staging', 'dev']) { + const generated = execFileSync('bash', [new URL('../../../scripts/relay-wrangler-config.sh', import.meta.url).pathname, channel], { env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: 'test-account' }, encoding: 'utf8' }); + assert(generated.includes('CAF_ADMIN_KEYS = "aTuSKJXUX8oeetqpxdDIqyB7tHfetea0dmlHJzEfreQ"')); + assert(!/storageOpen/.test(generated)); + } +}); + +function admin() { + const keys = generateKeyPairSync('ed25519'), pub = keys.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('base64url'); + const records = new Map(); let admissions = 0; + const env = { BASE_PATH: '/fabric', CAF_ADMIN_KEYS: pub, IDENTITY: { + idFromName(id) { return id; }, get(id) { return { setStorage: body => guarded(() => { + let state = records.get(id); + if (!state) { state = new StorageAccess(new Meta(memorySql()), limitsOf({})); records.set(id, state); } + state.set(body); + }) }; }, + }, NEWCOMERS: { get() { admissions++; throw Error('approval must not admit'); } } }; + const now = Date.now(); + const body = { v: 1, host: 'relay.test', identity: account, storage: 'on', at: now, nonce: 'random-test-nonce' }; + const send = async (changes = {}, options = {}) => { + const raw = options.raw ?? JSON.stringify({ ...body, ...changes }); + const sig = options.signature ?? sign(null, Buffer.concat([Buffer.from(ADMIN_DOMAIN), Buffer.from(raw)]), keys.privateKey).toString('base64url'); + return respond(() => dispatch(new Request('https://relay.test/fabric/v1/admin/storage', { method: 'POST', headers: { 'codeaf-admin-signature': sig }, body: raw }), env)); + }; + return { env, records, body, signature: (raw, label=ADMIN_DOMAIN) => sign(null,Buffer.from(label+raw),keys.privateKey).toString('base64url'), send, admissions: () => admissions }; +} + +test('R6 R7 signed front-door approval works before first device contact; stale and reordered commands fail', async () => { + const a = admin(); + const response = await a.send(); assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { identity: account, storage: 'on' }); + assert.equal(a.admissions(), 0); + assert.equal((await a.send()).status, 409); + assert.equal((await a.send({ at: a.body.at - 1, storage: 'off' })).status, 409); + assert.equal((await a.send({ at: a.body.at + 1, storage: 'off' })).status, 200); + assert.equal(a.records.get(account).state(), 'off'); +}); + +test('R6 R7 signature, exact bytes, host, time window, shape and missing keys are enforced', async () => { + const a = admin(); + for (const changes of [{ host: 'other.test' }, { at: a.body.at - ADMIN_WINDOW_MS - 1000 }, { at: a.body.at + ADMIN_WINDOW_MS + 1000 }]) assert.equal((await a.send(changes)).status, 401); + for (const changes of [{ v: 2 }, { identity: 'bad' }, { storage: true }, { nonce: '' }, { at: '1' }]) assert.equal((await a.send(changes)).status, 400); + assert.equal((await a.send({}, { raw: '{' })).status, 400); + assert.equal((await a.send({}, { signature: 'bad' })).status, 401); + const unknown = admin(); a.env.CAF_ADMIN_KEYS = unknown.env.CAF_ADMIN_KEYS; + assert.equal((await a.send()).status, 401); + a.env.CAF_ADMIN_KEYS = ''; assert.equal((await a.send()).status, 401); + assert.equal(a.records.size, 0); +}); + +test('R1 structural guard keeps all production R2 puts inside the gated writer', () => { + const root = new URL('../src/', import.meta.url), paths = []; + const visit = dir => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = new URL(entry.name, dir); if (entry.isDirectory()) visit(new URL(entry.name + '/', dir)); else if (entry.name.endsWith('.js')) paths.push(path); } }; + visit(root); + const puts = paths.flatMap(path => [...readFileSync(path, 'utf8').matchAll(/\b(?:bucket|FRAMES)\s*(?:\.\s*put|\[\s*['"]put['"]\s*\])\s*\(/g)].map(() => path.pathname)); + assert.deepEqual(puts, [new URL('../src/store.js', import.meta.url).pathname]); + const constructors = paths.filter(path => /new R2Store\(/.test(readFileSync(path, 'utf8'))).map(path => path.pathname); + assert.deepEqual(constructors, [new URL('../src/tenant.js', import.meta.url).pathname]); + const tenantSource = readFileSync(new URL('../src/tenant.js', import.meta.url), 'utf8'); + assert.match(tenantSource, /new R2Store\(bucket, sql, identity, \(\) => this\.storage\.assert\(\)\)/); + const store = readFileSync(new URL('../src/store.js', import.meta.url), 'utf8'); + assert.match(store, /this\.assertStorage\(\);\s*await this\.bucket\.put/); + assert.match(store, /this\.assertStorage\(\);\s*if \(this\.#indexed/); +}); + + +test('R3 pairing and machine tunnel ingress remain available with storage off', async () => { + let pairs=0, machines=0; + const gate={create:async()=>{pairs++;return {ok:{nameplate:42}};}}; + const env={CAF_LIMITS:'{"storageOpen":false}',PAIR_GATE:{idFromName:x=>x,get:()=>gate}, + MACHINE:{idFromName:x=>x,get:()=>({fetch:async()=>{machines++;return new Response('live',{status:200});}})}, + MACHINE_DIAL_LIMIT:{limit:async()=>({success:true})},MACHINE_REGISTER_LIMIT:{limit:async()=>({success:true})}, + FRAMES:new Proxy({}, {get(){throw Error('live access touched R2');}}), + IDENTITY:new Proxy({}, {get(){throw Error('live access consulted storage approval');}})}; + let response=await respond(()=>dispatch(new Request('https://relay.test/v1/pair',{method:'POST',headers:{'codeaf-pair-key':Buffer.alloc(16,1).toString('base64url')}}),env)); + assert.equal(response.status,201);assert.equal(pairs,1); + for(const path of ['/v1/engine','/v1/dial/amber-forest-42']) { + response=await respond(()=>dispatch(new Request('https://relay.test'+path,{headers:{upgrade:'websocket','sec-websocket-protocol':PROTOCOL,[HEADER_NAME]:'amber-forest-42'}}),env)); + assert.equal(response.status,200,await response.text()); + } + assert.equal(machines,2); +}); + +test('S2 a fixed valid signature refuses changed bytes, whitespace and a missing domain label', async () => { + const a=admin(), raw=JSON.stringify(a.body), signature=a.signature(raw); + for(const changed of [raw.replace('random-test-nonce','random-test-noncf'),raw+' ']) assert.equal((await a.send({}, {raw:changed,signature})).status,401); + assert.equal((await a.send({}, {raw,signature:a.signature(raw,'')})).status,401); + assert.equal(a.records.size,0); + delete a.env.CAF_ADMIN_KEYS; + assert.equal((await a.send()).status,401); + const other=admin(); a.env.CAF_ADMIN_KEYS=other.env.CAF_ADMIN_KEYS+', '+admin().env.CAF_ADMIN_KEYS; + assert.equal((await a.send()).status,401); + a.env.CAF_ADMIN_KEYS+=', '+other.env.CAF_ADMIN_KEYS; + const b=admin(); b.env.CAF_ADMIN_KEYS=other.env.CAF_ADMIN_KEYS+', '+b.env.CAF_ADMIN_KEYS; + assert.equal((await b.send()).status,200); +}); + +test('B1 inheritance copies only on into an empty successor and never replaces its own record', () => { + for(const state of ['on','off']) { + const access=new StorageAccess(new Meta(memorySql()), limitsOf({})); + access.set({storage:state,at:1}); + access.set({storage:'on',at:5},true); + assert.deepEqual(access.approval(),{storage:state,at:1}); + } + const empty=new StorageAccess(new Meta(memorySql()),limitsOf({})); + empty.set({storage:'off',at:10},true); assert.equal(empty.approval(),undefined); + empty.set({storage:'on',at:11},true); assert.equal(empty.state(),'on'); + const open=new StorageAccess(new Meta(memorySql()),{storageOpen:true}); + open.set({storage:'on',at:12},true); assert.equal(open.approval(),undefined); +}); diff --git a/relay/hosted/test/store.test.js b/relay/hosted/test/store.test.js index 34286792c6..35b014c98c 100644 --- a/relay/hosted/test/store.test.js +++ b/relay/hosted/test/store.test.js @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; import { Conflict, R2Store, sealFrame } from '../src/store.js'; import { Tenant } from '../src/tenant.js'; import { matchRoute } from '../src/routes.js'; -import { DEFAULTS } from '../src/limits.js'; +import { DEFAULTS as CLOSED_DEFAULTS } from '../src/limits.js'; +const DEFAULTS = { ...CLOSED_DEFAULTS, storageOpen: true }; import { policyOf } from '../src/rules.js'; import { Flight } from '../src/flight.js'; import { Wire } from '../src/wire.js'; @@ -33,7 +34,7 @@ before(async () => { env = await openBucket(); }); after(() => env.close()); // One identity's state is its SQLite and its R2 prefix; a "cold" store is a new object over the same two. -const open = (identity, sql = memorySql()) => ({ sql, store: new R2Store(env.bucket, sql, identity) }); +const open = (identity, sql = memorySql()) => ({ sql, store: new R2Store(env.bucket, sql, identity, () => {}) }); const put = async (store, f, admit) => store.put(await sealFrame(f.bytes), admit); test('stats adds durable stored totals and active limits without changing request counters or writing on a read', async () => { @@ -59,14 +60,14 @@ test('stats adds durable stored totals and active limits without changing reques const caps = { limit_bytes: 10_000, limit_objects: 100, limit_frames_per_day: 10 }; queries.length = 0; const stats = async (tenant = cold) => (await runRoute('GET', '/v1/store/stats', tenant)).json(); - assert.deepEqual(await stats(), { ...counters, ...used, ...caps }); + assert.deepEqual(await stats(), { storage: 'on', ...counters, ...used, ...caps }); assert.deepEqual(counters, { puts: 3, gets: 1, has: 1, bytes_in: first.bytes.length * 2 + second.bytes.length, bytes_out: first.objs[0].length }); - assert.deepEqual(await stats(tenantOf('id_stats_other')), { puts: 0, gets: 0, has: 0, bytes_in: 0, bytes_out: 0, + assert.deepEqual(await stats(tenantOf('id_stats_other')), { storage: 'on', puts: 0, gets: 0, has: 0, bytes_in: 0, bytes_out: 0, stored_bytes: 0, stored_objects: 0, frames_today: 0, limit_bytes: DEFAULTS.storeBytes, limit_objects: DEFAULTS.storeObjects, limit_frames_per_day: DEFAULTS.framesPerDay }); now = 86_400_000; - assert.deepEqual(await stats(), { ...counters, ...used, frames_today: 0, ...caps }); + assert.deepEqual(await stats(), { storage: 'on', ...counters, ...used, frames_today: 0, ...caps }); assert.deepEqual(sql.exec('SELECT day, frames, bytes, objects FROM usage').one(), { day: 0, frames: 2, bytes: used.stored_bytes, objects: 3, }); @@ -185,7 +186,7 @@ test('getMany costs one R2 read per frame touched, not one per object', async () const { sql, store } = open('id_many_cost'); await put(store, frame([rid(1), rid(2), rid(3), rid(4)])); const counted = countingBucket(env.bucket); - const cold = new R2Store(counted.bucket, sql, 'id_many_cost'); + const cold = new R2Store(counted.bucket, sql, 'id_many_cost', () => {}); await cold.getMany([rid(4), rid(1), rid(3), rid(2)]); assert.equal(counted.calls.get, 1); }); @@ -274,7 +275,7 @@ test('R21 upload references become confirmable only after local R2 and index com async put(...args) { if (fail) throw new Error('injected R2 failure'); return env.bucket.put(...args); }, get(...args) { return env.bucket.get(...args); }, }; - const sql = memorySql(), store = new R2Store(bucket, sql, 'id_r21'); + const sql = memorySql(), store = new R2Store(bucket, sql, 'id_r21', () => {}); const f = frame([rid(21), rid(22)]), sealed = await sealFrame(f.bytes); await assert.rejects(store.put(sealed), /injected R2 failure/); await assert.rejects(store.checkReferences(rid(21), [sealed.id], async () => {})); @@ -283,7 +284,7 @@ test('R21 upload references become confirmable only after local R2 and index com let completed = false; await store.checkReferences(rid(21), [sealed.id], async () => { completed = true; }); assert(completed); - const cold = new R2Store(bucket, sql, 'id_r21'); + const cold = new R2Store(bucket, sql, 'id_r21', () => {}); await cold.checkReferences(rid(21), [], async () => {}); assert.deepEqual(await cold.get(rid(22)), f.objs[1]); }); diff --git a/relay/hosted/wrangler.toml b/relay/hosted/wrangler.toml index 0a3f6fa9d3..48b6dfb963 100644 --- a/relay/hosted/wrangler.toml +++ b/relay/hosted/wrangler.toml @@ -54,6 +54,7 @@ new_sqlite_classes = ["LinkGate", "LinkRequest"] command = "sh ../../scripts/raft-hosted-build.sh" [vars] +CAF_ADMIN_KEYS = "aTuSKJXUX8oeetqpxdDIqyB7tHfetea0dmlHJzEfreQ" # "amended": 90-second leases, renewed by publish; "stage1": 30-second leases. LEASE_POLICY = "amended" diff --git a/scripts/relay-conformance.sh b/scripts/relay-conformance.sh index 64de4d66d7..78847e23bc 100755 --- a/scripts/relay-conformance.sh +++ b/scripts/relay-conformance.sh @@ -168,9 +168,12 @@ OPEN='{"newIdentitiesPerIpPerDay":1000000,"minGraceMs":2000,"sweepPageSize":2,"r SMALL='{"newIdentitiesPerIpPerDay":1000000,"pairMaxBoxes":2}' EXPIRY='{"newIdentitiesPerIpPerDay":1000000,"pairTtlMs":4000}' wrangler() { # port, persist dir, limits json + # These existing conformance cases exercise enabled storage; opt-in has its own closed-default proofs. + local storage_limits + storage_limits=$(node -e 'console.log(JSON.stringify({...JSON.parse(process.argv[1]), storageOpen:true}))' "$3") start_relay "http://127.0.0.1:$1/" "$work/worker-$1.log" \ sh "$hosted/test/local.sh" "$hosted/node_modules/.bin/wrangler" dev --local -c "${CONFORMANCE_WORKER_CONFIG:-$hosted/wrangler.toml}" --name "codeaf-conformance-$1" --inspector-port 0 --ip 127.0.0.1 --port "$1" --persist-to "$2" \ - --var "CAF_LIMITS:$3" --var TRUST_PROXY:1 + --var "CAF_LIMITS:$storage_limits" --var TRUST_PROXY:1 } # Track 2: relayconf through the Worker. From 9b65649d2782f46342e742221cbba46d1f08b7ee Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 21:07:43 -0500 Subject: [PATCH 02/18] Discover relay storage permission and keep uploads local while it is off The client reads storage_off as its own refusal, keeps the last answer beside the saved relay, and makes no store writes while a hosted relay says off. One refresher per process asks at start, after /status and every ten minutes while off, and asks nothing while on or for a self-hosted relay; a halted drive retries against the local reading, so approval resumes sync within a minute. Rotation sends the original freeze bytes to every relay and hands the approval over through the separate route only when the relay reports storage, before any resealing upload; a rotation with storage off finishes without store writes. codeaf-relay-admin approves and revokes accounts; it is not part of any release. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf-relay-admin/main.go | 180 ++++++++++++ cmd/codeaf-relay-admin/main_test.go | 87 ++++++ internal/blobstore/http.go | 18 +- internal/blobstore/storage_optin_test.go | 53 ++++ internal/blobstore/wire.go | 2 + internal/cellsync/batch.go | 12 +- internal/cellsync/refusal.go | 9 +- internal/cellsync/storage_optin_test.go | 66 +++++ internal/chatlist/copy.go | 21 +- internal/directory/rotation.go | 47 ++- internal/directory/storage_handoff_test.go | 103 +++++++ internal/directory/storage_optin_test.go | 21 ++ internal/directory/wire.go | 1 + internal/rotate/journal.go | 3 + internal/rotate/plan.go | 12 +- internal/rotate/rotate.go | 46 ++- internal/rotate/steps.go | 36 ++- internal/rotate/storage_optin_test.go | 225 +++++++++++++++ internal/syncsetup/drive.go | 16 ++ internal/syncsetup/rotate.go | 39 ++- internal/syncsetup/state.go | 8 +- internal/syncsetup/storage.go | 136 +++++++++ internal/syncsetup/storage_batcher_test.go | 110 +++++++ internal/syncsetup/storage_headless_test.go | 93 ++++++ internal/syncsetup/storage_optin_test.go | 171 +++++++++++ internal/syncsetup/storage_refresh.go | 299 ++++++++++++++++++++ internal/syncsetup/storage_refresh_test.go | 186 ++++++++++++ internal/syncsetup/syncsetup.go | 2 +- internal/syncsetup/take.go | 2 +- internal/taskdoor/storage_optin_test.go | 93 ++++++ internal/vaultsync/storage_optin_test.go | 17 ++ internal/vaultsync/syncer.go | 9 + internal/wireauth/wireauth.go | 3 + 33 files changed, 2095 insertions(+), 31 deletions(-) create mode 100644 cmd/codeaf-relay-admin/main.go create mode 100644 cmd/codeaf-relay-admin/main_test.go create mode 100644 internal/blobstore/storage_optin_test.go create mode 100644 internal/cellsync/storage_optin_test.go create mode 100644 internal/directory/storage_handoff_test.go create mode 100644 internal/directory/storage_optin_test.go create mode 100644 internal/rotate/storage_optin_test.go create mode 100644 internal/syncsetup/storage.go create mode 100644 internal/syncsetup/storage_batcher_test.go create mode 100644 internal/syncsetup/storage_headless_test.go create mode 100644 internal/syncsetup/storage_optin_test.go create mode 100644 internal/syncsetup/storage_refresh.go create mode 100644 internal/syncsetup/storage_refresh_test.go create mode 100644 internal/taskdoor/storage_optin_test.go create mode 100644 internal/vaultsync/storage_optin_test.go diff --git a/cmd/codeaf-relay-admin/main.go b/cmd/codeaf-relay-admin/main.go new file mode 100644 index 0000000000..171023c8c1 --- /dev/null +++ b/cmd/codeaf-relay-admin/main.go @@ -0,0 +1,180 @@ +// codeaf-relay-admin is an operator tool and is deliberately excluded from releases. +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +const adminDomain = "codeaf/relay-admin/v1\n" +const adminPath = "/v1/admin/storage" + +var requestCode = regexp.MustCompile(`^id_[0-9a-f]{32}$`) + +type approval struct { + V int `json:"v"` + Host string `json:"host"` + Identity string `json:"identity"` + Storage string `json:"storage"` + At int64 `json:"at"` + Nonce string `json:"nonce"` +} + +func main() { + if err := run(context.Background(), os.Args[1:], os.Stdout, &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(ctx context.Context, args []string, out io.Writer, hc *http.Client) error { + if len(args) == 0 { + return errors.New("usage: codeaf-relay-admin approve|revoke [--relay prod|staging|dev|] [--key ], or public-key [--key ]") + } + action, args := args[0], args[1:] + if action != "approve" && action != "revoke" && action != "public-key" { + return errors.New("unknown admin command") + } + code := "" + if action != "public-key" { + if len(args) == 0 || !requestCode.MatchString(args[0]) { + return errors.New("request code must be id_ followed by 32 lowercase hex digits") + } + code, args = args[0], args[1:] + } + flags := flag.NewFlagSet("codeaf-relay-admin", flag.ContinueOnError) + flags.SetOutput(io.Discard) + relay := flags.String("relay", "prod", "relay name or URL") + keyPath := flags.String("key", os.Getenv("CODEAF_RELAY_ADMIN_KEY"), "private seed file") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("unexpected admin command argument") + } + if *keyPath == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + *keyPath = filepath.Join(home, ".config", "codeaf-relay-admin", "admin.key") + } + key, err := readKey(*keyPath) + if err != nil { + return err + } + if action == "public-key" { + _, err = fmt.Fprintln(out, base64.RawURLEncoding.EncodeToString(key.Public().(ed25519.PublicKey))) + return err + } + base, err := relayURL(*relay) + if err != nil { + return err + } + nonce := make([]byte, 24) + if _, err = rand.Read(nonce); err != nil { + return err + } + storage := "on" + if action == "revoke" { + storage = "off" + } + raw, err := json.Marshal(approval{1, base.Host, code, storage, time.Now().UnixMilli(), base64.RawURLEncoding.EncodeToString(nonce)}) + if err != nil { + return err + } + signature := ed25519.Sign(key, append([]byte(adminDomain), raw...)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, wireauth.Endpoint(base.String(), adminPath), bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("codeaf-admin-signature", base64.RawURLEncoding.EncodeToString(signature)) + resp, err := hc.Do(req) + if err != nil { + return errors.New("relay request failed: " + err.Error()) + } + defer resp.Body.Close() + answer, err := io.ReadAll(io.LimitReader(resp.Body, 4097)) + if err != nil { + return err + } + if len(answer) > 4096 { + return errors.New("relay answer is too large") + } + var reply struct { + Identity string `json:"identity"` + Storage string `json:"storage"` + Err string `json:"err"` + } + if json.Unmarshal(answer, &reply) != nil { + return fmt.Errorf("relay answered HTTP %d with an unreadable answer", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("relay refused: %s (HTTP %d)", reply.Err, resp.StatusCode) + } + if reply.Identity != code || reply.Storage != storage { + return errors.New("relay answer does not match the request") + } + _, err = fmt.Fprintf(out, "%s storage %s\n", reply.Identity, reply.Storage) + return err +} + +func relayURL(raw string) (*url.URL, error) { + switch raw { + case "prod": + raw = syncsetup.ProductionRelayURL + case "staging": + raw = syncsetup.StagingRelayURL + case "dev": + raw = syncsetup.DevRelayURL + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, errors.New("relay must be prod, staging, dev or an HTTP URL") + } + return u, nil +} + +func readKey(path string) (ed25519.PrivateKey, error) { + file, err := os.Open(path) + if err != nil { + return nil, errors.New("could not open admin key file") + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { + return nil, errors.New("admin key file must be a regular file readable only by its owner") + } + raw, err := io.ReadAll(io.LimitReader(file, 128)) + if err != nil { + return nil, err + } + text := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "\r") + seed, err := base64.RawURLEncoding.Strict().DecodeString(text) + if err != nil || len(seed) != ed25519.SeedSize { + return nil, errors.New("admin key file must contain one unpadded base64url Ed25519 seed") + } + return ed25519.NewKeyFromSeed(seed), nil +} diff --git a/cmd/codeaf-relay-admin/main_test.go b/cmd/codeaf-relay-admin/main_test.go new file mode 100644 index 0000000000..ca417b7956 --- /dev/null +++ b/cmd/codeaf-relay-admin/main_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestStorageOptInAdminSignedRequestAndRefusals(t *testing.T) { + pub, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "test.key") + if err := os.WriteFile(path, []byte(base64.RawURLEncoding.EncodeToString(key.Seed())+"\n"), 0600); err != nil { + t.Fatal(err) + } + code := "id_" + strings.Repeat("b", 32) + calls, refused := 0, false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + raw, _ := io.ReadAll(r.Body) + signature, _ := base64.RawURLEncoding.DecodeString(r.Header.Get("codeaf-admin-signature")) + if !ed25519.Verify(pub, append([]byte(adminDomain), raw...), signature) { + t.Error("bad signature") + } + var body approval + if json.Unmarshal(raw, &body) != nil || body.Host != r.Host || body.Identity != code || body.Nonce == "" || time.Since(time.UnixMilli(body.At)).Abs() > time.Minute { + t.Errorf("bad body: %+v", body) + } + if r.URL.Path != "/fabric/v1/admin/storage" { + t.Errorf("lost prefix: %s", r.URL.Path) + } + if refused { + w.WriteHeader(409) + _, _ = w.Write([]byte(`{"err":"stale"}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"identity": code, "storage": body.Storage}) + })) + defer srv.Close() + for _, action := range []string{"approve", "revoke"} { + var out bytes.Buffer + if err := run(t.Context(), []string{action, code, "--key", path, "--relay", srv.URL + "/fabric"}, &out, srv.Client()); err != nil { + t.Fatal(err) + } + state := "on" + if action == "revoke" { + state = "off" + } + if out.String() != code+" storage "+state+"\n" { + t.Fatalf("answer: %q", out.String()) + } + } + refused = true + if err := run(t.Context(), []string{"approve", code, "--key", path, "--relay", srv.URL + "/fabric"}, io.Discard, srv.Client()); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("refusal: %v", err) + } + if err := run(t.Context(), []string{"approve", "bad", "--key", path, "--relay", srv.URL}, io.Discard, srv.Client()); err == nil || calls != 3 { + t.Fatal("invalid code sent a request") + } + var out bytes.Buffer + if err := run(t.Context(), []string{"public-key", "--key", path}, &out, srv.Client()); err != nil || strings.TrimSpace(out.String()) != base64.RawURLEncoding.EncodeToString(pub) { + t.Fatalf("public key: %v %q", err, out.String()) + } + _ = os.Chmod(path, 0640) + if _, err := readKey(path); err == nil { + t.Fatal("group-readable key accepted") + } + for name, want := range map[string]string{"prod": syncsetup.ProductionRelayURL, "staging": syncsetup.StagingRelayURL, "dev": syncsetup.DevRelayURL} { + got, err := relayURL(name) + if err != nil || got.String() != want { + t.Fatalf("relay %s: %v %v", name, got, err) + } + } +} diff --git a/internal/blobstore/http.go b/internal/blobstore/http.go index 352bcab162..22c991c3ff 100644 --- a/internal/blobstore/http.go +++ b/internal/blobstore/http.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,9 +16,13 @@ import ( // HTTP is a Store that lives behind the store wire (contract §3). Every // request is signed, so the far side knows whose namespace it is. type HTTP struct { - base string - sign wireauth.Sign - hc *http.Client + // CanStore screens uploads locally while leaving the read wire available. + CanStore func() bool + // OnStorageOff remembers a refusal immediately for other local uploaders. + OnStorageOff func() + base string + sign wireauth.Sign + hc *http.Client // deadline sizes each request's time limit from its body; a test swaps it // to make a modest frame time out. @@ -35,8 +40,14 @@ func NewHTTP(base string, sign wireauth.Sign, hc *http.Client) *HTTP { // PutFrame implements Store. func (c *HTTP) PutFrame(ctx context.Context, frame []byte) (FrameID, error) { + if c.CanStore != nil && !c.CanStore() { + return "", wireauth.ErrStorageOff + } var out putAnswer if err := c.doJSON(ctx, http.MethodPost, pathFrames, frame, &out); err != nil { + if errors.Is(err, wireauth.ErrStorageOff) && c.OnStorageOff != nil { + c.OnStorageOff() + } return "", err } return out.Frame, nil @@ -139,6 +150,7 @@ func (c *HTTP) Stats(ctx context.Context) (Counts, error) { // Counts holds request counters and optional stored totals for one identity. // Nil totals distinguish an older or self-hosted relay from a reported zero. type Counts struct { + Storage string Puts, Gets, Has, BytesIn, BytesOut int64 StoredBytes, StoredObjects, FramesToday *int64 LimitBytes, LimitObjects, LimitFramesPerDay *int64 diff --git a/internal/blobstore/storage_optin_test.go b/internal/blobstore/storage_optin_test.go new file mode 100644 index 0000000000..3300e34e74 --- /dev/null +++ b/internal/blobstore/storage_optin_test.go @@ -0,0 +1,53 @@ +package blobstore + +import ( + "context" + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/wireauth" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStorageOptInWireAndNoWriteRequest(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path == pathStats { + _, _ = w.Write([]byte(`{"storage":"off"}`)) + return + } + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"err":"storage_off"}`)) + })) + defer srv.Close() + c := NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client()) + if _, err := c.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("wire refusal: %v", err) + } + c.CanStore = func() bool { return false } + if _, err := c.PutFrame(context.Background(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 1 { + t.Fatalf("local gate: calls=%d err=%v", calls, err) + } + counts, err := c.Stats(t.Context()) + if err != nil || counts.Storage != "off" || calls != 2 { + t.Fatalf("stats while off: %+v %v", counts, err) + } + if errors.Is(wireauth.ErrStorageOff, ErrFull) || errors.Is(wireauth.ErrStorageOff, wireauth.ErrRelayFull) { + t.Fatal("off must remain distinct from full") + } +} + +func TestStorageOptInSelfHostedStatsOmitStorage(t *testing.T) { + h := Handler(func(*http.Request, []byte) (string, string, error) { return "id", "device", nil }, func(string) (Store, error) { return NewMemory(), nil }) + r := httptest.NewRecorder() + h.ServeHTTP(r, httptest.NewRequest("GET", pathStats, nil)) + var body map[string]any + if err := json.Unmarshal(r.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if _, present := body["storage"]; present { + t.Fatal("self-hosted stats sent storage state") + } +} diff --git a/internal/blobstore/wire.go b/internal/blobstore/wire.go index 6044fca6d5..b3389adf68 100644 --- a/internal/blobstore/wire.go +++ b/internal/blobstore/wire.go @@ -16,6 +16,7 @@ var wireErrors = []struct { status int err error }{ + {"storage_off", http.StatusForbidden, wireauth.ErrStorageOff}, {"bad_frame", http.StatusBadRequest, ErrBadFrame}, {"bad_rid", http.StatusBadRequest, ErrBadRID}, {"too_many", http.StatusBadRequest, ErrTooMany}, @@ -100,6 +101,7 @@ func LimitOf(err error) int64 { // statsBody is the answer to GET /v1/store/stats. type statsBody struct { + Storage string `json:"storage,omitempty"` Puts int64 `json:"puts"` Gets int64 `json:"gets"` Has int64 `json:"has"` diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go index fae34273ff..7466d1ccbe 100644 --- a/internal/cellsync/batch.go +++ b/internal/cellsync/batch.go @@ -500,7 +500,7 @@ func (b *Batcher) publish(ctx context.Context, head string) error { b.adopt(d) b.setFlag(&b.published, true) b.tell(0) // a publish always carries the newest turn, so nothing is pending - b.recovered() + b.recovered(true) turns := b.durable(head) b.emit(Flush{Head: head, Turns: turns, Frames: len(ex.Frames), Objects: ex.Objects, Bytes: ex.Bytes}) return nil @@ -520,7 +520,7 @@ func (b *Batcher) beat(ctx context.Context) { switch { case err == nil: b.tell(pending) - b.recovered() + b.recovered(false) case errors.Is(err, directory.ErrFenceStale): b.lost(ctx, d) default: @@ -716,10 +716,14 @@ func (b *Batcher) now() time.Time { return time.Now() } -// recovered notes that the relay answered, so the next refusal is news again. -func (b *Batcher) recovered() { +// A directory heartbeat cannot end an upload-permission refusal episode. +// Only a successful upload proves that storage has recovered. +func (b *Batcher) recovered(upload bool) { b.mu.Lock() defer b.mu.Unlock() + if !upload && b.told == wireauth.ErrStorageOff.Error() { + return + } b.refusedSince, b.told = time.Time{}, "" } diff --git a/internal/cellsync/refusal.go b/internal/cellsync/refusal.go index f4975f6c22..4321e5d9f8 100644 --- a/internal/cellsync/refusal.go +++ b/internal/cellsync/refusal.go @@ -31,6 +31,7 @@ type Refusal struct { // within minutes stays quiet for a few of them, because a burst that ended // before anyone could act is noise; a longer refusal is said at once. var refusals = []Refusal{ + {Is: wireauth.ErrStorageOff, Say: said(chatlist.RelayStorageOff), Halts: true, RetryStartup: true}, // Both decoders preserve a general full match, so shared capacity must come first. {Is: wireauth.ErrRelayFull, Say: said(chatlist.RelayStorageFull), Halts: true, RetryStartup: true}, {Is: blobstore.ErrFull, Say: fullLine, Halts: true, RetryStartup: true}, @@ -73,15 +74,19 @@ func (r Refusal) StartupWait(prev, interval time.Duration, err error) (time.Dura } // next is the wait after a failed flush that followed a wait of prev. The relay's -// own Retry-After is a floor on any wait, and a halt outranks every other. +// own Retry-After is a floor except for storage permission, whose local gate +// must retry within a minute. Capacity halts outrank other waits. func (r Refusal) next(prev, interval time.Duration, err error) time.Duration { wait := min(2*max(prev, interval), max(MaxBackoff, interval)) if r.Flat { wait = interval } wait = max(wait, min(wireauth.After(err), MaxHalt)) - if r.Halts { + if r.Halts && !errors.Is(err, wireauth.ErrStorageOff) { wait = MaxHalt } + if errors.Is(err, wireauth.ErrStorageOff) { + wait = min(wait, MaxBackoff) + } return wait } diff --git a/internal/cellsync/storage_optin_test.go b/internal/cellsync/storage_optin_test.go new file mode 100644 index 0000000000..3375d3d8c6 --- /dev/null +++ b/internal/cellsync/storage_optin_test.go @@ -0,0 +1,66 @@ +package cellsync + +import ( + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/wireauth" + "testing" + "time" +) + +func TestStorageOptInRefusalHaltsAndSpeaksOnce(t *testing.T) { + r, ok := RefusalOf(wireauth.ErrStorageOff) + if !ok || !r.Halts || !r.RetryStartup || r.Say(nil) != chatlist.RelayStorageOff { + t.Fatalf("storage row: %+v", r) + } + if wait, retry := r.StartupWait(0, time.Second, wireauth.ErrStorageOff); !retry || wait > MaxBackoff { + t.Fatalf("startup wait: %v %v", wait, retry) + } + b := &Batcher{} + if !b.news(r) || b.news(r) { + t.Fatal("refusal repeated within one episode") + } + b.recovered(true) + if !b.news(r) { + t.Fatal("new episode lost") + } +} + +func TestStorageOptInMissedOffOnStillPublishesWithinCap(t *testing.T) { + r := newRig(t) + b := r.batcher() + head := r.seal(map[string]string{"a": "pending"}) + note(b, head) + r.store.set(wireauth.ErrStorageOff, nil) + wait := b.flush(t.Context()) + if wait > MaxBackoff { + t.Fatalf("storage-off wait %v exceeds %v", wait, MaxBackoff) + } + // Another process saves off and on between the waiter's samples. No edge + // notification is delivered to this batcher; its ordinary retry must suffice. + r.store.set(nil, nil) + if b.flush(t.Context()) != 0 || b.Pending() != 0 || r.head(cellID).Head != head { + t.Fatal("pending publication never recovered") + } +} + +func TestStorageOptInRetriesStayQuietAcrossSuccessfulHeartbeats(t *testing.T) { + r := newRig(t) + b := r.batcher() + notices := 0 + b.OnError = func(error) { notices++ } + note(b, r.seal(map[string]string{"a": "pending"})) + r.store.set(wireauth.ErrStorageOff, nil) + for i := 0; i < 5; i++ { + if wait := b.flush(t.Context()); wait > MaxBackoff { + t.Fatalf("wait %v", wait) + } + b.beat(t.Context()) + } + if notices != 1 { + t.Fatalf("refusal said %d times", notices) + } + row, _ := RefusalOf(wireauth.ErrStorageOff) + if wait, _ := row.StartupWait(0, 2*time.Minute, wireauth.ErrStorageOff); wait > MaxBackoff { + t.Fatalf("custom interval bypassed cap: %v", wait) + } +} diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index f89ffcf6c5..c930e55502 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -15,16 +15,17 @@ const ( OfferContinue = "continue here" // MoveHere is the verb for a chat another device is running right now, and // ContinueVerb for one it let go of; the devices row offers them by name. - MoveHere = "Move here" - ContinueVerb = "Continue here" - LostRace = "another device continued this chat first" - NoIdentity = "this machine has no identity yet: codeaf identity import" - SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync service's address" - Unreachable = "the sync service is not answering" - ReadTimedOut = "the sync service did not answer in time" - SyncPaused = syncwords.UnknownOutage - SyncRestored = "this chat is syncing again" - ClockOff = "this computer's clock is off by more than 5 minutes" + MoveHere = "Move here" + ContinueVerb = "Continue here" + LostRace = "another device continued this chat first" + NoIdentity = "this machine has no identity yet: codeaf identity import" + RelayStorageOff = "relay storage is off for this account, so chats stay on this computer; request access in Settings under Devices" + SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync service's address" + Unreachable = "the sync service is not answering" + ReadTimedOut = "the sync service did not answer in time" + SyncPaused = syncwords.UnknownOutage + SyncRestored = "this chat is syncing again" + ClockOff = "this computer's clock is off by more than 5 minutes" // What the relay's refusals say (one sentence each, in the order the // cellsync table lists them). None names a code or a number of requests: // each says what is true for the person and what, if anything, to do. diff --git a/internal/directory/rotation.go b/internal/directory/rotation.go index 07778b4b0f..65c67069f9 100644 --- a/internal/directory/rotation.go +++ b/internal/directory/rotation.go @@ -2,10 +2,14 @@ package directory import ( "context" + "encoding/hex" "errors" + "net/http" "time" + "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/wireauth" ) @@ -136,11 +140,52 @@ func retire(cur *Rotation, device string, req RotationReq, now int64, b GraceBou } // Gate is a Client's rotation calls as the three moves a rotation makes. -type Gate struct{ Client Client } +type Gate struct { + Client Client + // Store discovers the hosted handoff route without changing the rotation wire. + Store interface { + Stats(context.Context) (blobstore.Counts, error) + } + Source string +} // Freeze stops every write to the identity. func (g Gate) Freeze(ctx context.Context) error { return g.move(ctx, RotationReq{Op: OpFreeze}) } +// HandoffTo carries hosted storage permission after freezing and before uploads. +// Relays without the stats field receive no additional request. +func (g Gate) HandoffTo(ctx context.Context, successor identity.Identity) error { + if g.Store == nil { + return nil + } + counts, err := g.Store.Stats(ctx) + if err != nil { + return err + } + if counts.Storage == "" { + return nil + } + client, ok := g.Client.(interface { + HandoffStorage(context.Context, string, identity.Identity) error + }) + if !ok { + return ErrTooOld + } + return client.HandoffStorage(ctx, g.Source, successor) +} + +// HandoffStorage signs consent with the successor root; the HTTP request is +// still signed by a device of the source, just like other store requests. +func (h *HTTP) HandoffStorage(ctx context.Context, source string, successor identity.Identity) error { + proof := successor.Sign([]byte("codeaf/storage-handoff/v1\n" + source + "\n" + successor.ID())) + request := struct { + Successor string `json:"successor"` + SuccessorKey string `json:"successor_key"` + Proof string `json:"proof"` + }{successor.ID(), hex.EncodeToString(successor.PublicKey()), hex.EncodeToString(proof)} + return h.do(ctx, http.MethodPost, "/v1/store/handoff", request, nil) +} + // Thaw undoes a freeze. func (g Gate) Thaw(ctx context.Context) error { return g.move(ctx, RotationReq{Op: OpThaw}) } diff --git a/internal/directory/storage_handoff_test.go b/internal/directory/storage_handoff_test.go new file mode 100644 index 0000000000..9225c588a4 --- /dev/null +++ b/internal/directory/storage_handoff_test.go @@ -0,0 +1,103 @@ +package directory + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/clusterstate" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +func TestStorageOptInFreezeNegotiatesExactLegacyBytes(t *testing.T) { + id, err := identity.Mint() + if err != nil { + t.Fatal(err) + } + dev, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + next, err := identity.Mint() + if err != nil { + t.Fatal(err) + } + for _, state := range []string{"", "on", "off"} { + t.Run("state="+state, func(t *testing.T) { + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.URL.Path) + raw, _ := io.ReadAll(r.Body) + if _, _, err := reqsign.AuthenticateAt(time.Now)(r, raw); err != nil { + t.Errorf("device signature: %v", err) + } + switch r.URL.Path { + case "/fabric/v1/store/stats": + json.NewEncoder(w).Encode(blobstore.Counts{Storage: state}) + case "/fabric/v1/identity/rotation": + if string(raw) != `{"op":"freeze"}` { + t.Errorf("changed origin/dev freeze bytes: %q", raw) + } + // The restored production decoder rejects unknown rotation fields. + cert, _ := json.Marshal(dev.Cert) + proof := clusterstate.V1Request{Method: "POST", URI: "/v1/identity/rotation", At: "1000", IdentityKey: id.PublicKey(), Certificate: base64.RawURLEncoding.EncodeToString(cert), Body: raw} + proof.Signature = dev.Sign(clusterstate.V1SigningMessage(proof.Method, proof.URI, proof.At, proof.Body)) + if _, err := clusterstate.V1Command("test", 1000, proof); err != nil { + t.Errorf("origin/dev decoder: %v", err) + } + w.Write([]byte(`{}`)) + case "/fabric/v1/store/handoff": + var fields map[string]string + if err := json.Unmarshal(raw, &fields); err != nil { + t.Error(err) + return + } + pub, _ := hex.DecodeString(fields["successor_key"]) + sig, _ := hex.DecodeString(fields["proof"]) + message := []byte("codeaf/storage-handoff/v1\n" + id.ID() + "\n" + next.ID()) + if len(fields) != 3 || fields["successor"] != next.ID() || !reflect.DeepEqual(pub, []byte(next.PublicKey())) || !ed25519.Verify(next.PublicKey(), message, sig) { + t.Errorf("bad handoff: %s", raw) + } + w.Write([]byte(`{"successor":"` + next.ID() + `","storage":"on"}`)) + default: + t.Errorf("unexpected request %s", r.URL.Path) + } + })) + defer server.Close() + sign := reqsign.SignFor(storageHandoffSigner{id, dev}, time.Now) + gate := Gate{Client: NewHTTP(server.URL+"/fabric", sign, server.Client()), Store: blobstore.NewHTTP(server.URL+"/fabric", sign, server.Client()), Source: id.ID()} + if err := gate.Freeze(context.Background()); err != nil { + t.Fatal(err) + } + if err := gate.HandoffTo(context.Background(), next); err != nil { + t.Fatal(err) + } + want := []string{"/fabric/v1/identity/rotation", "/fabric/v1/store/stats"} + if state != "" { + want = append(want, "/fabric/v1/store/handoff") + } + if !reflect.DeepEqual(calls, want) { + t.Fatalf("calls %v, want %v", calls, want) + } + }) + } +} + +type storageHandoffSigner struct { + id identity.Identity + dev identity.Dev +} + +func (s storageHandoffSigner) IdentityKey() ed25519.PublicKey { return s.id.PublicKey() } +func (s storageHandoffSigner) Cert() identity.Cert { return s.dev.Cert } +func (s storageHandoffSigner) Sign(msg []byte) []byte { return s.dev.Sign(msg) } diff --git a/internal/directory/storage_optin_test.go b/internal/directory/storage_optin_test.go new file mode 100644 index 0000000000..29fc250443 --- /dev/null +++ b/internal/directory/storage_optin_test.go @@ -0,0 +1,21 @@ +package directory + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/wireauth" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStorageOptInDirectoryWire(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"err":"storage_off"}`)) + })) + defer srv.Close() + _, err := NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client()).List(t.Context()) + if !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("directory refusal: %v", err) + } +} diff --git a/internal/directory/wire.go b/internal/directory/wire.go index 1af1314db5..8736c41b6d 100644 --- a/internal/directory/wire.go +++ b/internal/directory/wire.go @@ -19,6 +19,7 @@ type wireError struct { // wireErrors is the single place a sentinel and its code meet, so the handler // and the client cannot drift apart. Order matters only for errors.Is chains. var wireErrors = []wireError{ + {wireauth.ErrStorageOff, "storage_off", http.StatusForbidden}, {ErrUnreachable, "unreachable", http.StatusServiceUnavailable}, {wireauth.ErrUnavailable, "unreachable", http.StatusServiceUnavailable}, {clusterstate.ErrMigration, "unreachable", http.StatusServiceUnavailable}, diff --git a/internal/rotate/journal.go b/internal/rotate/journal.go index 7c33a48319..07cbd8e2c7 100644 --- a/internal/rotate/journal.go +++ b/internal/rotate/journal.go @@ -11,6 +11,8 @@ import ( // File is the journal under the codeaf home. const File = "rotation.json" +const successorFile = "rotation-successor.json" + // State is how far a rotation has got. Each state names what is already true. type State string @@ -43,6 +45,7 @@ type Journal struct { NewDevice json.RawMessage `json:"new_device"` // identity.Dev document GraceMS int64 `json:"grace_ms"` Items []Item `json:"items,omitempty"` + LocalOnly bool `json:"local_only,omitempty"` Vault bool `json:"vault,omitempty"` // the resealed vault is on the new relay } diff --git a/internal/rotate/plan.go b/internal/rotate/plan.go index a66145469e..d814101490 100644 --- a/internal/rotate/plan.go +++ b/internal/rotate/plan.go @@ -33,8 +33,18 @@ func (e Env) Plan(ctx context.Context) (Plan, error) { return Plan{}, err } p := Plan{Chats: len(l.Cells)} + upload := true + if e.StorageAllowed != nil { + var err error + upload, err = e.StorageAllowed(ctx, nil) + if err != nil { + return Plan{}, err + } + } for id, c := range l.Cells { - p.UpBytes += int64(c.Size) + if upload { + p.UpBytes += int64(c.Size) + } want, err := e.Engine(e.Identity).Want(ctx, e.Cell(id), c.Head) if err != nil { return Plan{}, err diff --git a/internal/rotate/rotate.go b/internal/rotate/rotate.go index d5e1bc2602..78fa55db90 100644 --- a/internal/rotate/rotate.go +++ b/internal/rotate/rotate.go @@ -4,8 +4,11 @@ import ( "context" "crypto/ed25519" "encoding/hex" + "encoding/json" "errors" "fmt" + "os" + "path/filepath" "time" "github.com/Agent-Field/codeaf/internal/blobstore" @@ -75,8 +78,11 @@ type Env struct { Cell func(id string) cell.Cell // Record is this device's directory record under id. Record func(id identity.Identity) (directory.Device, error) - Grace time.Duration // 0 is DefaultGrace - Say func(string) // progress lines; nil says nothing + // StorageAllowed lets a closed account finish with local copies instead of + // asking verification to find content it was forbidden to upload. + StorageAllowed func(context.Context, blobstore.Store) (bool, error) + Grace time.Duration // 0 is DefaultGrace + Say func(string) // progress lines; nil says nothing // After is called once each step is durable, with its name; an error stops // the rotation there. Tests use it to crash at every point. After func(step string) error @@ -185,6 +191,30 @@ func (e Env) open(ctx context.Context) (*run, error) { // mint makes the new root, the device it certifies, and writes them down before // anything is sent. func (e Env) mint() (Journal, error) { + if raw, err := os.ReadFile(filepath.Join(e.Home, successorFile)); err == nil { + var j Journal + if json.Unmarshal(raw, &j) != nil { + return Journal{}, errors.New("rotate: saved successor keys are damaged; keep " + successorFile) + } + if j.OldID == e.Identity.ID() { + if _, err := identity.Unmarshal(j.New); err != nil { + return Journal{}, err + } + oldDoc, err := e.Device.Marshal() + if err != nil { + return Journal{}, err + } + j.OldDevice = oldDoc + j.GraceMS = e.grace().Milliseconds() + if err := save(e.Home, j); err != nil { + return Journal{}, err + } + return j, os.Remove(filepath.Join(e.Home, successorFile)) + } + } else if !errors.Is(err, os.ErrNotExist) { + return Journal{}, err + } + id, err := identity.Mint() if err != nil { return Journal{}, err @@ -257,7 +287,8 @@ func (r *run) refusedFirst(err error) error { } // Abandon stops a rotation that has not switched: the old identity is thawed -// and the journal, which holds the unused new keys, is deleted. With no journal +// and its successor keys are kept outside the active journal so a later +// rotation can reuse the relay’s permanently bound successor. With no journal // it thaws anyway, which is how another computer undoes a rotation whose // keeper lost its disk. func (e Env) Abandon(ctx context.Context) error { @@ -278,7 +309,14 @@ func (e Env) Abandon(ctx context.Context) error { if err := r.old.Gate.Thaw(ctx); err != nil { return err } - return remove(e.Home) + r.j.State = Minted + r.j.Items = nil + r.j.Vault = false + r.j.LocalOnly = false + if err := save(e.Home, r.j); err != nil { + return err + } + return os.Rename(path(e.Home), filepath.Join(e.Home, successorFile)) } // vaultSyncer is the vault's syncer for the new relay. diff --git a/internal/rotate/steps.go b/internal/rotate/steps.go index 87be8557b8..4ee412ccd5 100644 --- a/internal/rotate/steps.go +++ b/internal/rotate/steps.go @@ -80,6 +80,23 @@ func (e Env) complete(ctx context.Context, from blobstore.Store, it Item) error // that does it. Each finished chat is written down, so a crash repeats at most // the chat that was in flight. func (r *run) seal(ctx context.Context) error { + // A frozen journal already holds the successor keys. The separate handoff + // retries safely after a crash without changing any coordination command. + if gate, ok := r.old.Gate.(interface { + HandoffTo(context.Context, identity.Identity) error + }); ok { + if err := gate.HandoffTo(ctx, r.newID); err != nil { + return err + } + } + if r.StorageAllowed != nil { + allowed, err := r.StorageAllowed(ctx, r.new.Store) + if err != nil { + return err + } + r.j.LocalOnly = !allowed + } + if len(r.j.Items) == 0 { l, err := r.old.Dir.List(ctx) if err != nil { @@ -90,6 +107,13 @@ func (r *run) seal(ctx context.Context) error { return err } } + if r.j.LocalOnly { + if err := r.registerDevice(ctx); err != nil { + return err + } + r.say("relay storage is off; chats stay on your computers") + return r.advance(Sealed) + } if err := r.registerDevice(ctx); err != nil { return err } @@ -152,7 +176,14 @@ func (r *run) record(ctx context.Context, it Item) error { Head: it.Head, Class: v.Cell.Class, Title: title, ParentCell: v.Cell.ParentCell, Size: v.Cell.Size, OrphanTurns: v.Cell.OrphanTurns, }) - if err != nil && !errors.Is(err, directory.ErrExists) { + if errors.Is(err, directory.ErrExists) { + held, acquireErr := r.new.Dir.Acquire(ctx, it.ID, directory.AcquireOpts{}) + if acquireErr != nil { + return acquireErr + } + _, err = r.new.Dir.Publish(ctx, it.ID, directory.Publish{Fence: held.Cell.Lease.Fence, OldHead: held.Cell.Head, Head: it.Head, Class: v.Cell.Class, Title: title, Size: v.Cell.Size}) + } + if err != nil { return err } if err := r.letGo(ctx, it.ID); err != nil { @@ -210,6 +241,9 @@ func (r *run) carryVault(ctx context.Context) error { // verify asks the new relay for what the journal says it holds: every chat at // the carried head, every head's remote id, and the vault. func (r *run) verify(ctx context.Context) error { + if r.j.LocalOnly { + return r.advance(Verified) + } l, err := r.new.Dir.List(ctx) if err != nil { return err diff --git a/internal/rotate/storage_optin_test.go b/internal/rotate/storage_optin_test.go new file mode 100644 index 0000000000..ed6da9386c --- /dev/null +++ b/internal/rotate/storage_optin_test.go @@ -0,0 +1,225 @@ +package rotate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/reqsign" + "net/http" + "net/http/httptest" + "testing" +) + +type storageCountWrites struct { + blobstore.Store + writes *int +} + +func (s storageCountWrites) PutFrame(ctx context.Context, b []byte) (blobstore.FrameID, error) { + *s.writes++ + return s.Store.PutFrame(ctx, b) +} +func TestStorageOptInRotationOffFinishesWithoutWrites(t *testing.T) { + for _, resume := range []bool{false, true} { + t.Run(map[bool]string{false: "new", true: "frozen"}[resume], func(t *testing.T) { + r := newRig(t) + r.putVault() + writes := 0 + connect := r.env.Connect + r.env.Connect = func(s reqsign.Signer) Relay { + side := connect(s) + side.Store = storageCountWrites{side.Store, &writes} + return side + } + r.env.StorageAllowed = func(context.Context, blobstore.Store) (bool, error) { return false, nil } + if resume { + r.env.After = crashAt(string(Frozen)) + if _, err := r.env.Rotate(t.Context()); err != errCrash { + t.Fatalf("crash %v", err) + } + r.env.After = nil + } + result, err := r.env.Rotate(t.Context()) + if err != nil { + t.Fatal(err) + } + if result.Chats != 2 { + t.Fatalf("local chat count %d", result.Chats) + } + if writes != 0 || Pending(r.home) || r.relay.of(r.old.PublicKey()).state != "retired" { + t.Fatalf("writes=%d pending=%v", writes, Pending(r.home)) + } + id, err := identity.Load(r.home) + if err != nil || id.ID() != result.NewID { + t.Fatalf("identity %v", err) + } + vault, err := keys.Open(r.home) + if err != nil { + t.Fatal(err) + } + entry, err := vault.Get("p/KEY") + if err != nil || entry.Value != "secret" { + t.Fatalf("local vault %v", err) + } + // Local objects are shared by the engines before and after rotation. + for _, chat := range []string{chatA, chatB} { + head := listing(t, r.relay.of(r.old.PublicKey()), r.dev.ID()).Cells[chat].Head + if missing, err := r.engineFor(id).Want(t.Context(), r.cell(chat), head); err != nil || len(missing) > 0 { + t.Fatalf("local chat %s: missing %v, %v", chat, missing, err) + } + } + }) + } +} + +func TestStorageOptInCanceledRotationKeepsBoundSuccessor(t *testing.T) { + r := newRig(t) + r.env.After = crashAt(string(Frozen)) + if _, err := r.env.Rotate(t.Context()); err != errCrash { + t.Fatal(err) + } + j, _, err := load(r.home) + if err != nil { + t.Fatal(err) + } + if err := r.env.Abandon(t.Context()); err != nil { + t.Fatal(err) + } + if Pending(r.home) { + t.Fatal("cancellation still blocks ordinary sync") + } + r.env.After = nil + result, err := r.env.Rotate(t.Context()) + if err != nil { + t.Fatal(err) + } + if result.NewID != j.NewID { + t.Fatal("new successor would conflict with first handoff") + } +} + +func TestStorageOptInOffRotationPlanHasNoUploadCost(t *testing.T) { + r := newRig(t) + r.env.StorageAllowed = func(context.Context, blobstore.Store) (bool, error) { return false, nil } + plan, err := r.env.Plan(t.Context()) + if err != nil { + t.Fatal(err) + } + if plan.Chats != 2 || plan.UpBytes != 0 { + t.Fatalf("off plan: %+v", plan) + } +} + +// A failed handoff leaves the Frozen journal resumable, without another freeze +// or a resealing write before the retry succeeds. +func TestStorageOptInRotationHandoffBeforeUploads(t *testing.T) { + for _, resume := range []bool{false, true} { + for _, state := range []string{"on", "off"} { + t.Run(fmt.Sprintf("resume=%v/storage=%s", resume, state), func(t *testing.T) { + r := newRig(t) + connect := r.env.Connect + source := connect(signer{r.old.PublicKey(), r.dev}) + freezes, handoffs, writes := 0, 0, 0 + refuse := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/identity/rotation": + var req directory.RotationReq + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + t.Error(err) + return + } + var err error + switch req.Op { + case directory.OpFreeze: + freezes++ + err = source.Gate.Freeze(request.Context()) + case directory.OpRetire: + err = source.Gate.Retire(request.Context(), 0) + default: + t.Errorf("unexpected rotation %s", req.Op) + } + if err != nil { + t.Error(err) + http.Error(w, err.Error(), 500) + return + } + w.Write([]byte(`{}`)) + case "/v1/store/stats": + json.NewEncoder(w).Encode(blobstore.Counts{Storage: state}) + case "/v1/store/handoff": + handoffs++ + if r.relay.of(r.old.PublicKey()).state != "frozen" { + t.Error("handoff before freeze") + } + if refuse { + http.Error(w, `{"err":"unavailable"}`, 503) + return + } + w.Write([]byte(`{"storage":"` + state + `"}`)) + default: + t.Errorf("unexpected request %s", request.URL.Path) + } + })) + defer server.Close() + sign := func(*http.Request, []byte) {} + r.env.Connect = func(sig reqsign.Signer) Relay { + side := connect(sig) + if reqsign.IDOf(sig.IdentityKey()) == r.old.ID() { + side.Gate = directory.Gate{Client: directory.NewHTTP(server.URL, sign, server.Client()), Store: blobstore.NewHTTP(server.URL, sign, server.Client()), Source: r.old.ID()} + } else { + side.Store = storageHandoffWrites{Store: side.Store, before: func() { + writes++ + if handoffs < 2 || refuse { + t.Error("upload before successful handoff") + } + }} + } + return side + } + r.env.StorageAllowed = func(context.Context, blobstore.Store) (bool, error) { return state == "on", nil } + if resume { + r.env.After = crashAt(string(Frozen)) + if _, err := r.env.Rotate(t.Context()); !errors.Is(err, errCrash) { + t.Fatalf("freeze crash: %v", err) + } + if freezes != 1 || handoffs != 0 { + t.Fatalf("freeze=%d handoff=%d", freezes, handoffs) + } + r.env.After = nil + } + if _, err := r.env.Rotate(t.Context()); err == nil { + t.Fatal("handoff refusal was ignored") + } + if freezes != 1 || handoffs != 1 || writes != 0 { + t.Fatalf("before retry freeze=%d handoff=%d writes=%d", freezes, handoffs, writes) + } + refuse = false + if _, err := r.env.Rotate(t.Context()); err != nil { + t.Fatal(err) + } + if freezes != 1 || handoffs != 2 || Pending(r.home) { + t.Fatalf("freeze=%d handoff=%d pending=%v", freezes, handoffs, Pending(r.home)) + } + if (state == "on" && writes == 0) || (state == "off" && writes != 0) { + t.Fatalf("storage=%s writes=%d", state, writes) + } + }) + } + } +} + +type storageHandoffWrites struct { + blobstore.Store + before func() +} + +func (s storageHandoffWrites) PutFrame(ctx context.Context, b []byte) (blobstore.FrameID, error) { + s.before() + return s.Store.PutFrame(ctx, b) +} diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index 4490a3a70b..f5fd614211 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -16,6 +16,7 @@ import ( "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/vaultsync" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // unknownDevice is said for the machine that took a chat when the directory @@ -77,6 +78,9 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt if Quiet(s.Home) { return nil, ErrQuiet } + if !s.StorageAllowed() { + return nil, wireauth.ErrStorageOff + } files, err := s.files(eng, c) if err != nil { return nil, err @@ -113,6 +117,8 @@ func (s *Sync) Drive(ctx context.Context, eng cellstore.Engine, c cell.Cell, opt defer close(d.done) var work sync.WaitGroup work.Go(func() { d.followOwnership(run) }) + stopStorage := OnStorageOpened(s.Home, d.StorageOpened) + defer stopStorage() _ = d.batcher.Run(run) work.Wait() }() @@ -175,6 +181,9 @@ func (d *Drive) note(sealed cellstore.Sealed) { // see. It is best effort and says nothing when it fails: the fingerprint is then // not recorded, so the next flush tries again, and a seal never waits on it. func (d *Drive) syncVault(ctx context.Context) { + if !d.sync.StorageAllowed() { + return + } err := d.sync.withVault(func(v vaultsync.Syncer) error { if err := v.Capture(); err != nil { return err @@ -644,3 +653,10 @@ func (d *Drive) Close(ctx context.Context) error { }) return d.closeErr } + +// StorageOpened wakes capacity-halted publication without replacing ownership. +func (d *Drive) StorageOpened() { + if d.batcher != nil { + d.batcher.Freed() + } +} diff --git a/internal/syncsetup/rotate.go b/internal/syncsetup/rotate.go index 7924ec438f..99a8c16f04 100644 --- a/internal/syncsetup/rotate.go +++ b/internal/syncsetup/rotate.go @@ -1,6 +1,7 @@ package syncsetup import ( + "context" "errors" "net/http" "os" @@ -38,6 +39,24 @@ func (s *Sync) Rotation(eng cellstore.Engine, held func(id string) (cell.Cell, b return rotate.Env{ Home: s.Home, Identity: s.Identity, Device: s.Device, Say: say, Connect: s.relayOf, + StorageAllowed: func(ctx context.Context, successor blobstore.Store) (bool, error) { + if !IsHostedRelay(s.Relay) { + return true, nil + } + counts, err := s.Store.(interface { + Stats(context.Context) (blobstore.Counts, error) + }).Stats(ctx) + if err != nil { + return false, err + } + if counts.Storage == "off" || successor == nil { + return counts.Storage != "off", nil + } + next, err := successor.(interface { + Stats(context.Context) (blobstore.Counts, error) + }).Stats(ctx) + return err == nil && next.Storage != "off", err + }, Engine: func(id identity.Identity) cellsync.Engine { keys := cellstore.SyncKeys{CellKey: id.CellKey(), Dedup: id.DedupSecret()} return landing{Engine: eng.Sync(keys, cellstore.LedgerName(s.Relay, id.ID()))} @@ -60,7 +79,25 @@ func (s *Sync) Tidy() error { return os.RemoveAll(scratchDir(s.Home)) } func (s *Sync) relayOf(sig reqsign.Signer) rotate.Relay { sign := reqsign.SignFor(sig, time.Now) dir := directory.NewHTTP(s.Relay, sign, &http.Client{Timeout: requestTimeout}) - return rotate.Relay{Dir: dir, Store: blobstore.NewHTTP(s.Relay, sign, &http.Client{}), Gate: directory.Gate{Client: dir}} + store := GateStore(blobstore.NewHTTP(s.Relay, sign, &http.Client{}), s.Home, s.Relay, reqsign.IDOf(sig.IdentityKey())) + if IsHostedRelay(s.Relay) && reqsign.IDOf(sig.IdentityKey()) != s.Identity.ID() { + allowed := store.CanStore + legacy := false + store.CanStore = func() bool { + if legacy || allowed() { + return true + } + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + counts, err := store.Stats(ctx) + if err == nil && counts.Storage == "" { + legacy = true + return true + } + return err == nil && SaveStorage(s.Home, s.Relay, reqsign.IDOf(sig.IdentityKey()), counts.Storage) == nil && allowed() + } + } + return rotate.Relay{Dir: dir, Store: store, Gate: directory.Gate{Client: dir, Store: store, Source: reqsign.IDOf(sig.IdentityKey())}} } // deviceRecord is a device's directory record under id: its name sealed under diff --git a/internal/syncsetup/state.go b/internal/syncsetup/state.go index 3a84fe3992..56c5be8958 100644 --- a/internal/syncsetup/state.go +++ b/internal/syncsetup/state.go @@ -20,6 +20,8 @@ var stateMu sync.Mutex // its lifetime. Quiet, unpaired and removed homes end silently; other failures // retry after the sync interval and are reported once per distinct error. func RunHomeState(ctx context.Context, home string, report func(error)) { + stopStorage := StartStorageRefresh(ctx, home) + defer stopStorage() interval, err := flushInterval() if err != nil { interval = DefaultInterval @@ -96,7 +98,7 @@ func (s *Sync) SyncState(ctx context.Context) error { return err } fp, exists := keys.Fingerprint(s.Home) - if exists && fp != s.stateSent { + if exists && fp != s.stateSent && s.StorageAllowed() { if err := v.Push(ctx); err != nil { return err } @@ -112,7 +114,9 @@ func (s *Sync) SyncState(ctx context.Context) error { } s.stateVault, s.stateVaultKnown = rid, local } - s.stateSent, _ = keys.Fingerprint(s.Home) + if s.StorageAllowed() { + s.stateSent, _ = keys.Fingerprint(s.Home) + } return nil }) } diff --git a/internal/syncsetup/storage.go b/internal/syncsetup/storage.go new file mode 100644 index 0000000000..1e51d4fa84 --- /dev/null +++ b/internal/syncsetup/storage.go @@ -0,0 +1,136 @@ +package syncsetup + +import ( + "encoding/json" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// StorageFile keeps the last permission beside the saved relay, bound to both +// account and service so pairing or switching relays cannot reuse an approval. +const StorageFile = "sync-storage" + +// StoragePollEvery notices a permission refreshed by another local process. +const StoragePollEvery = time.Minute + +// StorageRequestURL is the one public door for requesting hosted storage. +const StorageRequestURL = "https://github.com/Agent-Field/CodeAF/issues/new" + +type storageReading struct { + Relay string `json:"relay"` + Identity string `json:"identity"` + Storage string `json:"storage"` +} + +// IsHostedRelay recognizes all hosted channels, including an explicitly saved URL. +func IsHostedRelay(raw string) bool { + u := storageURL(raw) + for _, host := range HostedLinkHosts() { + if strings.EqualFold(u.Host, host) { + return true + } + } + return false +} + +// StorageState reads only local state. An unknown hosted permission is closed; +// older and self-hosted relays retain their existing enabled storage policy. +func StorageState(home, relay, identity string) string { + raw, err := os.ReadFile(filepath.Join(home, StorageFile)) + var reading storageReading + if err == nil && json.Unmarshal(raw, &reading) == nil && + normalizeStorageURL(reading.Relay) == normalizeStorageURL(relay) && reading.Identity == identity && + (reading.Storage == "on" || reading.Storage == "off") { + return reading.Storage + } + if IsHostedRelay(relay) { + return "off" + } + return "on" +} + +// SaveStorage remembers a successful stats answer atomically. Missing state +// means closed on our hosts and enabled on a relay that predates this field. +func SaveStorage(home, relay, identity, state string) error { + if state != "on" && state != "off" { + state = "on" + if IsHostedRelay(relay) { + state = "off" + } + } + if err := os.MkdirAll(home, 0700); err != nil { + return err + } + raw, err := json.Marshal(storageReading{normalizeStorageURL(relay), identity, state}) + if err != nil { + return err + } + f, err := os.CreateTemp(home, ".sync-storage-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(append(raw, '\n')); err != nil { + f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + if err := os.Rename(f.Name(), filepath.Join(home, StorageFile)); err != nil { + return err + } + storageSaved(home, relay, identity, state) + return nil +} + +// GateStore keeps every hosted upload door on the same local permission check. +func GateStore(store *blobstore.HTTP, home, relay, identity string) *blobstore.HTTP { + store.CanStore = func() bool { return StorageState(home, relay, identity) == "on" } + store.OnStorageOff = func() { _ = SaveStorage(home, relay, identity, "off") } + return store +} + +func (s *Sync) StorageAllowed() bool { + return StorageState(s.Home, s.Relay, s.Identity.ID()) == "on" +} + +// StorageRequestLink includes only the public account identifier, never a key. +func StorageRequestLink(identity string) string { + query := url.Values{"title": {"relay storage access: " + identity}, "body": {"Please turn on relay storage for my account.\n\nRequest code: " + identity}} + return StorageRequestURL + "?" + query.Encode() +} + +// StorageForHome does not create an identity or make a network request. +func StorageForHome(home string) string { + relay := Resolve(home) + if !relay.On() { + return "" + } + id, err := identity.Load(home) + if err != nil { + return "" + } + return StorageState(home, relay.URL, id.ID()) +} + +// Default ports and host case identify the same service and permission file. +func storageURL(raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil { + return &url.URL{} + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + if u.Scheme == "https" && u.Port() == "443" || u.Scheme == "http" && u.Port() == "80" { + u.Host = u.Hostname() + } + return u +} +func normalizeStorageURL(raw string) string { return strings.TrimRight(storageURL(raw).String(), "/") } diff --git a/internal/syncsetup/storage_batcher_test.go b/internal/syncsetup/storage_batcher_test.go new file mode 100644 index 0000000000..408ac80515 --- /dev/null +++ b/internal/syncsetup/storage_batcher_test.go @@ -0,0 +1,110 @@ +package syncsetup + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestStorageOptInRealBatcherMissedOffOnAndLocalGate(t *testing.T) { + home := t.TempDir() + const account = "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + var revoked atomic.Bool + revoked.Store(true) + var writes atomic.Int32 + memory := blobstore.NewMemory() + handler := blobstore.Handler(func(*http.Request, []byte) (string, string, error) { return account, "device", nil }, func(string) (blobstore.Store, error) { return memory, nil }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/store/frames" && r.Method == "POST" { + writes.Add(1) + if revoked.Load() { + w.WriteHeader(403) + w.Write([]byte(`{"err":"storage_off"}`)) + return + } + } + handler.ServeHTTP(w, r) + })) + defer srv.Close() + store := GateStore(blobstore.NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client()), home, ProductionRelayURL, account) + if err := SaveStorage(home, ProductionRelayURL, account, "on"); err != nil { + t.Fatal(err) + } + dir := directory.NewMemory(time.Now) + defer dir.Close() + client := dir.For("device") + c := cell.Cell{ID: "01J0000000000000000000000A", Root: t.TempDir()} + view, err := client.Create(t.Context(), c.ID, directory.CellInit{}) + if err != nil { + t.Fatal(err) + } + engine := cellsync.NewFakeEngine(t.TempDir()) + head := engine.Seal(c, map[string]string{"a": "pending"}) + pendingWait := make(chan time.Duration, 10) + retry := make(chan struct{}) + uploaded := make(chan struct{}, 1) + b := &cellsync.Batcher{Publisher: &cellsync.Publisher{Engine: engine, Store: store, Dir: client}, Driving: &cellsync.Driving{Cell: c, Fence: view.Cell.Lease.Fence}, Interval: 5 * time.Second, OnFlush: func(cellsync.Flush) { uploaded <- struct{}{} }} + b.Sleep = func(ctx context.Context, d time.Duration) error { + if d != directory.HeartbeatEvery { + pendingWait <- d + select { + case <-retry: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + <-ctx.Done() + return ctx.Err() + } + b.Note(cellstore.Turn{ID: head}) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { defer close(done); b.Run(ctx) }() + defer func() { cancel(); <-done }() + wait := <-pendingWait + if wait > cellsync.MaxBackoff { + t.Fatalf("halt %v", wait) + } + if StorageState(home, ProductionRelayURL, account) != "off" { + t.Fatal("wire revoke not saved") + } + // An ordinary retry while the local file is off makes no second put. + retry <- struct{}{} + wait = <-pendingWait + if writes.Load() != 1 || b.Pending() != 1 { + t.Fatal("closed local gate reached relay or lost pending turn") + } + // Another process replaces the file directly, without notifying this one. + // The whole off/on change occurred between permission-waiter samples. + revoked.Store(false) + raw := []byte(`{"relay":"` + ProductionRelayURL + `","identity":"` + account + `","storage":"on"}`) + if err := os.WriteFile(filepath.Join(home, StorageFile), raw, 0600); err != nil { + t.Fatal(err) + } + if wait > cellsync.MaxBackoff { + t.Fatalf("retry %v", wait) + } + retry <- struct{}{} + select { + case <-uploaded: + case <-time.After(5 * time.Second): + t.Fatal("missed edge stranded real publication") + } + if b.Pending() != 0 || writes.Load() != 2 { + t.Fatalf("pending=%d writes=%d", b.Pending(), writes.Load()) + } + if _, err := client.Cell(t.Context(), c.ID); err != nil { + t.Fatal(err) + } +} diff --git a/internal/syncsetup/storage_headless_test.go b/internal/syncsetup/storage_headless_test.go new file mode 100644 index 0000000000..a586946518 --- /dev/null +++ b/internal/syncsetup/storage_headless_test.go @@ -0,0 +1,93 @@ +package syncsetup + +import ( + "bytes" + "context" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/reqsign" + "io" + "net/http" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestStorageOptInFreshServingHomeDiscoversApproval(t *testing.T) { + celltest.Available(t) + server := relay(t) + first := machine(t, server.URL) + source, ok, err := Open(first) + if err != nil || !ok { + t.Fatal(err) + } + fresh := t.TempDir() + if err := identity.Install(fresh, source.Identity, source.Device); err != nil { + t.Fatal(err) + } + t.Setenv(URLVar, ProductionRelayURL) + t.Setenv(IntervalVar, "20") + _ = SaveStorage(first, ProductionRelayURL, source.Identity.ID(), "on") + if StorageForHome(fresh) != "off" { + t.Fatal("fresh home started open") + } + vault, err := keys.Open(fresh) + if err != nil { + t.Fatal(err) + } + if err := vault.Put("p/result", keys.Entry{Name: "result", Scope: "p", Value: "serving-home-upload"}); err != nil { + t.Fatal(err) + } + original := http.DefaultTransport + var stats, writes atomic.Int32 + approved := make(chan struct{}, 1) + http.DefaultTransport = storageTransport(func(r *http.Request) (*http.Response, error) { + if IsHostedRelay(r.URL.Scheme + "://" + r.URL.Host) { + var raw []byte + if r.Body != nil { + raw, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(raw)) + } + if _, _, err := reqsign.AuthenticateAt(time.Now)(r, raw); err != nil { + t.Errorf("unsigned request: %v", err) + } + if strings.HasSuffix(r.URL.Path, "/v1/store/stats") { + stats.Add(1) + select { + case approved <- struct{}{}: + default: + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"storage":"on"}`))}, nil + } + if strings.HasSuffix(r.URL.Path, "/v1/store/frames") { + writes.Add(1) + } + clone := r.Clone(r.Context()) + u, _ := url.Parse(server.URL) + clone.URL.Scheme = u.Scheme + clone.URL.Host = u.Host + clone.URL.Path = strings.TrimPrefix(clone.URL.Path, "/fabric") + clone.RequestURI = "" + reqsign.SignFor(deviceSigner{source.Identity, source.Device}, time.Now)(clone, raw) + return original.RoundTrip(clone) + } + return original.RoundTrip(r) + }) + t.Cleanup(func() { http.DefaultTransport = original }) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { defer close(done); RunHomeState(ctx, fresh, func(err error) { t.Errorf("serving: %v", err) }) }() + defer func() { cancel(); <-done }() + select { + case <-approved: + case <-time.After(5 * time.Second): + t.Fatal("headless startup never read approval") + } + waitServeState(t, func() bool { return writes.Load() > 0 }) + if stats.Load() != 1 || StorageForHome(fresh) != "on" { + t.Fatalf("stats=%d permission=%s", stats.Load(), StorageForHome(fresh)) + } +} diff --git a/internal/syncsetup/storage_optin_test.go b/internal/syncsetup/storage_optin_test.go new file mode 100644 index 0000000000..11131b6538 --- /dev/null +++ b/internal/syncsetup/storage_optin_test.go @@ -0,0 +1,171 @@ +package syncsetup + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/wireauth" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type storageTransport func(*http.Request) (*http.Response, error) + +func (f storageTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestStorageOptInCacheAndUploadDoors(t *testing.T) { + home := t.TempDir() + const id = "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + for _, relay := range []string{ProductionRelayURL, StagingRelayURL, DevRelayURL} { + if !IsHostedRelay(relay) || StorageState(home, relay, id) != "off" { + t.Fatalf("unknown hosted state open: %s", relay) + } + } + if IsHostedRelay("https://codeaf.agentfield.ai.evil.test/fabric") || StorageState(home, "http://own.test", id) != "on" { + t.Fatal("self-hosted policy changed") + } + calls := 0 + hc := &http.Client{Transport: storageTransport(func(r *http.Request) (*http.Response, error) { + calls++ + body := `{"frame":"frame","objects":1}` + if strings.HasSuffix(r.URL.Path, "/has") { + body = `{"have":[false]}` + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + })} + c := GateStore(blobstore.NewHTTP(ProductionRelayURL, func(*http.Request, []byte) {}, hc), home, ProductionRelayURL, id) + if _, err := c.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 0 { + t.Fatalf("write while off: %v calls=%d", err, calls) + } + if _, err := c.Has(t.Context(), nil); err != nil || calls != 1 { + t.Fatalf("read while off: %v calls=%d", err, calls) + } + if err := SaveStorage(home, ProductionRelayURL, id, "on"); err != nil { + t.Fatal(err) + } + if _, err := c.PutFrame(t.Context(), nil); err != nil || calls != 2 { + t.Fatalf("approved write: %v calls=%d", err, calls) + } + if StorageState(home, DevRelayURL, id) != "off" || StorageState(home, ProductionRelayURL, "id_b") != "off" { + t.Fatal("permission crossed account or relay") + } + if err := SaveStorage(home, ProductionRelayURL, id, "off"); err != nil { + t.Fatal(err) + } + if _, err := c.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 2 { + t.Fatal("revoked store made a request") + } + _ = SaveStorage(home, ProductionRelayURL, id, "") + if StorageState(home, ProductionRelayURL, id) != "off" { + t.Fatal("missing hosted answer enabled storage") + } + _ = os.WriteFile(filepath.Join(home, StorageFile), []byte("{"), 0600) + if StorageState(home, ProductionRelayURL, id) != "off" { + t.Fatal("damaged cache enabled storage") + } +} + +func TestStorageOptInDriveStopsBeforeAnyNetworkOrEngineWork(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id} + _, err = s.Drive(t.Context(), cellstore.Engine{}, cell.Cell{ID: "local"}, DriveOptions{}) + if !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("drive started: %v", err) + } +} + +func TestStorageOptInWireRefusalClosesAllLocalUploaders(t *testing.T) { + home := t.TempDir() + id := "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if err := SaveStorage(home, ProductionRelayURL, id, "on"); err != nil { + t.Fatal(err) + } + calls := 0 + hc := &http.Client{Transport: storageTransport(func(*http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 403, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"err":"storage_off"}`))}, nil + })} + first := GateStore(blobstore.NewHTTP(ProductionRelayURL, func(*http.Request, []byte) {}, hc), home, ProductionRelayURL, id) + second := GateStore(blobstore.NewHTTP(ProductionRelayURL, func(*http.Request, []byte) {}, hc), home, ProductionRelayURL, id) + _, _ = first.PutFrame(t.Context(), nil) + if _, err := second.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 1 { + t.Fatalf("refusal was not remembered: %v requests=%d", err, calls) + } +} + +func TestStorageOptInVaultEditsWaitAndUploadAfterApproval(t *testing.T) { + home := t.TempDir() + t.Setenv(URLVar, ProductionRelayURL) + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + dev, err := identity.Device(home) + if err != nil { + t.Fatal(err) + } + vault, err := keys.Open(home) + if err != nil { + t.Fatal(err) + } + if err = vault.Put("project/pending", keys.Entry{Name: "pending", Value: "throwaway-test-value", Scope: "project"}); err != nil { + t.Fatal(err) + } + dir := directory.NewMemory(time.Now) + defer dir.Close() + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id, Device: dev, Store: blobstore.NewMemory(), Dir: dir.For(dev.ID())} + if err = s.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + listing, err := s.Dir.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if listing.Identity.Vault != "" || s.stateSent != "" { + t.Fatal("pending vault was treated as sent while off") + } + if err = SaveStorage(home, ProductionRelayURL, id.ID(), "on"); err != nil { + t.Fatal(err) + } + if err = s.SyncState(t.Context()); err != nil { + t.Fatal(err) + } + listing, err = s.Dir.List(t.Context()) + if err != nil || listing.Identity.Vault == "" { + t.Fatalf("pending vault did not upload after approval: %+v %v", listing.Identity, err) + } +} + +func TestStorageOptInDefaultPortsAndCase(t *testing.T) { + home := t.TempDir() + for _, relay := range []string{ProductionRelayURL, StagingRelayURL, DevRelayURL} { + u, _ := url.Parse(relay) + u.Host = strings.ToUpper(u.Host) + ":443" + if !IsHostedRelay(u.String()) || StorageState(home, u.String(), "account") != "off" { + t.Fatalf("unknown default-port hosted relay is open: %s", u) + } + if err := SaveStorage(home, relay, "account", "on"); err != nil { + t.Fatal(err) + } + if StorageState(home, u.String(), "account") != "on" { + t.Fatalf("cache binding differs for %s", u) + } + } + if !IsHostedRelay("http://CODEAF.AGENTFIELD.AI:80/fabric") || IsHostedRelay("https://codeaf.agentfield.ai:444/fabric") { + t.Fatal("port normalization") + } +} diff --git a/internal/syncsetup/storage_refresh.go b/internal/syncsetup/storage_refresh.go new file mode 100644 index 0000000000..fd326d44cd --- /dev/null +++ b/internal/syncsetup/storage_refresh.go @@ -0,0 +1,299 @@ +package syncsetup + +import ( + "context" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/identity" +) + +const StorageRefreshEvery = 10 * time.Minute + +// One coordinator owns both approval discovery and startup waits in a process. +// Running batchers use their own bounded retry and never poll permission files. +var storageProcesses = struct { + sync.Mutex + homes map[string]*storageProcess +}{homes: make(map[string]*storageProcess)} + +type storageProcess struct { + mu sync.Mutex + home string + users int + next uint64 + waiting map[uint64]func() + opened map[uint64]func() + stop func() bool + ctx context.Context + lastRefresh time.Time + active bool + dirty bool + counts blobstore.Counts + countsRelay, countsIdentity string + // These seams count actual scheduled work without relying on elapsed time. + after func(time.Duration, func()) func() bool + read func() (string, string, string) + fetch func(context.Context) error + now func() time.Time + load func(string) (identity.Identity, error) + state func(string, string, string) string +} + +func storageProcessFor(home string) *storageProcess { + storageProcesses.Lock() + defer storageProcesses.Unlock() + p := storageProcesses.homes[home] + if p == nil { + ctx := context.Background() + p = &storageProcess{home: home, ctx: ctx, waiting: make(map[uint64]func()), opened: make(map[uint64]func()), now: time.Now} + p.load = identity.Load + p.state = StorageState + p.after = func(d time.Duration, f func()) func() bool { return time.AfterFunc(d, f).Stop } + p.read = func() (string, string, string) { + relay := Resolve(home) + if !relay.On() || !IsHostedRelay(relay.URL) || Quiet(home) { + return "", "", "" + } + id, err := p.load(home) + if err != nil { + return "", "", "" + } + return relay.URL, id.ID(), p.state(home, relay.URL, id.ID()) + } + p.fetch = func(ctx context.Context) error { + s, ok, err := Open(home) + if err != nil || !ok { + return err + } + _, err = s.RefreshStorage(ctx) + return err + } + storageProcesses.homes[home] = p + } + return p +} + +// StartStorageRefresh shares one asynchronous signed reading at startup and +// every ten minutes while hosted storage is unknown or off. Callers join its +// lifetime independently; chat opening never waits for a network answer. +func StartStorageRefresh(ctx context.Context, home string, initial ...bool) func() { + p := storageProcessFor(home) + p.mu.Lock() + p.users++ + first := p.users == 1 + p.mu.Unlock() + if first { + refresh := len(initial) == 0 || initial[0] + go p.check(refresh) + } + var once sync.Once + stop := func() { + once.Do(func() { + p.mu.Lock() + p.users-- + if p.users == 0 && len(p.waiting) == 0 { + p.disarm() + } + p.mu.Unlock() + }) + } + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + stop() + case <-done: + } + }() + var joined sync.Once + return func() { stop(); joined.Do(func() { close(done) }) } +} + +// RefreshStorage retains a failed reading and persists only successful stats. +func (s *Sync) RefreshStorage(ctx context.Context) (blobstore.Counts, error) { + store, ok := s.Store.(interface { + Stats(context.Context) (blobstore.Counts, error) + }) + if !ok { + return blobstore.Counts{}, nil + } + counts, err := store.Stats(ctx) + storageProcesses.Lock() + p := storageProcesses.homes[s.Home] + storageProcesses.Unlock() + if p != nil { + p.mu.Lock() + p.lastRefresh = p.now() + if err == nil { + p.counts = counts + p.countsRelay, p.countsIdentity = s.Relay, s.Identity.ID() + } + p.mu.Unlock() + } + if err == nil { + err = SaveStorage(s.Home, s.Relay, s.Identity.ID(), counts.Storage) + } + return counts, err +} + +// WaitStorage registers a stopped chat on one shared local sampling timer. +func WaitStorage(home string, opened func()) func() { + p := storageProcessFor(home) + p.mu.Lock() + p.next++ + id := p.next + p.waiting[id] = opened + first := len(p.waiting) == 1 + p.mu.Unlock() + if first { + go p.check(false) + } + return func() { + p.mu.Lock() + _, had := p.waiting[id] + delete(p.waiting, id) + empty := had && len(p.waiting) == 0 + if empty { + p.disarm() + } + users := p.users + p.mu.Unlock() + if empty && users > 0 { + go p.check(false) + } + } +} + +// OnStorageOpened wakes an existing batcher immediately after an in-process +// refresh, without giving it a timer or a separate permission-file reader. +func OnStorageOpened(home string, opened func()) func() { + p := storageProcessFor(home) + p.mu.Lock() + p.next++ + id := p.next + p.opened[id] = opened + p.mu.Unlock() + return func() { p.mu.Lock(); delete(p.opened, id); p.mu.Unlock() } +} + +func (p *storageProcess) disarm() { + if p.stop != nil { + p.stop() + p.stop = nil + } +} + +func (p *storageProcess) check(refresh bool) { + p.mu.Lock() + if p.active { + p.dirty = true + p.mu.Unlock() + return + } + p.active = true + p.disarm() + p.mu.Unlock() + relay, _, state := p.read() + if relay != "" && state != "on" && refresh { + ctx, cancel := context.WithTimeout(p.ctx, requestTimeout) + _ = p.fetch(ctx) + cancel() + p.mu.Lock() + p.lastRefresh = p.now() + p.mu.Unlock() + relay, _, state = p.read() + } + p.mu.Lock() + p.active = false + again := p.dirty + p.dirty = false + var callbacks []func() + if state == "on" { + for id, fn := range p.waiting { + callbacks = append(callbacks, fn) + delete(p.waiting, id) + } + } + if relay != "" && state != "on" && (p.users > 0 || len(p.waiting) > 0) { + wait := StorageRefreshEvery + if p.users > 0 && !p.lastRefresh.IsZero() { + wait = max(time.Millisecond, StorageRefreshEvery-p.now().Sub(p.lastRefresh)) + } + if len(p.waiting) > 0 { + wait = min(wait, StoragePollEvery) + } + p.stop = p.after(wait, func() { + p.mu.Lock() + refresh := p.users > 0 && p.now().Sub(p.lastRefresh) >= StorageRefreshEvery + p.mu.Unlock() + p.check(refresh) + }) + } + p.mu.Unlock() + for _, fn := range callbacks { + fn() + } + if again { + p.check(false) + } +} + +func storageSaved(home, relay, id, state string) { + storageProcesses.Lock() + p := storageProcesses.homes[home] + storageProcesses.Unlock() + if p == nil { + return + } + // The cache may temporarily hold a rotation successor, so callbacks use the + // current home's binding instead of trusting a different identity's reading. + p.mu.Lock() + active := p.active + if active && state == "off" { + p.dirty = true + } + p.mu.Unlock() + if state == "on" { + current, account, reading := p.read() + if normalizeStorageURL(current) != normalizeStorageURL(relay) || account != id || reading != "on" { + return + } + p.mu.Lock() + p.disarm() + callbacks := make([]func(), 0, len(p.waiting)+len(p.opened)) + for key, fn := range p.waiting { + callbacks = append(callbacks, fn) + delete(p.waiting, key) + } + for _, fn := range p.opened { + callbacks = append(callbacks, fn) + } + p.mu.Unlock() + for _, fn := range callbacks { + go fn() + } + } else if !active { + go p.check(false) + } +} + +// StorageCounts retains the last in-process usage reading while approval is on. +// Permission persistence deliberately stores no usage counters. +func StorageCounts(home, relay, identity string) blobstore.Counts { + storageProcesses.Lock() + p := storageProcesses.homes[home] + storageProcesses.Unlock() + if p == nil { + return blobstore.Counts{Storage: "on"} + } + p.mu.Lock() + defer p.mu.Unlock() + counts := p.counts + if normalizeStorageURL(p.countsRelay) != normalizeStorageURL(relay) || p.countsIdentity != identity { + counts = blobstore.Counts{} + } + counts.Storage = "on" + return counts +} diff --git a/internal/syncsetup/storage_refresh_test.go b/internal/syncsetup/storage_refresh_test.go new file mode 100644 index 0000000000..0b52917f83 --- /dev/null +++ b/internal/syncsetup/storage_refresh_test.go @@ -0,0 +1,186 @@ +package syncsetup + +import ( + "context" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestStorageOptInSharedWaiterWork(t *testing.T) { + for _, n := range []int{1, 10, 50} { + for _, mode := range []string{"on", "off", "self-hosted"} { + t.Run(fmt.Sprintf("%s/%d", mode, n), func(t *testing.T) { + home := t.TempDir() + relay := ProductionRelayURL + if mode == "self-hosted" { + relay = "http://own.test" + } + t.Setenv(URLVar, relay) + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + state := mode + if state == "self-hosted" { + state = "on" + } + if err := SaveStorage(home, relay, id.ID(), state); err != nil { + t.Fatal(err) + } + reads, timers, requests := 0, 0, 0 + var next func() + p := storageProcessFor(home) + p.load = func(home string) (identity.Identity, error) { reads++; return identity.Load(home) } + p.state = func(home, relay, id string) string { reads++; return StorageState(home, relay, id) } + p.fetch = func(context.Context) error { requests++; return nil } + p.after = func(wait time.Duration, fn func()) func() bool { + if wait > StoragePollEvery { + t.Fatalf("wait %v", wait) + } + timers++ + next = fn + return func() bool { next = nil; return true } + } + woke := 0 + if mode == "off" { + for i := 0; i < n; i++ { + p.waiting[uint64(i)] = func() { woke++ } + } + } + p.check(false) + // Setup resolves the identity once. Count actual identity and permission + // file reads in one minute of steady-state local waiting. + reads, timers, requests = 0, 0, 0 + if next != nil { + fn := next + next = nil + fn() + } + wantReads, wantTimers := 0, 0 + if mode == "off" { + wantReads, wantTimers = 2, 1 + } + if reads != wantReads || timers != wantTimers || requests != 0 { + t.Fatalf("reads=%d timers=%d requests=%d", reads, timers, requests) + } + t.Logf("chats=%d state=%s file-reads/min=%d timer-arms/min=%d relay-requests=%d", n, mode, reads, timers, requests) + if mode == "off" { + // A raw atomic file change models another process sharing this home. + raw := fmt.Sprintf(`{"relay":%q,"identity":%q,"storage":"on"}`, relay, id.ID()) + if err := os.WriteFile(filepath.Join(home, StorageFile), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + fn := next + fn() + if woke != n || next != nil { + t.Fatalf("wake=%d timer=%v", woke, next != nil) + } + } + }) + } + } +} + +func TestStorageOptInFailedRefreshPreservesCache(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + for _, state := range []string{"off", "on"} { + if err := SaveStorage(home, ProductionRelayURL, id.ID(), state); err != nil { + t.Fatal(err) + } + hc := &http.Client{Transport: storageTransport(func(*http.Request) (*http.Response, error) { return nil, errors.New("offline fixture") })} + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id, Store: blobstore.NewHTTP(ProductionRelayURL, func(*http.Request, []byte) {}, hc)} + if _, err := s.RefreshStorage(t.Context()); err == nil || StorageState(home, ProductionRelayURL, id.ID()) != state { + t.Fatal("failed refresh changed permission") + } + } +} +func TestStorageOptInRefresherCadenceAndApplicability(t *testing.T) { + for _, mode := range []string{"off", "on", "self-hosted", "sync-off"} { + t.Run(mode, func(t *testing.T) { + requests := 0 + var next func() + now := time.Unix(100, 0) + p := &storageProcess{ctx: t.Context(), users: 1, waiting: map[uint64]func(){}, opened: map[uint64]func(){}, now: func() time.Time { return now }} + p.read = func() (string, string, string) { + if mode == "self-hosted" || mode == "sync-off" { + return "", "", "" + } + return ProductionRelayURL, "id", mode + } + p.fetch = func(context.Context) error { requests++; return nil } + p.after = func(wait time.Duration, fn func()) func() bool { + if wait != StorageRefreshEvery { + t.Fatalf("cadence %v", wait) + } + next = fn + return func() bool { next = nil; return true } + } + p.check(true) + if mode == "off" { + if requests != 1 || next == nil { + t.Fatal("missing startup refresh") + } + now = now.Add(StorageRefreshEvery) + next() + if requests != 2 { + t.Fatal("missing periodic refresh") + } + } else if requests != 0 || next != nil { + t.Fatalf("inapplicable work requests=%d timer=%v", requests, next != nil) + } + }) + } +} + +func TestStorageOptInSignedRefreshUpdatesWaitersAndRunningDrives(t *testing.T) { + home := t.TempDir() + t.Setenv(URLVar, ProductionRelayURL) + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + p := storageProcessFor(home) + opened := make(chan string, 2) + cancelRunning := OnStorageOpened(home, func() { opened <- "running" }) + defer cancelRunning() + // Register under the coordinator lock so this test controls the initial + // local sample and the signed answer, rather than the scheduler. + p.mu.Lock() + p.waiting[1] = func() { opened <- "waiting" } + p.mu.Unlock() + hc := &http.Client{Transport: storageTransport(func(r *http.Request) (*http.Response, error) { + if r.Header.Get("Codeaf-Sig") == "" { + t.Error("unsigned stats") + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"storage":"on"}`))}, nil + })} + dev, err := identity.Device(home) + if err != nil { + t.Fatal(err) + } + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id, Device: dev, Store: blobstore.NewHTTP(ProductionRelayURL, reqsign.SignFor(deviceSigner{id, dev}, time.Now), hc)} + if _, err := s.RefreshStorage(t.Context()); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + select { + case <-opened: + case <-time.After(5 * time.Second): + t.Fatal("refresh failed to wake") + } + } +} diff --git a/internal/syncsetup/syncsetup.go b/internal/syncsetup/syncsetup.go index d2062b82e2..bc3656f3bc 100644 --- a/internal/syncsetup/syncsetup.go +++ b/internal/syncsetup/syncsetup.go @@ -204,7 +204,7 @@ func buildAt(home, base string, id identity.Identity, dev identity.Dev, interval Interval: interval, } s.Dir = directory.NewHTTP(base, sign, s.ClusterHTTP(&http.Client{Timeout: requestTimeout})) - s.Store = blobstore.NewHTTP(base, sign, s.ClusterHTTP(&http.Client{})) + s.Store = GateStore(blobstore.NewHTTP(base, sign, s.ClusterHTTP(&http.Client{})), home, base, id.ID()) return s } diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 16aa688fb7..905f4c2eb1 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -735,7 +735,7 @@ func (s *Sync) withVaultWithin(ctx context.Context, within time.Duration, use fu } sc := s.scope(cellstats.VaultScope) defer sc.Settle() - return use(vaultsync.Syncer{Store: sc.Store, Dir: s.Dir, Vault: v, CellKeyID: s.Identity.CellKeyID(), + return use(vaultsync.Syncer{StorageOn: s.StorageAllowed, Store: sc.Store, Dir: s.Dir, Vault: v, CellKeyID: s.Identity.CellKeyID(), Carry: append(s.carried(), extra...)}) } diff --git a/internal/taskdoor/storage_optin_test.go b/internal/taskdoor/storage_optin_test.go new file mode 100644 index 0000000000..f09982beda --- /dev/null +++ b/internal/taskdoor/storage_optin_test.go @@ -0,0 +1,93 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +type storageStatsTransport func(*http.Request) (*http.Response, error) + +func (f storageStatsTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// This acceptance test uses the existing independent-copy and child fixtures, +// including their disposable Git history, isolated from the caller's checkout. +func TestStorageOptInServingTaskResultAfterApproval(t *testing.T) { + celltest.Available(t) + c := newChildRig(t) + first, fresh := t.TempDir(), t.TempDir() + id, dev := c.copies.Identity, c.copies.Device + if err := identity.Install(first, id, dev); err != nil { + t.Fatal(err) + } + if err := identity.Install(fresh, id, dev); err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, syncsetup.ProductionRelayURL) + if err := syncsetup.SaveStorage(first, syncsetup.ProductionRelayURL, id.ID(), "on"); err != nil { + t.Fatal(err) + } + if syncsetup.StorageForHome(fresh) != "off" { + t.Fatal("fresh home reused first home permission") + } + var stats, writes atomic.Int32 + original := http.DefaultTransport + underlying := blobstore.NewHTTP(c.copies.Service, func(*http.Request, []byte) {}, &http.Client{Transport: storageStatsTransport(func(r *http.Request) (*http.Response, error) { + if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/v1/store/frames") { + writes.Add(1) + } + return original.RoundTrip(r) + })}) + // The production HTTP gate used by serving copies shares the fresh home's + // reading; the frame wire still reaches the existing real loopback store. + c.copies.Store = syncsetup.GateStore(underlying, fresh, syncsetup.ProductionRelayURL, id.ID()) + http.DefaultTransport = storageStatsTransport(func(r *http.Request) (*http.Response, error) { + if syncsetup.IsHostedRelay(r.URL.Scheme + "://" + r.URL.Host) { + if r.Method != "GET" || !strings.HasSuffix(r.URL.Path, "/v1/store/stats") { + t.Fatalf("unexpected hosted request %s", r.URL) + } + if _, _, err := reqsign.AuthenticateAt(time.Now)(r, nil); err != nil { + t.Errorf("stats signature: %v", err) + } + stats.Add(1) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"storage":"on"}`))}, nil + } + return original.RoundTrip(r) + }) + defer func() { http.DefaultTransport = original }() + // This is the same process-wide lifetime serve and --take-tasks start. + stopRefresh := syncsetup.StartStorageRefresh(t.Context(), fresh) + defer stopRefresh() + deadline := time.After(5 * time.Second) + for syncsetup.StorageForHome(fresh) != "on" { + select { + case <-deadline: + t.Fatal("serving process did not discover approval") + default: + time.Sleep(time.Millisecond) + } + } + stop := c.r.serve(c.config(t, false)) + defer stop() + conn := c.start(t) + defer conn.Close() + if err := os.WriteFile(filepath.Join(c.dir, "finish"), nil, 0600); err != nil { + t.Fatal(err) + } + _, _, done := childFinish(t, c.r.dial(), c.id) + if done.Outcome != "done" || done.Result == nil || writes.Load() == 0 || stats.Load() != 1 { + t.Fatalf("result=%+v stats=%d writes=%d", done, stats.Load(), writes.Load()) + } +} diff --git a/internal/vaultsync/storage_optin_test.go b/internal/vaultsync/storage_optin_test.go new file mode 100644 index 0000000000..3eef48cecb --- /dev/null +++ b/internal/vaultsync/storage_optin_test.go @@ -0,0 +1,17 @@ +package vaultsync + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/wireauth" + "testing" +) + +func TestStorageOptInVaultDoesNotStartUploadRound(t *testing.T) { + s := Syncer{StorageOn: func() bool { return false }} + if err := s.Push(t.Context()); !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("push: %v", err) + } + if err := s.PushSealed(t.Context(), []byte("sealed"), ""); !errors.Is(err, wireauth.ErrStorageOff) { + t.Fatalf("push sealed: %v", err) + } +} diff --git a/internal/vaultsync/syncer.go b/internal/vaultsync/syncer.go index 3f913dad5c..1122839f65 100644 --- a/internal/vaultsync/syncer.go +++ b/internal/vaultsync/syncer.go @@ -12,6 +12,7 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // magic leads every vault object; the store refuses any object without a known @@ -43,6 +44,8 @@ var ErrTampered = errors.New("vaultsync: the vault object does not match its id // Syncer moves one identity's vault between this device and the store. The // vault object is the sealed vault.enc, so the store never sees a secret. type Syncer struct { + // StorageOn prevents an upload round while the last relay answer is closed. + StorageOn func() bool Store blobstore.Store Dir directory.Client Vault VaultFile @@ -59,6 +62,9 @@ type Syncer struct { // third device pushed in between, pulls again and pushes once more; a second // loss is returned, since looping would only hide a busy directory. func (s Syncer) Push(ctx context.Context) error { + if s.StorageOn != nil && !s.StorageOn() { + return wireauth.ErrStorageOff + } if err := s.Capture(); err != nil { return err } @@ -91,6 +97,9 @@ func (s Syncer) publish(ctx context.Context, old string) error { // new identity's relay with the vault resealed for it, which no local file // holds yet. func (s Syncer) PushSealed(ctx context.Context, enc []byte, old string) error { + if s.StorageOn != nil && !s.StorageOn() { + return wireauth.ErrStorageOff + } obj := append(bytes.Clone(magic), enc...) rid := ridOf(obj) if err := s.put(ctx, rid, obj); err != nil || rid == old { diff --git a/internal/wireauth/wireauth.go b/internal/wireauth/wireauth.go index fb6575a85e..8ab3265493 100644 --- a/internal/wireauth/wireauth.go +++ b/internal/wireauth/wireauth.go @@ -73,6 +73,9 @@ var ErrRateLimited = errors.New("wireauth: rate limited") // time, but by hours and not seconds. var ErrTooManyIdentities = errors.New("wireauth: too many new identities") +// ErrStorageOff means the relay has not enabled storage for this account. +var ErrStorageOff = errors.New("wireauth: relay storage off") + // ErrRelayFull identifies shared storage capacity on both wires. Each decoder // also retains its own general full error so existing callers keep matching it. var ErrRelayFull = errors.New("wireauth: relay storage full") From 5f0a4702c07b6a03b000eb7d76b3a51a63ca4263 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 21:07:43 -0500 Subject: [PATCH 03/18] Show relay storage state and the request door in Settings and /status Settings > Devices gains a "relay storage" row on a hosted relay: on, or off with enter to request access, which opens a pre-filled GitHub issue and also shows the request code and link in the panel when no browser opens. The row is absent for a self-hosted relay or an unpaired computer. /status says "relay storage off" in place of the sync space line, and the quit receipt after a revoke says the turn stays on this computer instead of blaming the relay's reachability. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/approvals.go | 15 +++ cmd/codeaf/chatv3_surface.go | 3 + cmd/codeaf/chatv3_taskcomputers.go | 2 +- cmd/codeaf/engine.go | 5 + cmd/codeaf/final_save.go | 7 +- cmd/codeaf/serve_state.go | 7 +- cmd/codeaf/serve_tasks.go | 5 +- cmd/codeaf/storage_optin_test.go | 156 ++++++++++++++++++++++++++ cmd/codeaf/syncdrive.go | 36 +++++- cmd/codeaf/syncdrive_live.go | 52 ++++++--- cmd/codeaf/team_resume.go | 4 +- internal/remote/client.go | 4 + internal/remote/server.go | 2 + internal/remote/storage_optin_test.go | 25 +++++ internal/remote/wire.go | 1 + internal/tui3/app.go | 7 ++ internal/tui3/place_settings.go | 7 +- internal/tui3/settings.go | 15 ++- internal/tui3/settings_devices.go | 72 ++++++++++-- internal/tui3/statusnote.go | 7 +- internal/tui3/storage_optin_test.go | 140 +++++++++++++++++++++++ internal/tui3/syncspace.go | 23 +++- 22 files changed, 554 insertions(+), 41 deletions(-) create mode 100644 cmd/codeaf/storage_optin_test.go create mode 100644 internal/remote/storage_optin_test.go create mode 100644 internal/tui3/storage_optin_test.go diff --git a/cmd/codeaf/approvals.go b/cmd/codeaf/approvals.go index 00baeb1eb0..b9e18e73e7 100644 --- a/cmd/codeaf/approvals.go +++ b/cmd/codeaf/approvals.go @@ -20,6 +20,7 @@ import ( "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/reqsign" "github.com/Agent-Field/codeaf/internal/syncsetup" "github.com/Agent-Field/codeaf/internal/tui3" ) @@ -276,3 +277,17 @@ func (d *approvalsDoor) ownRow() tui3.DeviceRow { // SyncService names the local pairing service even when the chat runs elsewhere. func (d *approvalsDoor) SyncService() string { return syncsetup.Resolve(d.dir).URL } + +// RelayStorage reads this computer's account without creating keys or asking the network. +func (d *approvalsDoor) RelayStorage() (code, state string) { + relay := syncsetup.Resolve(d.dir) + if !relay.On() || !syncsetup.IsHostedRelay(relay.URL) || !syncsetup.MayTalk(d.dir) { + return "", "" + } + id, err := identity.Load(d.dir) + if err != nil { + return "", "" + } + code = reqsign.IDOf(id.PublicKey()) + return code, syncsetup.StorageState(d.dir, relay.URL, code) +} diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index e51705a09c..e6406032fd 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -2,6 +2,7 @@ package main import ( "context" + "github.com/Agent-Field/codeaf/internal/syncsetup" "log" "os" @@ -49,6 +50,8 @@ var ( // it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface] // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { + stopStorage := syncsetup.StartStorageRefresh(ctx, home.Dir(), false) + defer stopStorage() if options.Host == "" { if options.Notices == nil { options.Notices = surfaceNotices diff --git a/cmd/codeaf/chatv3_taskcomputers.go b/cmd/codeaf/chatv3_taskcomputers.go index 2e7712f137..be2916ad90 100644 --- a/cmd/codeaf/chatv3_taskcomputers.go +++ b/cmd/codeaf/chatv3_taskcomputers.go @@ -47,7 +47,7 @@ func v3TaskComputers(profile string) session.TaskComputers { engine := cellstore.EngineFor("") engine.DataRoot = filepath.Join(profile, "v3", "stores") engine.Guard.Home = profile - store := blobstore.NewHTTP(route.URL, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{Timeout: time.Minute}) + store := syncsetup.GateStore(blobstore.NewHTTP(route.URL, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{Timeout: time.Minute}), profile, route.URL, id.ID()) copies := taskdoor.CopyDeps{Root: filepath.Join(profile, "v3", "taskcopies"), Service: route.URL, Identity: id, Device: device, Engine: engine, Store: store} return &taskplace.Computers{Directory: client, Copies: copies, Home: profile} } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index e90cef3dd9..18f054b9fb 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -42,6 +42,7 @@ package main // cannot work must still take a remote session. import ( + "context" "errors" "fmt" "io" @@ -155,6 +156,8 @@ func runRemoteEngine(args []string) error { return quietRefusal(remote.Refuse(os.Stdout, stale.reason)) } } + stopStorage := syncsetup.StartStorageRefresh(context.Background(), home.Dir()) + defer stopStorage() err := remote.Serve(os.Stdin, os.Stdout, remote.Options{ SyncService: chatSyncService(), Boot: func(hello remote.Hello) (*remote.Engine, error) { @@ -925,6 +928,8 @@ func placesWord(n int) string { // the machine is in exactly the state that was asked for — and this process was // started by another one that is about to dial the socket either way. func runEngineHost(workspaceFlag, sessionFlag string) error { + stopStorage := syncsetup.StartStorageRefresh(context.Background(), home.Dir()) + defer stopStorage() workspace, err := engineWorkspace(workspaceFlag) if err != nil { return err diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go index fda68ce3ad..e8f043fd96 100644 --- a/cmd/codeaf/final_save.go +++ b/cmd/codeaf/final_save.go @@ -8,6 +8,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/syncsetup" "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" "os" "os/signal" "strings" @@ -24,6 +25,7 @@ const quitSavingWord = "saving this chat to your other devices…" const quitStillSavingWord = "still saving — ctrl+c leaves it saved here" const quitStillLocalWord = "still saving on this computer — ctrl+c leaves the written turns here" const quitLegacySavingWord = "codeaf is still saving this chat in the background; unsaved turns stay on this computer." +const quitStorageOffWord = "saved here; relay storage is off for this account, so it stays on this computer" const quitSavedHereWord = "saved here; it reaches your other devices when the sync service at %s answers" // finalSaving keeps one failure until cleanup has returned the terminal. @@ -176,7 +178,7 @@ func recordFinalSaveFailure(err error) { } if syncwords.UploadPending(err) { finalSaving.finishing = true - if finalSaving.failure == nil { + if finalSaving.failure == nil || syncwords.UploadPending(finalSaving.failure) && errors.Is(err, wireauth.ErrStorageOff) { finalSaving.failure = err } return @@ -266,6 +268,9 @@ func finishFinalSaving(ctx context.Context) (ok bool) { // finalSaveWords distinguishes durable local turns from a failed local write. func finalSaveWords(err error, finishing bool, address string) (string, bool) { if syncwords.UploadPending(err) { + if errors.Is(err, wireauth.ErrStorageOff) { + return quitStorageOffWord, true + } return fmt.Sprintf(quitSavedHereWord, syncwords.Host(address)), true } if (err == nil && finishing) || errors.Is(err, session.ErrSavingUnanswered) { diff --git a/cmd/codeaf/serve_state.go b/cmd/codeaf/serve_state.go index 6d1e9e93da..2eb7f0e5d8 100644 --- a/cmd/codeaf/serve_state.go +++ b/cmd/codeaf/serve_state.go @@ -18,11 +18,11 @@ func startServeKeeping(ctx context.Context, service string) func() { } // serveKeeping is startServeKeeping with the decision made. With keep false -// it starts nothing and the join does nothing, so every serve that did not ask -// behaves exactly as it always has. +// only permission discovery runs; home-state and presence remain opt-in. func serveKeeping(ctx context.Context, service string, keep bool) func() { + stopStorage := syncsetup.StartStorageRefresh(ctx, home.Dir()) if !keep { - return func() {} + return stopStorage } joinState := startServeHomeState(ctx, home.Dir(), func(err error) { fmt.Fprintln(os.Stderr, "could not sync this machine's saved keys and settings:", err) @@ -34,6 +34,7 @@ func serveKeeping(ctx context.Context, service string, keep bool) func() { stopPresence() <-presenceDone joinState() + stopStorage() } } diff --git a/cmd/codeaf/serve_tasks.go b/cmd/codeaf/serve_tasks.go index b7e5003d14..3f673a0349 100644 --- a/cmd/codeaf/serve_tasks.go +++ b/cmd/codeaf/serve_tasks.go @@ -41,6 +41,7 @@ func serveTasks(ctx context.Context, service string) func() { } config.Say = func(line string) { fmt.Fprintln(os.Stdout, line) } ctx, cancel := context.WithCancel(ctx) + stopStorage := syncsetup.StartStorageRefresh(ctx, profile) done := make(chan struct{}) guard.Go("serve/tasks", func() { defer close(done) @@ -48,7 +49,7 @@ func serveTasks(ctx context.Context, service string) func() { fmt.Fprintln(os.Stderr, "could not take tasks from your paired computers:", err) } }) - return func() { cancel(); <-done } + return func() { cancel(); stopStorage(); <-done } } func serveTasksConfig(profile, service string) (taskdoor.Config, bool, error) { @@ -71,7 +72,7 @@ func serveTasksConfig(profile, service string) (taskdoor.Config, bool, error) { engine := cellstore.EngineFor("") engine.DataRoot = filepath.Join(profile, "v3", "stores") engine.Guard.Home = profile - store := blobstore.NewHTTP(service, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{}) + store := syncsetup.GateStore(blobstore.NewHTTP(service, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{}), profile, service, id.ID()) copies := taskdoor.CopyDeps{Root: filepath.Join(profile, "v3", "taskcopies"), Service: service, Identity: id, Device: device, Engine: engine, Store: store} return taskdoor.Config{Service: service, Identity: id, Device: device, Home: profile, Root: filepath.Join(profile, "v3", "taskdoor"), Roster: func(ctx context.Context) (map[string]bool, error) { diff --git a/cmd/codeaf/storage_optin_test.go b/cmd/codeaf/storage_optin_test.go new file mode 100644 index 0000000000..1ae6e99ed4 --- /dev/null +++ b/cmd/codeaf/storage_optin_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestStorageOptInStatsWakesOpenChatAndRevokesLocally(t *testing.T) { + celltest.Available(t) + t.Setenv(home.EnvVar, t.TempDir()) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + var state atomic.Bool + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != "GET" || r.URL.Path != "/v1/store/stats" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + word := "off" + if state.Load() { + word = "on" + } + _, _ = w.Write([]byte(`{"storage":"` + word + `"}`)) + })) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + if _, err := startDrive(cell.Cell{ID: "open"}, cellstore.Engine{}, func(error) {}); !errors.Is(err, wireauth.ErrStorageOff) || requests.Load() != 0 { + t.Fatalf("off startup: %v requests=%d", err, requests.Load()) + } + old := syncDrives + var starts atomic.Int32 + l := &liveDrive{now: time.Now, changes: make(chan struct{}, 1), after: func(time.Duration, func()) func() bool { return func() bool { return true } }, start: func() (*syncsetup.Drive, error) { + starts.Add(1) + if syncsetup.StorageForHome(home.Dir()) != "on" { + return nil, wireauth.ErrStorageOff + } + return nil, nil + }} + syncDrives = &driveBook{drive: map[string]*liveDrive{"open": l}} + t.Cleanup(func() { syncDrives = old }) + l.try() + startupFinished(l) + if starts.Load() != 1 { + t.Fatal("local wait did not start") + } + state.Store(true) + counts, err := chatSyncStats(t.Context()) + if err != nil || counts.Storage != "on" { + t.Fatalf("approval refresh: %+v %v", counts, err) + } + startupFinished(l) + if starts.Load() != 2 || syncsetup.StorageForHome(home.Dir()) != "on" { + t.Fatal("approval did not wake open chat") + } + state.Store(false) + _ = syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), "off") + if _, err := chatSyncStats(t.Context()); err != nil { + t.Fatal(err) + } + if syncsetup.StorageForHome(home.Dir()) != "off" || teamPaired() { + t.Fatal("revocation did not stop uploads") + } +} + +func TestStorageOptInSettingsOnlyPairedHosted(t *testing.T) { + dir := t.TempDir() + t.Setenv(syncsetup.URLVar, syncsetup.ProductionRelayURL) + id, err := identity.EnsureSolo(dir) + if err != nil { + t.Fatal(err) + } + door := newApprovalsDoor(dir) + if code, _ := door.RelayStorage(); code != "" { + t.Fatal("unpaired row present") + } + if err = identity.EndSolo(dir); err != nil { + t.Fatal(err) + } + if code, state := door.RelayStorage(); code != id.ID() || state != "off" { + t.Fatalf("hosted row: %s %s", code, state) + } + if err = syncsetup.SaveStorage(dir, syncsetup.ProductionRelayURL, id.ID(), "on"); err != nil { + t.Fatal(err) + } + if _, state := door.RelayStorage(); state != "on" { + t.Fatal("approved row not on") + } + portHome := t.TempDir() + portID, err := identity.Ensure(portHome) + if err != nil { + t.Fatal(err) + } + portDoor := newApprovalsDoor(portHome) + for _, raw := range []string{syncsetup.ProductionRelayURL, syncsetup.StagingRelayURL, syncsetup.DevRelayURL} { + u, _ := url.Parse(raw) + u.Host = strings.ToUpper(u.Host) + ":443" + t.Setenv(syncsetup.URLVar, u.String()) + if code, state := portDoor.RelayStorage(); code != portID.ID() || state != "off" { + t.Fatalf("default-port row: %s %s %s", u, code, state) + } + } + + for _, relay := range []string{"http://own.test", "off"} { + t.Setenv(syncsetup.URLVar, relay) + if code, _ := door.RelayStorage(); code != "" { + t.Fatalf("inapplicable row present: %s", relay) + } + } +} + +func TestStorageOptInQuitReceiptAfterRevocation(t *testing.T) { + armFinalSaving() + retainLocalDurability(func() bool { return true }) + before := os.Stdout + out, err := os.CreateTemp(t.TempDir(), "receipt") + if err != nil { + t.Fatal(err) + } + defer out.Close() + os.Stdout = out + t.Cleanup(func() { os.Stdout = before }) + recordFinalSaveFailure(syncwords.UploadFailure(context.DeadlineExceeded)) + recordFinalSaveFailure(syncwords.UploadFailure(wireauth.ErrStorageOff)) + if !finishFinalSaving(context.Background()) { + t.Fatal("local save changed exit status") + } + raw, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(raw)) != "saved here; relay storage is off for this account, so it stays on this computer" { + t.Fatalf("receipt: %s", raw) + } +} diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index 8f56d4d5e2..18a6a09456 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -13,6 +13,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // driveCloseWithin bounds the quit path's last publish and lease release, so a @@ -95,6 +96,9 @@ func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), change if syncsetup.Quiet(home.Dir()) { return nil, syncsetup.ErrQuiet // a fleet of one has nothing to sync with yet } + if !s.StorageAllowed() { + return nil, wireauth.ErrStorageOff + } if line, first := s.FirstRun(); first { surfaceNotices.Say(line) } @@ -228,10 +232,32 @@ func chatSyncStats(ctx context.Context) (blobstore.Counts, error) { if err != nil { return blobstore.Counts{}, err } - if store, ok := s.Store.(interface { - Stats(context.Context) (blobstore.Counts, error) - }); ok { - return store.Stats(ctx) + if syncsetup.IsHostedRelay(s.Relay) && s.StorageAllowed() { + return syncsetup.StorageCounts(s.Home, s.Relay, s.Identity.ID()), nil + } + before := s.StorageAllowed() + counts, err := s.RefreshStorage(ctx) + if err == nil && !before && s.StorageAllowed() { + syncDrives.storageOpened() + } + if syncsetup.IsHostedRelay(s.Relay) && counts.Storage != "on" { + counts.Storage = "off" + } + return counts, err +} + +// storageOpened wakes both running batchers and local startup waits for open chats. +func (b *driveBook) storageOpened() { + if b == nil { + return + } + b.mu.Lock() + drives := make([]*liveDrive, 0, len(b.drive)) + for _, d := range b.drive { + drives = append(drives, d) + } + b.mu.Unlock() + for _, d := range drives { + d.storageOpened() } - return blobstore.Counts{}, nil } diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index 352c3304e9..1929f68e81 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -13,6 +13,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/syncsetup" "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" ) // liveDrive is the drive side of one chat, started when it can be. A chat opens @@ -30,17 +31,18 @@ type liveDrive struct { // transcript identifies the retained turns if this writer's final publish fails. transcript string - mu sync.Mutex - drive *syncsetup.Drive - waiting bool // local setup or a retryable failure still permits another attempt - ready chan struct{} - attempted bool - closed bool - retryAt time.Time - retryWait time.Duration - stopRetry func() bool - refusedSince time.Time - told error + mu sync.Mutex + drive *syncsetup.Drive + storageWaiting bool + waiting bool // local setup or a retryable failure still permits another attempt + ready chan struct{} + attempted bool + closed bool + retryAt time.Time + retryWait time.Duration + stopRetry func() bool + refusedSince time.Time + told error // changes coalesces local health updates for the host's cached surface state. changes chan struct{} } @@ -63,7 +65,7 @@ func newLiveDrive(start func(func()) (*syncsetup.Drive, error)) *liveDrive { func (l *liveDrive) try() { l.mu.Lock() defer l.mu.Unlock() - if l.closed || l.drive != nil || l.ready != nil || (l.attempted && !l.waiting) || (l.waiting && !syncsetup.MayTalk(home.Dir())) { + if l.closed || l.storageWaiting || l.drive != nil || l.ready != nil || (l.attempted && !l.waiting) || (l.waiting && !syncsetup.MayTalk(home.Dir())) { return } // Seals and idle notifications cannot shorten the relay's requested wait. @@ -116,7 +118,13 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) if wait, retry := r.StartupWait(l.retryWait, cellsync.DefaultInterval, err); retry { l.waiting = true l.retryWait, l.retryAt = wait, now.Add(wait) - l.stopRetry = l.after(wait, l.try) + if errors.Is(err, wireauth.ErrStorageOff) { + l.storageWaiting = true + cancel := syncsetup.WaitStorage(home.Dir(), l.storageOpened) + l.stopRetry = func() bool { cancel(); return true } + } else { + l.stopRetry = l.after(wait, l.try) + } } } } @@ -321,3 +329,21 @@ func (l *liveDrive) catchUpAfter(used *syncsetup.Drive) { l.drive.CatchUp() } } + +// storageOpened shortens only a capacity wait; it never replaces an active writer. +func (l *liveDrive) storageOpened() { + l.mu.Lock() + d := l.drive + l.storageWaiting = false + if l.stopRetry != nil { + l.stopRetry() + l.stopRetry = nil + } + l.retryAt = time.Time{} + l.mu.Unlock() + if d != nil { + d.StorageOpened() + } else { + l.try() + } +} diff --git a/cmd/codeaf/team_resume.go b/cmd/codeaf/team_resume.go index 12d71ea36f..5bf09605bd 100644 --- a/cmd/codeaf/team_resume.go +++ b/cmd/codeaf/team_resume.go @@ -92,4 +92,6 @@ func teamComputers(ctx context.Context, members []teams.Member) (map[string]stri } // teamPaired supplies the shared quiet gate to packages below relay setup. -func teamPaired() bool { return syncsetup.MayTalk(home.Dir()) } +func teamPaired() bool { + return syncsetup.MayTalk(home.Dir()) && syncsetup.StorageForHome(home.Dir()) == "on" +} diff --git a/internal/remote/client.go b/internal/remote/client.go index 399a42b6ef..8ad34d001b 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" "io" "os" "path/filepath" @@ -981,6 +982,9 @@ func (c *Client) deliver(frame Frame) { if frame.Error != "" { err := errors.New(frame.Error) if c.Welcome().LocalSavingReceipts && frame.SyncPending && frame.LocalSaved { + if frame.StorageOff { + err = errors.Join(wireauth.ErrStorageOff, err) + } err = syncwords.UploadFailure(err) } if frame.UnsavedTranscript != "" { diff --git a/internal/remote/server.go b/internal/remote/server.go index a94e8d0103..f9c5ead992 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -60,6 +60,7 @@ import ( "fmt" "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" "io" "path/filepath" "sort" @@ -2123,6 +2124,7 @@ func (s *server) dispatch(call Frame) { if err != nil { result.Payload, result.Error = nil, err.Error() result.SyncPending = result.LocalSaved && syncwords.UploadPending(err) + result.StorageOff = result.SyncPending && errors.Is(err, wireauth.ErrStorageOff) var unsaved *session.UnsavedChatError if errors.As(err, &unsaved) { result.UnsavedTranscript = unsaved.Transcript diff --git a/internal/remote/storage_optin_test.go b/internal/remote/storage_optin_test.go new file mode 100644 index 0000000000..70f5b4b43b --- /dev/null +++ b/internal/remote/storage_optin_test.go @@ -0,0 +1,25 @@ +package remote + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" + "testing" +) + +func TestStorageOptInRemoteSavingRetainsRefusal(t *testing.T) { + loop, err := Loopback(Hello{}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: &fakeAgent{}, SessionFile: "/chat/transcript.jsonl", FinishSaving: func(WrappedAgent) error { + return session.SavingError("/chat/transcript.jsonl", syncwords.UploadFailure(wireauth.ErrStorageOff)) + }}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + err = loop.Client.Agent().Close() + if !syncwords.UploadPending(err) || !errors.Is(err, wireauth.ErrStorageOff) || !loop.Client.Agent().LocalDurable() { + t.Fatalf("remote receipt lost storage refusal: %v", err) + } +} diff --git a/internal/remote/wire.go b/internal/remote/wire.go index cf9a3e2a33..8837261d6e 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -454,6 +454,7 @@ type Frame struct { LocalSaved bool `json:"localSaved,omitempty"` // SyncPending distinguishes an upload failure after the local close succeeded. SyncPending bool `json:"syncPending,omitempty"` + StorageOff bool `json:"storageOff,omitempty"` // UnsavedTranscript preserves a recovery location on a failed saving reply. UnsavedTranscript string `json:"unsavedTranscript,omitempty"` diff --git a/internal/tui3/app.go b/internal/tui3/app.go index f6d60927b7..282a6d192a 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -5949,7 +5949,14 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case syncSpaceMsg: // The reading is the identity's, not one chat's, so it is kept whichever // chat is on screen. It is only ever read by the next /status. + if msg.counts.Storage == "" { + msg.counts.Storage = a.syncSpace.Storage + } a.syncSpace = msg.counts + if a.at(pageSettings) { + a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() + a.sheet.build() + } return a, nil case spelledMsg: diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 368e723810..b568f7b318 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -173,6 +173,9 @@ func settingsBody(a *app, width, room int, sidebar bool) []placeRow { detailRoom := 0 if s.edit == nil && s.conn.entry == nil { detailRoom = min(6, max(0, room-3)) + if s.storageRequest { + detailRoom = min(len(settingsDetails(a, width))+1, max(0, room-3)) + } } room -= detailRoom body, owner := s.listLines(width, room, pal, a.hoveredSheetRow()) @@ -409,7 +412,7 @@ func settingsDetails(a *app, width int) []string { if a.sheet.onDevices() && !a.sheet.searching() { frameW, _ := a.size() lines := a.sheet.devicesDetails(frameW, width) - if len(lines) > 5 { + if len(lines) > 5 && !a.sheet.storageRequest { lines = lines[:5] lines[4] = fit(lines[4]+glyphMore, max(1, width-4)) } @@ -431,7 +434,7 @@ func settingsDetails(a *app, width int) []string { } } lines = append(lines, wrap(about, max(1, width-4))...) - if len(lines) > 5 { + if len(lines) > 5 && !a.sheet.storageRequest { lines = lines[:5] lines[4] = fit(lines[4]+glyphMore, max(1, width-4)) } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 836a6cc356..79313fe171 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1011,6 +1011,9 @@ type sheet struct { // hosts a [deviceCard] over the surface's own [Approvals] door and clock // (settings_devices.go). cellsUnavailable string + storageCode string + storageState string + storageRequest bool approvals Approvals dev *deviceCard now func() time.Time @@ -1336,6 +1339,7 @@ func (a *app) raiseSettings() { routing: a.routing, today: a.todayReading(), } + a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() // WHOSE ROWS, SAID FOR THE TAB ON SHOW. Over --host the note is @@ -2387,7 +2391,7 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { if s.onDevices() && !s.searching() && s.devicesUnavailable() == "" { if s.dev != nil && s.dev.loaded { s.syncDeviceCursor() - if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.unavailable { + if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.storage && !item.dev.unavailable { return a.revoke(s.dev), true } } @@ -2399,7 +2403,7 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { if s.onDevices() && !s.searching() && s.devicesUnavailable() == "" { if s.dev != nil && s.dev.loaded { s.syncDeviceCursor() - if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.unavailable { + if item, ok := s.current(); ok && item.dev != nil && !item.dev.add && !item.dev.storage && !item.dev.unavailable { s.dev.startNaming() } } @@ -2529,6 +2533,9 @@ func (a *app) activate() tea.Cmd { } s.syncDeviceCursor() } + if item.dev.storage { + return a.requestRelayStorage() + } return nil } if item.conn != nil { @@ -2897,6 +2904,7 @@ func (a *app) applySetting(item sheetItem, raw string) bool { } else { a.sheet.msg = "" } + a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() }) @@ -2906,6 +2914,7 @@ func (a *app) applySetting(item sheetItem, raw string) bool { } a.sheet.msg = note a.sheet.savedKey = item.row.Key + a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() return true @@ -4068,6 +4077,8 @@ func (s *sheet) keysLine() string { switch { case item.memoryDoor, item.crewDoor: action = "open" + case item.dev != nil && item.dev.storage: + action = "request access" case item.dev != nil && item.dev.add: action = "add" case item.dev != nil: diff --git a/internal/tui3/settings_devices.go b/internal/tui3/settings_devices.go index 3541ec4ffc..6f2b2007a7 100644 --- a/internal/tui3/settings_devices.go +++ b/internal/tui3/settings_devices.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/fuzzy" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // ── THE DEVICES TAB ON THE SETTINGS SHEET ─────────────────────────────────── @@ -38,6 +39,7 @@ const ( type deviceSheetRow struct { index int id string + storage bool add bool unavailable bool } @@ -82,21 +84,28 @@ func (s *sheet) devNow() time.Time { return timeNow() } -// buildDevices populates the Devices tab when no search query is active. When -// devices cannot work here, or while the initial directory read is still in -// flight or failed, no rows are emitted — [sheet.listLines] draws the single -// explanatory sentence instead, never an empty list and never a working-looking -// add row. +// buildDevices keeps the local storage request available while a directory +// read waits or fails; device actions still require a loaded device list. func (s *sheet) buildDevices() { - if s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + if s.devicesUnavailable() != "" { s.cursor = 0 return } + if s.dev == nil || !s.dev.loaded { + if s.storageCode != "" { + s.items = append(s.items, sheetItem{dev: &deviceSheetRow{storage: true}}) + } + s.cursor = s.clampCursor(s.cursor) + return + } for i, d := range s.dev.rowsOf { s.items = append(s.items, sheetItem{ dev: &deviceSheetRow{index: i, id: d.ID}, }) } + if s.storageCode != "" { + s.items = append(s.items, sheetItem{dev: &deviceSheetRow{storage: true}}) + } s.items = append(s.items, sheetItem{ dev: &deviceSheetRow{add: true}, }) @@ -126,7 +135,7 @@ func (s *sheet) syncDeviceCursor() { return } item, ok := s.current() - if !ok || item.dev == nil || item.dev.add || item.dev.unavailable { + if !ok || item.dev == nil || item.dev.add || item.dev.storage || item.dev.unavailable { s.dev.cursor = len(s.dev.rowsOf) return } @@ -259,6 +268,12 @@ func (s *sheet) appendDevicesSearch(terms []fuzzy.Term) int { }) } } + if s.storageCode != "" { + if score, ok, at, size := s.matchHits([]string{"relay storage", "request access", tabDevices}, terms); ok { + best = max(best, score) + matched = append(matched, devHit{item: sheetItem{dev: &deviceSheetRow{storage: true}, hitAt: at, hitLen: size}, score: score}) + } + } if score, ok, hitAt, hitLen := s.matchHits(addDeviceSearchWords, terms); ok { best = max(best, score) matched = append(matched, devHit{ @@ -285,6 +300,9 @@ func (s *sheet) devRowLines(row *deviceSheetRow, hit []int, selected, hovered bo if row.unavailable { return overlayLines(s.devicesUnavailable(), "", selected, false, hovered, width, pal) } + if row.storage { + return overlayLinesHit("relay storage", s.storageState, hit, selected, false, hovered, width, pal) + } if row.add { return overlayLinesHit(addDeviceWord, "", hit, selected, false, hovered, width, pal) } @@ -339,12 +357,22 @@ func (s *sheet) devicesNote(pal palette, width int) ([]string, bool) { // foot rule (or a concurrent refresh error standing alongside a completed // removal, so neither fact erases the other). func (s *sheet) devicesDetails(frameWidth, bodyWidth int) []string { - if !s.onDevices() || s.searching() || s.devicesUnavailable() != "" || s.dev == nil || !s.dev.loaded { + if !s.onDevices() || s.searching() || s.devicesUnavailable() != "" { return nil } ruleRoom := max(1, frameWidth-placeNoteRuleFrame) wrapRoom := max(1, bodyWidth-4) var lines []string + if s.storageRequest && s.storageCode != "" { + lines = append(lines, wrap("request code "+s.storageCode, wrapRoom)...) + target := syncsetup.StorageRequestLink(s.storageCode) + for _, line := range wrap(target, wrapRoom) { + lines = append(lines, linkify(line, target)) + } + } + if s.dev == nil { + return lines + } if s.dev.line != "" && ansi.StringWidth(s.dev.line) > ruleRoom { lines = append(lines, wrap(s.dev.line, wrapRoom)...) } @@ -376,7 +404,11 @@ func (s *sheet) devicesKeysLine() string { out = append(out, devicesMoveKey) } if item, ok := s.current(); ok && item.dev != nil && !item.dev.unavailable { - if item.dev.add { + if item.dev.storage { + if s.storageState == "off" { + out = append(out, "enter request access") + } + } else if item.dev.add { out = append(out, devicesAddKey) } else { out = append(out, devicesNameKey) @@ -390,3 +422,25 @@ func (s *sheet) devicesKeysLine() string { out = append(out, "←→ categories", devicesCloseKey) return strings.Join(out, " · ") } + +func (a *app) relayStorageAccount() (string, string) { + if a.cellsUnavailable != "" { + return "", "" + } + if door, ok := a.approvals.(interface{ RelayStorage() (string, string) }); ok { + return door.RelayStorage() + } + return "", "" +} + +func (a *app) requestRelayStorage() tea.Cmd { + if a.sheet.storageCode == "" || a.sheet.storageState != "off" { + return nil + } + a.sheet.storageRequest = true + link := syncsetup.StorageRequestLink(a.sheet.storageCode) + open := processOpener + return func() tea.Msg { _ = open(link); return storageRequestOpenedMsg{} } +} + +type storageRequestOpenedMsg struct{} diff --git a/internal/tui3/statusnote.go b/internal/tui3/statusnote.go index 0c67acb818..c45efc664e 100644 --- a/internal/tui3/statusnote.go +++ b/internal/tui3/statusnote.go @@ -117,7 +117,9 @@ func (a *app) statusItems() []deckItem { } items = append(items, item) } - if word := a.syncSpaceWord(); word != "" { + if a.relayStorageOff() { + items = append(items, deckItem{label: "relay storage", value: "off"}) + } else if word := a.syncSpaceWord(); word != "" { items = append(items, deckItem{label: "sync space", value: word}) } // phone lane: `keeping watch` used to be added HERE and nowhere else, which @@ -206,6 +208,9 @@ func (a *app) statusFacts(text string) []string { facts := columnFacts(text, false) crew := a.crewWord() space := a.syncSpaceWord() + if a.relayStorageOff() { + space = "off" + } if crew == "" && space == "" { return facts } diff --git a/internal/tui3/storage_optin_test.go b/internal/tui3/storage_optin_test.go new file mode 100644 index 0000000000..59f360d9b8 --- /dev/null +++ b/internal/tui3/storage_optin_test.go @@ -0,0 +1,140 @@ +package tui3 + +import ( + tea "charm.land/bubbletea/v2" + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "net/url" + "strings" + "testing" + "time" +) + +type storageApprovals struct { + *fakeApprovals + code, state string +} + +func (d *storageApprovals) RelayStorage() (string, string) { return d.code, d.state } + +func TestStorageOptInDevicesRequestAndStatus(t *testing.T) { + code := "id_" + strings.Repeat("a", 32) + door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} + a, r := openSettingsDevices(t, door, time.Now()) + a.syncService = syncsetup.ProductionRelayURL + found := false + for at, item := range a.sheet.items { + if item.dev != nil && item.dev.storage { + a.sheet.cursor = at + found = true + break + } + } + if !found || !strings.Contains(a.sheet.devicesKeysLine(), "enter request access") { + t.Fatalf("missing storage request row: %s", plain(frame(a))) + } + before := processOpener + var opened string + processOpener = func(link string) error { opened = link; return errors.New("no browser in test") } + t.Cleanup(func() { processOpener = before }) + r.press("enter") + r.until("storage request opener", func() bool { return opened != "" }) + parsed, err := url.Parse(opened) + if err != nil || !strings.HasPrefix(opened, syncsetup.StorageRequestURL+"?") || !strings.Contains(parsed.Query().Get("title"), code) { + t.Fatalf("request link: %s %v", opened, err) + } + details := plain(frame(a)) + if !strings.Contains(details, code) || !strings.Contains(strings.Join(strings.Fields(details), ""), syncsetup.StorageRequestLink(code)) { + t.Fatalf("no-browser instructions: %s", details) + } + a.syncSpace = blobstore.Counts{Storage: "off"} + text := a.statusText() + if !strings.Contains(text, "relay storage") || !strings.Contains(text, "off") || strings.Contains(text, "sync space") { + t.Fatalf("off status: %s", text) + } + for _, fact := range a.statusFacts(text) { + if fact == "off" { + t.Fatal("off value is bright") + } + } + door.state = "on" + a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() + if strings.Contains(a.sheet.devicesKeysLine(), "request access") { + t.Fatal("enabled storage still asks for access") + } + a.syncSpace = blobstore.Counts{Storage: "on"} + if strings.Contains(a.statusText(), "relay storage") { + t.Fatal("enabled status has off line") + } +} + +func (d *storageApprovals) SyncService() string { return syncsetup.ProductionRelayURL } + +func TestStorageOptInStatsAfterPairingWithoutRestart(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + door := &storageApprovals{fakeApprovals: devicesFake(), state: "off"} + a.approvals = door + calls := 0 + a.syncStats = func(context.Context) (blobstore.Counts, error) { calls++; return blobstore.Counts{Storage: "on"}, nil } + if cmd := a.refreshSyncSpace(); cmd != nil || calls != 0 { + t.Fatal("unpaired surface asked for storage") + } + door.code = "id_" + strings.Repeat("a", 32) + cmd := a.refreshSyncSpace() + if cmd == nil || calls != 0 { + t.Fatal("pairing did not enable the background refresh") + } + a.Update(cmd()) + if calls != 1 || a.syncSpace.Storage != "on" { + t.Fatal("approval not seen without restart") + } +} + +func TestStorageOptInSearchRequestShowsFallbackFrame(t *testing.T) { + code := "id_" + strings.Repeat("b", 32) + door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} + a, r := openSettingsDevices(t, door, time.Now()) + a.syncService = syncsetup.ProductionRelayURL + old := processOpener + processOpener = func(string) error { return errors.New("no browser") } + t.Cleanup(func() { processOpener = old }) + r.press("ctrl+u") + r.typeText("relay storage") + for i, item := range a.sheet.items { + if item.dev != nil && item.dev.storage { + a.sheet.cursor = i + break + } + } + r.press("enter") + shown := plain(frame(a)) + if a.sheet.searching() || !a.sheet.onDevices() || !strings.Contains(shown, code) || !strings.Contains(strings.Join(strings.Fields(shown), ""), syncsetup.StorageRequestLink(code)) { + t.Fatalf("search fallback frame: %s", shown) + } +} + +func TestStorageOptInRequestFallbackNarrowPendingDevicesFrame(t *testing.T) { + code := "id_" + strings.Repeat("c", 32) + door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} + a, r := openSettingsDevices(t, door, time.Now()) + a.syncService = syncsetup.ProductionRelayURL + a.Update(tea.WindowSizeMsg{Width: 56, Height: 40}) + for i, item := range a.sheet.items { + if item.dev != nil && item.dev.storage { + a.sheet.cursor = i + break + } + } + before := processOpener + processOpener = func(string) error { return errors.New("browser missing") } + defer func() { processOpener = before }() + // A device-list failure must not hide the locally available request. + a.sheet.dev.readErr = errors.New("device list unavailable") + r.press("enter") + shown := strings.Join(strings.Fields(plain(frame(a))), "") + if !strings.Contains(shown, code) || !strings.Contains(shown, syncsetup.StorageRequestLink(code)) { + t.Fatalf("narrow request frame: %s", plain(frame(a))) + } +} diff --git a/internal/tui3/syncspace.go b/internal/tui3/syncspace.go index 96994dd9a6..831ea467bf 100644 --- a/internal/tui3/syncspace.go +++ b/internal/tui3/syncspace.go @@ -8,6 +8,7 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // syncSpaceWithin keeps an optional relay reading from outliving its usefulness. @@ -33,6 +34,14 @@ func (a *app) runStatusCommand(rest string) tea.Cmd { // relay can report it. It is a command, so it never runs on the update loop or // while a frame is painted. func (a *app) refreshSyncSpace() tea.Cmd { + // Pairing can happen after this surface opened with no sync service. + if a.syncService == "" { + if code, _ := a.relayStorageAccount(); code != "" { + if door, ok := a.approvals.(interface{ SyncService() string }); ok { + a.syncService = door.SyncService() + } + } + } if a.syncService == "" || a.syncService == "off" || a.syncStats == nil { a.syncSpace = blobstore.Counts{} return nil @@ -61,8 +70,20 @@ func (a *app) showStatus(json bool) { // syncSpaceWord reads only the last answer, so rendering cannot contact a relay. func (a *app) syncSpaceWord() string { used, limit := a.syncSpace.StoredBytes, a.syncSpace.LimitBytes - if a.syncService == "" || a.syncService == "off" || used == nil || limit == nil || *used <= 0 || *limit <= 0 { + if a.syncService == "" || a.syncService == "off" || a.relayStorageOff() || used == nil || limit == nil || *used <= 0 || *limit <= 0 { return "" } return chatlist.BytesSize(*used) + " of " + chatlist.BytesSize(*limit) } + +// Failed readings retain only the previous permission, never stale usage totals. +func (a *app) relayStorageOff() bool { + if !syncsetup.IsHostedRelay(a.syncService) { + return false + } + if a.syncSpace.Storage != "" { + return a.syncSpace.Storage == "off" + } + _, state := a.relayStorageAccount() + return state == "off" +} From 70ca37bcd3b338590e8bdc4ef92384863ab67f10 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 21:07:43 -0500 Subject: [PATCH 04/18] Explain that hosted relay storage is off until an account is approved Every manual section that promised chats, keys, teams or task copies travel between paired computers automatically now says that on the hosted relay this waits for storage approval, and a new section answers how to request it. The system prompt no longer promises automatic hosted sync, and the relay README documents the operator approval and handoff routes. Co-Authored-By: Claude Opus 5.5 --- .../chat/a-dev-box-on-your-own-railway.md | 6 ++ .../manual/chat/add-or-remove-a-computer.md | 28 ++++--- internal/manual/chat/commands.md | 8 +- .../continuing-a-chat-on-another-computer.md | 82 +++++++++++++++---- .../manual/chat/conversations-and-teams.md | 14 +++- .../manual/chat/coordination-and-workers.md | 2 +- internal/manual/chat/getting-started.md | 4 +- internal/manual/chat/home.md | 22 ++++- internal/manual/chat/how-tasks-run.md | 2 + internal/manual/chat/keys.md | 2 +- .../manual/chat/lock-out-a-lost-computer.md | 22 +++-- internal/manual/chat/losing-every-device.md | 6 +- internal/manual/chat/models-and-cost.md | 2 + internal/manual/chat/naming-a-device.md | 2 +- .../chat/reaching-this-machine-without-ssh.md | 2 + .../manual/chat/relay-hosted-or-your-own.md | 16 ++-- .../manual/chat/relay-limits-and-refusals.md | 15 ++-- .../chat/relay-what-it-stores-and-sees.md | 24 ++++-- .../manual/chat/running-from-the-terminal.md | 14 +++- .../manual/chat/running-on-another-machine.md | 4 +- internal/manual/chat/settings.md | 6 +- internal/manual/chat/starting-codeaf.md | 4 + .../manual/chat/tasks-on-another-computer.md | 8 ++ internal/manual/chat/team-manager.md | 4 + .../chat/use-this-on-another-computer.md | 48 +++++------ internal/manual/chat/what-i-remember.md | 4 + internal/manual/chat_test.go | 40 +++++++++ internal/session/prompts/system.md | 2 + relay/hosted/README.md | 25 ++++++ 29 files changed, 319 insertions(+), 99 deletions(-) diff --git a/internal/manual/chat/a-dev-box-on-your-own-railway.md b/internal/manual/chat/a-dev-box-on-your-own-railway.md index 1c589fea71..035aa947af 100644 --- a/internal/manual/chat/a-dev-box-on-your-own-railway.md +++ b/internal/manual/chat/a-dev-box-on-your-own-railway.md @@ -2,6 +2,8 @@ ## Can I run codeaf on my own server or an always-on computer in the cloud? +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + Yes. `codeaf devbox up ` creates an always-on computer on **your own Railway account** and pairs it into your identity. You pay Railway for its compute and persistent volume. Your laptop can sleep while the box keeps running. @@ -60,6 +62,8 @@ removal of the one-time setup values. Then open `codeaf chat --at build-box`. ## How saved provider keys, settings and shared memories reach the box +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + The box starts `codeaf serve` with `CODEAF_SERVE_SYNC=1`, so it pulls the identity's saved provider keys, settings and shared memories at startup and every five seconds by default, the same interval an open terminal uses; without that variable `codeaf serve` @@ -150,6 +154,8 @@ to admit another computer yet; use the creating laptop. ## Send a task to my dev box +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + A dev box also takes tasks. It starts `codeaf serve --take-tasks` whenever its installed build lists that option, so a chat on your laptop can run one task on the box while the conversation and transcript stay on the laptop: diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index a6d2aa9c7e..f80676d246 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -2,7 +2,7 @@ ## How do I add another computer — the Add another device card (add another machine), codeaf pair, a link to paste, and pairing a server with no browser -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. The original files stay untouched and remain usable in older codeaf builds. @@ -39,6 +39,8 @@ The card goes away once you have two devices. After that, `/pair` or Settings ## Do older chats move after pairing? +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. ## Pairing a server with no browser — the link, token and check number @@ -68,13 +70,13 @@ The approving device says `desk-b is now paired. Your chats can continue there.` ## What does codeaf pair --code do -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. `codeaf pair --code` shows a code such as `04-063-213` on this computer. On the other computer, run `codeaf pair ` to share this computer's chats. Compare the three words and approve on the computer showing the code. The page *Pairing your chats with a second computer* explains each step. ## The pair words — approve, --code and --via -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. ``` usage: codeaf pair [--name ] | codeaf pair approve | codeaf pair [--replace] [--name ] | codeaf pair --code @@ -91,7 +93,7 @@ A link is good for 10 minutes and one approval. If it ran out, run `codeaf pair` ## The four-digit check — how I know the right computer is asking -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. The new device shows a check number of four digits. The device that approves shows the same four digits. Look at both screens. Approve only when they match. If they do not match, decline: somebody else may have used the link. @@ -99,7 +101,7 @@ On the approving screen the number reads `Check number 4821. Approve only if the ## Approve a new device — the approve screen, a and d, and decline -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. When you open a link on a device that is already in (paste it, or type `/pair `), the screen says who is asking: @@ -129,7 +131,7 @@ Every other device that is on says it too, a moment later, from the same message ## What does the dot next to a computer mean — the devices row, online and offline -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. When you have two or more devices, home shows one row of them: @@ -143,7 +145,7 @@ Under the row, `alt+m bring work here` brings the newest chat of an online devic ## Does the other computer appear on the device list straight after pairing — a just-paired device, devices empty after pairing -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Yes, on both sides. When a pairing finishes — a code such as `04-063-213` on both screens, a link typed into `codeaf pair approve`, either end of it — the computer it finished on writes @@ -154,7 +156,7 @@ says one line about it, `paired; this computer will appear on your other devices ## Which devices do I have — /devices, how to remove or revoke one, a device that was revoked, and I was told this machine was removed -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Type `/devices` to list them: @@ -183,6 +185,8 @@ The list is in a fixed order: this device first, then devices that are online, t ## Devices in /settings — where your computers are on the settings page, add another device, rename this one, or remove (revoke) one from settings +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Open `/settings` (or `ctrl+,`) and walk with `←` and `→` to `Devices`, between `Connections` and `Privacy` — or type `devices`, `remove`, `revoke`, `pair` or `add device` in settings search. While loading, the tab says `looking up your devices…`. Loaded, it begins with the same dim `sync service: 127.0.0.1:8901 (your own)` or `sync service: codeaf.agentfield.ai (hosted)` line as `/devices` and the terminal. It lists this computer even before pairing, with the same platform and presence columns and in the same order and words as `/devices` — `●` online, `○` away (`seen 3h ago`, or `offline`), `this device`, and `removed` — followed by `Add another device`. The open tab refreshes on its own when a computer joins, renames itself or is removed. @@ -203,7 +207,7 @@ After a successful removal, its confirmation stays visible even if refreshing th ## I was told this device was removed — how to bring it back -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Home shows `removed by another of your devices — pair again to sync` on its own dim line with the Add another device card. The line remains visible when this connection cannot offer that card. The footer keeps only key hints. `/pair` repeats that notice, then says `on a device still in your devices, open /pair; enter its code here to pair again` and shows `Code from another device`. Enter the other device’s code and confirm pairing. It cannot offer a code from the removed membership or approve another device’s link. If this computer was removed while its window was closed, the explanation appears on Home on the next launch too. The launch check and the live check share one notice, so learning it both ways does not repeat it. @@ -215,6 +219,8 @@ this device was removed by another of your devices, so this chat stays here only ## Can a removed computer rejoin — /devices, pairing again and retained local chats +Pairing again restores device access. Sending new chats, keys or shared state through the hosted relay also requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + `/devices` on a removed computer says the same line in place of the list. A terminal refusal uses `this device was removed by another of your devices, so this chat stays here only — run codeaf pair to bring it back`. Home says `removed by another of your devices — pair again to sync`. The terminal command `codeaf devices` gives its removal explanation once, without following it with `nothing is paired yet`. It appears the moment the other device removes this one, even if codeaf has been open for days, in the open chat and on the home screen alike, and once only. Your chats on this device are safe and stay on it. They no longer follow your other devices. To bring the device back, run `codeaf pair` here, then approve the link it shows from a device that is still in (the steps above). If this computer has chats with submitted turns, run `codeaf pair --replace` instead. Its existing chats stay on this computer but no longer sync; shared memories and settings are cleared. Approve the new link from a computer still in your devices. A computer that has only opened codeaf, even with a provider connected, and whose chats contain only startup headers needs no `--replace`; saved knowledge, automations, tracked work and unknown local files still require the explicit replacement road. An active, already-paired computer is refused with the names of its other devices; `codeaf devices` lists them. ## Pair again in the chat — replace confirmation keeps local chats @@ -227,7 +233,7 @@ chats no longer sync under the replaced membership. ## The link did not work — a pairing link or token that ran out, was declined or was answered already -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. These are the sentences a link pairing says when it stops, in the words the screen uses. Nothing was changed on either device when you read one of them. @@ -244,6 +250,8 @@ Too many requests from one network are refused too: at most 10 new links in an h ## I quit on my laptop and opened my desktop after pairing +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + After a clean quit and final durable save, bare `codeaf` on another paired computer within 24 hours opens the released chat automatically. A sleeping or disconnected laptop still needs `alt+c` on Home. Local unsaved edits, another open local window, a named chat, `--host`, `--at` or `--session` prevent automatic continuation. A running chat never moves by itself. The arrival retains its `Moved from` banner and `not brought along:` facts; `esc` goes Home. Personal memory and stored model, display, approval and limit defaults follow the paired computers too. Limits are shared as values; spending counted against them stays per computer. Automations and machine-specific paths and settings stay local. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 0fafe3b58b..e97130eb1a 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -29,7 +29,7 @@ restores the ordinary prompt. Combined stdout/stderr appears as plain text while the command runs, preserving line breaks and indentation. Wide lines wrap; terminal control sequences are removed for safe display. The command and output remain in history after reopening. The model waits for your next message before responding. -In a chat that can move between computers, file changes from `!` are included in +On the hosted relay, sharing captured file changes requires relay storage approval in Settings → Devices → relay storage; a self-hosted relay with `--store` needs none. In a chat that can move between computers, file changes from `!` are included in its next saved turn, just like changes from the model's shell commands. A failed command keeps its output and exit status too. Shell paths, `@` names and slash words remain literal, not mentions or send tags. @@ -335,6 +335,8 @@ command). ## Pairing commands — /pair, /pair , /sync, /link and /laptop +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + | Command | Aliases | Argument | Effect | |---|---|---|---| | `/pair` | `/sync`, `/link`, `/laptop` | — | shows a code like `42-715-302`, valid 10 minutes, that gives your chats to another computer, then asks `y / n` when that computer types it | @@ -1958,7 +1960,7 @@ the Memories tab. Tasks controls delegated work; AI teams sets defaults for groups of AI chats. Permissions controls approvals. Spending holds limits. Connections holds accounts, keys, web search and remote connection settings. Devices lists your paired computers, renames this one, removes another, and starts -`/pair` from `Add another device`. +`/pair` from `Add another device`. A paired hosted account also has `relay storage`: when off, Enter opens a request and shows its code and link, including from Settings search. Self-hosted storage needs no request. Privacy holds history, drafts and data-sharing choices. Old saved keys and values are unchanged. Search accepts old labels and keys, @@ -2294,4 +2296,4 @@ nothing was left behind, `/setup` says so and does nothing. `/setup now` starts a **setup turn**: the agent installs the installable items and brings back what a move left behind, into user space. A new chat's commands already have the network and this computer's ordinary access; `/setup` is not needed to open the network or to install outside the workspace. Each setup call asks first unless approvals are open (`--yolo` or the allow posture), or the arrival card already approved that exact listed command. The setup approval floor still applies in a host-bound chat. Calls are recorded as external and the turn says `Setup` in `codeaf cell log`. -Limits: a setup turn is never replayed on another machine (the agent does it again there), never attempts impossible items, and can be rewound like any turn. When it ends, codeaf looks at what is now on this machine and updates the record. Existing cells keep their declared class: an older sandboxed cell still has its folder limits and opens its network for the setup turn only. Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. A compatible furrow engine is required. Without a cell there is nothing to set up. +Limits: a setup turn is never replayed on another machine (the agent does it again there), never attempts impossible items, and can be rewound like any turn. When it ends, codeaf looks at what is now on this machine and updates the record. Existing cells keep their declared class: an older sandboxed cell still has its folder limits and opens its network for the setup turn only. Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. A compatible furrow engine is required. Without a cell there is nothing to set up. diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index 31e3a21c35..3024b2ff8a 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -4,7 +4,7 @@ To continue on this machine, select the chat on home and choose `continue here`. ## Continue on another computer — continuing a chat on another computer, why can't I type in this chat, my message did not send and home opened with a question -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -25,10 +25,14 @@ If you continue it, the other device's window stops taking new turns and says `< ## Where an older chat's copy lives — the .moving folder, README.txt, can I delete it +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + An older chat's copy is kept in a folder called `.moving` beside the original, in codeaf's state folder for that project. The name is historical: nothing in it is a move in progress. It holds the live copies this build writes to, so deleting it deletes those chats. The folder carries a `README.txt` that says so. The originals beside it stay untouched for older codeaf builds. ## My typed message after continue here — is it kept, does it send by itself +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + before showing this question for a send, codeaf checks which computer holds the chat now. an older listing alone cannot raise it. if that check fails, nothing is sent and the whole message stays in the box, including pasted text and attachments. the chat says ` has not answered; your message is still in the box, press enter to send it`. press enter to check again. changing the draft while a check waits keeps the current words unsent; the chat says `draft changed before sending; press enter to send your current words`. a chat this window already holds, or one that has never been shared, sends without asking the service. an ordinary check for a shared copy waits up to five seconds. immediately after this window's own successful move, a message can wait up to 30 seconds for the chat to finish opening, then sends once. a window that lost the chat, or another window holding it, cannot give this window permission to send. @@ -37,22 +41,28 @@ if Enter raised the continue card, a successful move restores those same words, ## which draft comes back after continuing a chat +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + a move opens one conversation for that chat. sending clears the words from its saved drafts, including matching copies left by an earlier window. taking the chat back, reopening it or restarting codeaf then shows an empty box unless you typed a new draft. taking a chat from Home restores that chat's own saved draft, or leaves its box empty. words typed for a different chat stay with that chat, even when both work in one place on disk. a draft held behind a send's continuation card stays with the chat that raised it. continuing a chat this window already holds keeps its open conversation and typed words. ## Read a finished task's worker conversation after moving its chat +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Open `/task` and choose the finished task. Its saved worker conversation and steps remain readable with its outcome after moving the chat, even when the original working copy is no longer available. A finished task offers no restart action; typing `r` or `R` writes those letters in the box. ## What does leave it there do -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. On `Continue this chat here?`, `leave it there` closes the question without moving the chat. Your typed words stay in the box and nothing is sent. If the question opened because you tried to send from an older window, this answer is already selected. Choose `1 continue here` to bring the chat here before sending. ## Esc on a continue card returns to my chat, and each card keeps its own draft +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + If Enter in a displaced chat raises the continue card, `esc` or `2 leave it there` returns to that same chat window with its full draft still in the box. If you opened the card from a Home row, leaving the card keeps you on Home. during an automatic continue, the first `esc` withdraws its opening and any waiting choice. the automatic chat is not opened: it finishes arriving and is let go, so it stays on this computer, free to continue from anywhere. a further `esc` navigates as usual while that finishes. if the abandoned move has not answered after two minutes, its request is cancelled before the waiting choice can begin. inspecting another chat's card and choosing `leave it there` or `esc` leaves the automatic arrival alone. @@ -67,6 +77,8 @@ these are Home's listing sentences. a message left unsent by a failed send check ## Why does continue here say this chat was last held here — my only computer asks to continue +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + A chat this computer held last, whose hold expired with no other computer holding it, opens without a question: there is nothing to continue from. Enter sends, and turns this computer saved while it could not reach your other devices are sent to them when the chat opens. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. The card still says `this chat was last held here; last saved turn 2h ago` when this window's own earlier move failed. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. @@ -75,7 +87,7 @@ If unpublished work needs to be kept separately, the number of kept turns counts ## What does held on tower mean — a row on home and the verb it offers -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Home lists chats from your other devices. A chat another device still holds says `held on `. The hold alone does not establish that work is running there. One that a device let go of, or that went off because its lid closed, says ` offline`. @@ -83,7 +95,7 @@ Press enter on either row to continue it here. The verb on the card depends on t ## Continue on this machine — Continue where you left off, alt+c and alt+x -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. If you open codeaf and a chat on another device was running or went off in the last 24 hours, and that device is offline now (its lid is closed) or has let go of the chat, home offers it first of all: @@ -104,7 +116,7 @@ To bring work from a device that is online, use the devices row: `alt+m bring wo ## What happens when the chat arrives — the banner Moved from tower, and what was not brought -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. When the chat opens on this device it starts with one line that says how long the move took: @@ -130,6 +142,8 @@ got it ## What was not brought along — folders, commands and tests on the arrival card +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + When a chat arrives on a paired computer, its arrival card can include these lines: Each line is left out when it has nothing to say. @@ -146,13 +160,13 @@ Typing while either arrival card is up goes into the message box, including the ## Does a moved chat have the network — installs and access on this computer -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. A new chat is host-bound and keeps that class when moved. Its commands run on the computer that now holds it, with the network and the same access as any program you run there. Moving chats does not restrict commands to their workspace. `/setup` prepares missing tools and folders after a move; it does not enable ordinary network access. Existing cells declared sandboxed by an earlier build keep their declared class when moved or reopened. ## What folder does the moved chat work in — old absolute paths in earlier turns -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. A moved copy works in the workspace it arrived in. Before its next model request, the agent gets a session note naming the device it came from, the old folder and the new folder. Paths in earlier turns under the old folder now name the same relative paths under this workspace. The note is context for the agent, not a message you typed, and remains available after reopening or compacting. @@ -162,7 +176,7 @@ Taking a chat back into its original project restores that project in place. The ## Why does the project label say copy from another machine -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. A chat arriving here for the first time works in its own copy. It is filed under the folder it came from, apart from conversations in this computer's own folder of the same name. Home and the keys-row label say `proj-a (copy from another machine)`. This remains a copy even if a folder with the original path happens to exist on this computer. Taking back a chat that already works in your own project restores that project in place. An unknown origin is left unnamed. @@ -172,6 +186,8 @@ name. ## This chat is not syncing — the sync service is not answering, working offline +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + If this computer holds the chat and renewals have failed for longer than one renewal period, one dim line says `the sync service at 127.0.0.1:8901 is not answering; this chat keeps working here and catches up when it is back`. @@ -184,11 +200,13 @@ only shows it` notice still applies. ## Reopen a kept copy after another computer takes the chat +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + When unsent turns were kept as a separate copy, reopening that copy checks who holds the copy itself. Another computer may still hold the original chat. The previous window finishes saving and stops syncing before the reopened copy starts its own sync, so a late save cannot change which copy it opens. Taking that copy elsewhere still stops new tool work in its older window. ## Continue here does nothing — continue here did nothing, the engine store is busy, or a chat is already continuing here -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. while a chat you explicitly chose is moving here, the card says `continuing here…`. another explicit continue during that move says `a chat is already continuing here; wait for it to open` on the card. an automatic startup move instead lets your explicit choice go next; see “my choice while an automatic chat is arriving”. @@ -196,6 +214,8 @@ every failure stays where you are looking: on Home's card with `1 retry` and `2 ## why continue here failed — retry or leave it there +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry confirms the holder and turn of ownership now displayed on that card. @@ -210,7 +230,7 @@ a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry ## taking a chat back, and two computers continuing the same chat — which one opens -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. A chat has one holder at a time. To take a chat back from the computer that took it, continue it there the same way: press enter on its row on that computer and choose `continue here`. Nothing has to be returned or switched off first. A computer with the chat open when another takes it says ` continued this chat; this window now only shows it`, stops taking new turns, and keeps what it had not yet sent as a branch (` turns from : open separately / discard`). A tool call the model tries there answers with that same line. Taking the chat back leaves a `node_modules` or other install folder that computer already has alone. @@ -245,7 +265,7 @@ need another upload before the move. ## I reopened an old local chat after the other computer let go — where are the newer turns -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Reopening an older copy does not replace newer history on your other devices, even if the other computer has closed the chat. This window only shows its local copy until you choose `continue here`. Submitting a message opens that question and keeps your words in the box. Continuing brings the newer history here before writing can resume. @@ -259,7 +279,7 @@ Turns already shared do not become kept edits. Usage, reconciled task cost, nami ## The chat is arriving on this computer — why it waits to open -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. While a chat is moving onto this computer, pressing enter on its row says `this chat is arriving on this computer; it opens by itself in a moment`. Nothing is wrong and nothing needs doing: the move takes a second or two, and the chat opens by itself when it ends, with the other computer's latest turns in it. Opening it earlier is refused on purpose, because a window opened halfway would show the chat as it was before the move and could not save your turns. @@ -267,19 +287,21 @@ When the holder is off (its lid is shut), the row reads ` offline` and y ## Can the chat on the other computer still open the job logs it mentions — logs, saved tool output, task journals and the task history after a move -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Yes. A chat's messages name files that sit beside the chat and not in your project: the log of a background job (`log at …/logs/jobs/2.log`), the full output of a tool result that was cut short (`full: logs/stubs/9c2f.txt`), and the journals of its tasks. They move with the chat. The other computer puts the logs and saved output back in the chat's own folder, so the same names open there. The task journals (each task's transcript, trajectory and saved output) are part of the chat itself, so they arrive whole inside it at the paths the chat reads them from. The first message it receives after the move says where they are now (`… that earlier messages name under were carried: they are under here`). A path in an old message still shows the old folder; use the new one. ## Job log and saved tool output limits after a move — task journals stay whole +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Three limits keep a move light, and they apply to job logs and saved tool output only. A single such file over 1 MiB stays behind, and so does whatever does not fit in 16 MiB together (the oldest go first). A task journal has no size limit and always moves whole. A file that looks like it holds a secret (a key, a token) stays behind too. Each one is named under `not brought along:` with the reason and `stayed on the machine that made it`; read it there by asking that machine, and never assume the other computer has it. Two things are made again on the other computer and are not carried: the project's task history (the list of finished tasks home and the tasks tool show), which is rebuilt from the chat's own record, and the git bookkeeping inside a task's working copy. The program's own files inside a task's working copy (the `.codeaf` folder, where a task's job logs sit) are not carried either, because they are not your work: they never show up on the other computer as a change you did not make. A file you made in the working copy does move. A task that finished on a kept branch keeps that branch in your project on the other computer, at the same commit. ## What moves with a chat — is my git history sent, branches, secrets in my repo -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Your repository's `.git`, including its history, branches and settings, moves with the chat to your own paired devices. A task's commits travel too, and its working-copy bookkeeping is rebuilt on arrival. A key in an old commit remains in that history: codeaf does not remove or rewrite it. Git history travels sealed end to end; the sync service sees only ciphertext, including when the history contains keys. @@ -289,16 +311,20 @@ Before each seal, codeaf asks which files will be captured and screens those can ## Why does home appear while my chats are still syncing -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. The first screen draws while this computer publishes its device record, shares its saved keys and sends the first saved turn in the background. A slow sync service does not leave the terminal blank. New tool work waits for the initial ownership check; local startup saves are sent once that check finishes. ## Does every chat copy my whole project — disk space, shared saved files, older chats +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + New chats on this computer share one store of saved file contents. Opening four chats in the same project does not make four private copies of every unchanged file. Each chat keeps its own history, call log and transport scratch; restoring one does not move another chat's saved head. A chat whose older private store already exists keeps using that store in place, without migration. Nothing is deleted automatically. `codeaf cell gc` is an explicit command and removes only files the saved copy can restore. ## Why did dist or node_modules not move — gitignored files, build output, ignored folders +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + Every chat's capture follows Git's ignore rules. Untracked ignored output stays on the computer that made it: it is neither stored in the local file history nor uploaded. Tracked files travel even if an ignore rule matches their name. A wholly ignored directory is not walked or screened. Lockfile rebuild folders still follow the existing rebuild rules. The arrival card names omitted paths from the saved summary, for example `not brought along: dist/ (git ignores them), node_modules/ (git ignores them)`. The summary is bounded and can name only some omitted groups. Ignored secret files in ordinary directories are still screened and travel through the key vault; an ignored `.env` inside a wholly ignored directory stays there with that directory. @@ -307,18 +333,22 @@ If Git cannot read the project's ignore rules, saving stops with `cannot read wh ## Does saving slow my command — pending save, quit while saving, moving before the save finishes +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + A command's result returns after its call log is written, while its file capture finishes in the background. The next tool call waits until the previous capture has finished reading files. Continuing the chat on this computer, uploading it, closing it, quitting codeaf and `codeaf cell gc` also wait for both the call log and the saved files to reach durable storage. A storage failure is reported and stops that boundary. Opening a chat screens its folder in the background without sealing an empty turn. A command submitted before that screen finishes waits for it. A window that only views a chat held elsewhere does not seal turns. A failed capture keeps the finished call in its local log for the next save; codeaf never runs that command again to recover it. ## Quitting while saving — local confirmation and a slow upload A save wait lasts at most 30 seconds. After the screen closes, a pending sync save says `saving this chat to your other devices…`. After about three seconds, a confirmed local save adds `still saving — ctrl+c leaves it saved here`; without confirmation, it says `still saving on this computer — ctrl+c leaves the written turns here`. -If the turns are saved locally but not uploaded, the final sentence is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service’s host. That is a successful exit. Ctrl+c during this wait leaves promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`; that failure may include the local recovery path. +If the turns are saved locally but not uploaded, the final sentence is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service’s host. If that pending upload was refused because storage is off, the successful receipt is `saved here; relay storage is off for this account, so it stays on this computer`. That is a successful exit. Ctrl+c during this wait leaves promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`; that failure may include the local recovery path. If the last upload fails when you quit, for example because the connection dropped, this computer does not let the chat go. Your other computer keeps showing it as running here and does not open the older save by itself; the unsent turns stay on this computer until an upload succeeds. ## I pressed alt+m before the other chat appeared — still arriving, home on a second computer +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Device presence can arrive before its list of chats. If you press `alt+m` early, home says `chats are still arriving from your other computer; this opens when they are ready`. It keeps your request and opens the ordinary confirmation once the chat arrives; it never takes the chat without that confirmation. A recent chat left on another computer is offered when home opens, including one that computer closed cleanly. ## a new chat brings its full saved contents; a returning chat brings its changes @@ -327,6 +357,8 @@ a chat this computer has never held brings its saved contents in parallel, even ## Does first start send anything — local identity before pairing +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + The first start may create a local identity for this computer. That file sends nothing: there is no sync traffic or network use until you pair or offer a pairing code. Reading the add-device instructions leaves it quiet. Offering a pairing code or running `codeaf pair` contacts the sync service for that pairing. `CODEAF_SYNC_URL=off` blocks sync traffic even after pairing. ## What survives a crash or power loss — durability, killed process, lost call records @@ -341,6 +373,8 @@ A saved attachment reference keeps the source computer's path spelling with slas ## Can I rewind an old chat on my new laptop — where does the copy start +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Yes, after the older chat has a movable copy and you continue it on your paired laptop. Its first saved file state is the folder as it stood when the copy was made. Rewind can reach that first saved turn; file states from before the copy were never saved and cannot be reconstructed. The whole earlier conversation is still there to read. Recent older chats are prepared in the background after pairing. If you explicitly open an older chat whose copy is not ready, opening that conversation waits for its first folder snapshot to be saved. Large folders can take longer; Home's initial display does not wait for the background batch. @@ -349,6 +383,8 @@ The original older chat is untouched. A turn typed into the original by an older ## I quit on my laptop and opened my desktop — do I need alt+c +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + A clean quit lets go after the last save is durable. Opening bare `codeaf` on your other paired computer within 24 hours continues the released chat automatically, with the usual `Moved from` banner and `not brought along:` lines. `esc` goes Home. A sleeping laptop has not let go: Home offers the chat and `alt+c` continues it, asking only if local work needs a choice. If this computer is also coordinating, a clean terminal quit releases its idle chats and returns coordination to an anchor before stopping. With the anchors reachable, the other computer can continue immediately. Sleep, a crash or a lost connection still use the fallback described on the coordination and workers page. @@ -359,6 +395,8 @@ A running chat on the other computer never moves automatically. Neither does a n ## my choice while an automatic chat is arriving — continue a different chat, join it or cancel +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + bare startup still chooses the most recently released eligible chat and checks local safety first. input before that check answers cancels the automatic choice. if its move is already running, confirming a different chat on Home or using `alt+c` chooses what opens next. Home says `continuing next; is still on its way here`, with the titles from the continue cards. the automatic move settles first and is let go with its files and kept work intact; it never opens over your choice. the next move uses exactly the computer and turn of ownership you confirmed. `esc` cancels the waiting choice and the automatic chat still settles and is let go. @@ -373,6 +411,8 @@ An older chat that worked in your home folder, or in a folder that holds it, is ## A save has not reached the other computer yet — upload failed or another device continued first +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + A saved chat reaches your other computers only after its encrypted objects have been uploaded and its new saved head has been accepted. If an upload fails, the other computer keeps seeing the previous saved head. Retry when the connection @@ -381,6 +421,8 @@ chat first; this does not discard the work saved on your computer. ## Which computer coordinates: cluster status and cluster lead +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + On the hosted service, or a self-hosted service with persistent storage, an open paired home joins as a learner. Three persistent voters keep coordination available when your laptops sleep. `codeaf cluster status` shows this computer after joining. Continue chats through the ordinary continue door. See the coordination and workers page for command refusals and setup details. ## Can a worker access all my chats or approve pairing? @@ -401,6 +443,8 @@ No. The worker saves its claim and an execution intent before invoking tools. A ## Why can an older computer list chats but not continue a chat that is still running? +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Older builds can still list, save, publish and release chats through a compatible service. A service using quorum-backed coordination refuses an older request that tries to take a live chat without naming its observed holder and ownership number. It uses the existing refusal, `another device continued this chat first`; an old build does not necessarily show an extra update hint. Quit on the computer holding the chat before continuing it elsewhere, or update codeaf on the computer trying to continue. A released or expired chat can still be taken. A conditional choice never silently changes to a newer holder. @@ -411,6 +455,8 @@ Yes. Directory requests from older builds keep the existing 1 MiB request limit, ## Why did Enter show Continue here instead of sending my message +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Before sending, codeaf checks that this window still holds the chat. If another computer holds it, its latest saved turn is newer, or this window's earlier move failed, Enter opens **Continue this chat here?** or leaves the window showing the chat only. The words stay in the box. Choose **continue here** to bring the saved chat here, then press Enter to send. Once this window says another computer continued the chat, its first Enter @@ -422,13 +468,15 @@ A new chat can send its first message normally. A sleeping computer can also res ## A move that failed partway — are my typed words safe, can the old copy still send +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + If something changes the chat's files while a move is putting them in place, codeaf stops the move and lets go of the chat. It says the move could not finish and that your words are still in the box. The window keeps showing the chat or returns to Home, and it cannot send on the old copy. Try **continue here** again. Every failed move keeps the captured draft, including its whitespace, caret, pasted text and attachments, even if the old session closed during the move. Losing a race, a connection failure or a refused move does the same. A failed move never sends those words by itself. ## Repair sync — codeaf doctor and codeaf doctor --fix for unfinished continues, duplicate rows and a busy engine store -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Run `codeaf doctor` from a terminal to inspect this computer's engine store and cross-computer sync state without changing anything. When an issue is present, `engine store` and `sync` rows report: diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 71779e8f45..fe4abed2d4 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -450,7 +450,7 @@ profile or changes the requesting chat's model. ## Do my teams show on my other computer? -Team sharing is on by default for paired computers. The local switch is +The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. The local switch is **Settings → General → share teams**. Sharing includes teams, members, handles, the manager and team settings, plus recent Traffic as read-only history. Team changes show within a few seconds, even with no chat open. The Teams dropdown, Teams page @@ -481,7 +481,7 @@ wait while this computer finishes sending an earlier change to the relay. ## Will deleting a team on my laptop delete it on my desktop? -Team sharing is on by default for paired computers. With **Settings → General → +The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. With **Settings → General → share teams** enabled on both computers, a deleted team stays deleted. A desktop that was offline catches up when it reconnects. Renaming its old copy while offline does not bring the team back. Deleting a team does not delete its conversations. @@ -495,6 +495,8 @@ consistently, and a removed member cannot remain the manager. ## Does Traffic sync between computers? +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Traffic sharing is on by default for paired computers through **Settings → General → share teams**. Turning it off on either computer stops sharing between them. Traffic from another computer shows its newest history, up to 32 lines per team and at most 16 KiB per team. Across all teams, each computer shares @@ -519,7 +521,7 @@ on the other computer's history. ## How do I turn on team sharing? -Team sharing is on by default for paired computers. If it was turned off, open +The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. If it was turned off, open **Settings → General → share teams** on that computer and choose **on** to resume. Sharing exchanges teams, members, manager choices and settings, and recent Traffic as read-only history while the switch stays enabled on both paired computers. @@ -532,7 +534,7 @@ Turn **share teams** to **off** to stop all team sharing immediately. Teams alre ## Stop sharing teams with my other computer -Team sharing is on by default for paired computers. Open **Settings → General → +The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. Open **Settings → General → share teams** on either computer and choose **off**. Turning it off on either computer stops sharing teams, members, manager choices and settings, and recent Traffic between them immediately. That computer stops sending and receiving teams and Traffic. Teams already received @@ -547,6 +549,8 @@ arrive within a few seconds; recent Traffic can take about 2 minutes. ## First team sharing: backup, duplicate names and global managers +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Paired computers exchange teams automatically while **Settings → General → share teams** is on, its default. An unpaired computer is unaffected. Before the first exchange, this computer keeps a copy of its teams as @@ -567,6 +571,8 @@ the original teams. Review manager choices in Teams. ## Does a shared team's spending cap cover both computers? +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + A team's spending cap counts spending on each computer separately. Sharing the team and its cap does not combine the usage meters. A member continued on the other computer is metered there, while members still running here count here. diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 64d7263df4..92d42c8c00 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -90,7 +90,7 @@ The sync connection carries presence hints and committed lease acknowledgments. ## Does syncing keep sending requests when nothing is happening? -A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round. Over ten idle minutes, caught-up open homes make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. Home reads its roster and backstop from the local applied directory. Its separate relay watch supplies advisory online presence, pinging every 10 seconds. A computer whose last socket closes is marked offline after a 15-second reconnect grace period; a silent connection is marked offline within 30 seconds. Home can then offer its recent chat before the 90-second lease expires. Continuing a chat and all ownership decisions still use fresh majority reads. A committed change wakes peer delivery and the next state round pulls the changed vault. If an older hosted relay has no learner door, the home keeps its existing five-second state polling. On the consensus directory, a group with no work or held chats can stop its periodic coordination timer after an idle barrier has reached every voter. The first operation wakes coordination and must obtain a fresh majority response. A saved leader address cannot answer that operation by itself. Losing the majority leaves syncing unavailable and keeps local work intact. +A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round when relay storage is on. Hosted storage is off by default while privacy terms are being finished; request access in Settings → Devices → relay storage → enter. Pairing, presence, directory coordination and live `--at` remain available while storage is off, but new chat/file uploads and vault sharing wait. A self-hosted relay with `--store` needs no approval. Over ten idle minutes, caught-up open homes make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. Home reads its roster and backstop from the local applied directory. Its separate relay watch supplies advisory online presence, pinging every 10 seconds. A computer whose last socket closes is marked offline after a 15-second reconnect grace period; a silent connection is marked offline within 30 seconds. Home can then offer its recent chat before the 90-second lease expires. Continuing a chat and all ownership decisions still use fresh majority reads. A committed change wakes peer delivery and the next state round pulls the changed vault. If an older hosted relay has no learner door, the home keeps its existing five-second state polling. On the consensus directory, a group with no work or held chats can stop its periodic coordination timer after an idle barrier has reached every voter. The first operation wakes coordination and must obtain a fresh majority response. A saved leader address cannot answer that operation by itself. Losing the majority leaves syncing unavailable and keeps local work intact. ## Hosted idle presence sweeps and sleeping connections diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 9171c0aed6..283b1a1d3b 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -51,7 +51,7 @@ screen. Its heading is `Basic settings`, one line, with the keys line directly u ## I already use codeaf on a second computer — the code field on the first screen -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -62,7 +62,7 @@ have a code from another device? use your own relay: codeaf pair --via ``` -On the computer that has your chats, type `/pair` to show a code like `42-715-302`. Type that code in the field. Compare the three words on both screens. The other computer asks `y / n`. On `y`, this computer has your chats. Read *Pairing your chats with a second computer* for the rest. +On the computer that has your chats, type `/pair` to show a code like `42-715-302`. Type that code in the field. Compare the three words on both screens. The other computer asks `y / n`. On `y`, this computer has your identity; chats copy here only when relay storage is enabled. Read *Pairing your chats with a second computer* for the rest. ## Skipping setup and reading its header diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 44f2ba57d6..967b8c6eae 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -289,7 +289,7 @@ updates the tab and the row in Sessions together. ## A chat is running on my other computer — how does home show it, chats on my other machines, "held on tower", "tower offline", why does it say held on build-box offline, merge / discard -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -306,6 +306,8 @@ happened. The row says where the chat is only where that tells you something: ## Why a chat says held on build-box or build-box offline +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + For a chat on another paired computer, read the two words apart: `held on build-box` says that computer still holds the chat, including while it is idle, and `build-box offline` says that computer has not answered for 25 seconds (it is switched off, asleep, frozen or offline; the word appears within 30 seconds, and goes at once when it answers again) and has not let the chat go, so the chat's last saved turns are what you would get. The name is only the device's. A chat on a computer that is `offline` can still be continued here; the card says how many turns may still be there. A count of one reads `1 turn`, never `1 turns`. The device is the name you gave that machine. A chat whose name will not open shows `untitled`, and a machine whose name will not open shows the first eight characters of its id. On a narrow frame a branch's sentence shortens to ` turns · `, and where @@ -336,7 +338,7 @@ same rows in a terminal (see "The cell verbs" in running-from-the-terminal). ## Continue a chat here that another machine left — "tower offline", takeover, "continue here", the chat is running on another computer -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -354,6 +356,8 @@ question takes the whole screen, with the sentence wrapped. ## Continue here with unsaved edits, another device continuing first, or an unreachable computer +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + When continuing a chat from another paired computer: - If you had unsaved edits to that chat here, they are not overwritten. They are kept as a @@ -365,6 +369,8 @@ When continuing a chat from another paired computer: ## Is my project folder changed when I continue a chat here — what comes with a continued chat, the files, binary files, file modes, the executable bit, the .env, where the copy lives, no git needed +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Continuing a chat brings all of it: the whole conversation, and every file the chat worked on, byte for byte: text and binary files, files that were never in a git repository, the executable bit, links. Git history, branches and settings travel too, sealed to your paired @@ -391,7 +397,7 @@ identity on this machine, no secrets are fetched and no file is written. ## Do saved provider keys and credentials follow me to another computer -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -409,6 +415,8 @@ is kept beside it as `credentials.json.damaged`. ## Do budgets, settings and model choices move between computers +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + Your stored model picks, display and approval defaults, and spending-limit values follow your paired computers through the encrypted vault. Each setting merges independently; the later edit wins. Spending counted against a limit stays **per computer**, so a daily limit is not a combined fleet allowance. Local paths, caches, service addresses, automations and operator environment pins stay on their computer. Person and project memories also follow within a sync round, including lines edited by hand and memories from older chats. Concurrent edits of different lines survive; the later edit of the same line wins. Machine-scoped memories stay local. @@ -421,12 +429,16 @@ chat while you are still fetching it, this machine keeps exactly what it had. ## Is my git history sent — secrets in my repo, .git, committed keys, branches +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Yes. When you continue a chat on your own paired device, the repository's `.git` travels as it is, including its history, branches, settings and any keys in old commits. Task commits travel too. Git history is sealed end to end between your devices; the relay sees only ciphertext. codeaf does not screen, remove or rewrite committed secrets. The working-tree guard is separate: a `.env` or source file it recognises as holding a secret is excluded from the chat's file capture and sent only through the encrypted vault. The scanner reads the whole file, including keys past its first MiB. If the secret was committed, its Git copy still travels with the history. An untracked secret file with no committed copy arrives only through the vault. ## What a move carries and what it leaves behind — job logs, task history, kept branches, do spend and usage numbers sync between computers, memory, withheld folders +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + A chat that moves to another computer brings, from the relay and sealed on the way: - the whole conversation, and every file in its folder; @@ -442,6 +454,8 @@ It leaves behind, and lists on the card under `not brought along:`: install fold ## Does dist or other gitignored output move — ignored files and secret files +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + Untracked Git-ignored output stays on the computer that made it and is named on the arrival card with `(git ignores them)`. It is neither captured locally nor uploaded. Tracked files still move. A wholly ignored directory is not walked or screened; an ignored secret file in an ordinary directory, such as `.env` or `config/.env.local`, is screened and still travels through the key vault to paired computers. ## What a moved chat left behind — set this machine up, why is node_modules missing, my dev server is not running after I moved, not brought along, rebuilt from the lockfile @@ -2719,6 +2733,8 @@ from a previous visit cannot replace the current visit's selection or draft. ## I quit on my laptop and opened my desktop — straight back into the chat +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + After a clean quit lets a paired computer's chat go, bare `codeaf` on another paired computer continues that chat automatically within 24 hours. You arrive with the `Moved from` banner and the usual `not brought along:` facts; `esc` goes Home. If the laptop only slept or lost its connection, Home offers its latest chat and `alt+c` continues immediately. When local unsaved edits need to be kept as a branch, the continuation card asks first. Nothing moves automatically while the other computer is running that chat, for a named chat or `--host`/`--at`/`--session`, when another local window holds a chat, or when local unsaved edits need a choice. If the move fails, Home stays open and says why. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 85db2510f6..a4dd595fe1 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3205,6 +3205,8 @@ pointer — they still point at your turn, never at the parent task's own journa ## The optional arguments on propose_task +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + These arguments are optional. Bad values return a refusal the model can correct. **`computer`** — the shown name of a paired computer that runs diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 1b0d6bc00d..b12c8971b9 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -55,7 +55,7 @@ The screen closes after at most one second while a slow close finishes. The launcher keeps its connection open for the final publish and release before it exits. A successful quit with nothing waiting stays quiet. While a sync save waits, the restored terminal says `saving this chat to your other devices…`. After about three seconds, a confirmed local save adds `still saving — ctrl+c leaves it saved here`; without that confirmation, it says `still saving on this computer — ctrl+c leaves the written turns here`. -The launcher waits up to 30 seconds for the final saving reply. If only upload is outstanding, the successful receipt is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service's host. Ctrl+c during this restored-terminal wait exits promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`. +The launcher waits up to 30 seconds for the final saving reply. If only upload is outstanding, the successful receipt is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service's host. If that pending upload was refused because storage is off, the successful receipt is `saved here; relay storage is off for this account, so it stays on this computer`. Ctrl+c during this restored-terminal wait exits promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`. ## One Enter sends — checking which computer holds this chat diff --git a/internal/manual/chat/lock-out-a-lost-computer.md b/internal/manual/chat/lock-out-a-lost-computer.md index ab80c027b8..1ac38323c4 100644 --- a/internal/manual/chat/lock-out-a-lost-computer.md +++ b/internal/manual/chat/lock-out-a-lost-computer.md @@ -2,9 +2,11 @@ ## A computer was stolen and I want it shut for good — codeaf identity rotate gives the computers you keep a replacement identity +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Stopping a computer with `codeaf devices revoke ` cuts it off from the relay. It does not undo what it holds. That computer has your whole identity. It can read everything it already has, and it can make itself a new device. -`codeaf identity rotate` is the real fix. It makes a replacement identity and moves your chats and your saved keys to it. The old identity is shut. The lost computer holds the old one, so it can read nothing of what you do from then on. +`codeaf identity rotate` is the real fix. It makes a replacement identity and moves your saved keys to it. With hosted relay storage off, rotation still finishes without uploading chats or resealed keys: chats stay on the computers that hold them. Previously stored frames remain under the old identity until grace-period erasure. The old identity is shut. The lost computer holds the old one, so it can read nothing of what you do from then on. Run it on a computer you keep, as soon as you know a computer is lost: @@ -20,12 +22,14 @@ usage: codeaf identity rotate [--grace 7d] [--yes] [--abandon] ## What a rotation does — the steps, in order +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + 1. It tells you the cost first, and asks. It says how many chats you have, how many are not on this computer, how many megabytes go up and about how long that takes. Answer `n`, or press ctrl+c, and nothing has changed. 2. It fetches the chats this computer does not hold yet. This can take a long time for big chats. You can still cancel. 3. It makes the new identity and writes it down on this computer, before it sends anything. 4. It shuts the old identity on the relay. Nobody can write to it any more, this computer included. Your other computers show that your chats are moving, and wait. -5. It seals every chat and your saved keys again under the new keys and puts them on the relay under the new identity. -6. It checks that the relay has everything. +5. With storage on, it seals every chat and your saved keys again and uploads them under the new identity. Approval follows only after proof from the new identity; a successor’s own storage setting stays in place. With hosted storage off, no resealing uploads are made; chats stay on your computers and the saved keys are resealed locally. +6. With storage on, it checks that the relay has everything; with storage off, it proceeds with local copies. 7. This computer switches to the new identity. This is the step after which there is no way back. 8. It asks the relay to delete the old identity after a grace period. The default is 7 days. Use `--grace` to change it: the relay takes 1 hour to 30 days. @@ -41,9 +45,11 @@ change these at their providers: OPENROUTER_API_KEY ## The rotation stopped half way — resume it or abandon it -If `codeaf identity rotate` stops half way, because the network dropped or the computer went to sleep, run it again. It finds what it had done and finishes. It never makes a second new identity. While a rotation is unfinished, the computer does not sync, and says `a rotation is unfinished on this computer; run codeaf identity rotate to finish it`. +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. -To stop a rotation before step 7, run `codeaf identity rotate --abandon`. The old identity opens again and nothing has changed. After step 7 the only way is forward: it says `already switched to the new identity`. +If `codeaf identity rotate` stops half way, because the network dropped or the computer went to sleep, run it again. It finds what it had done and finishes. It never makes a second new identity. If saved replacement keys are damaged, keep `rotation-successor.json`; deleting it would lose the replacement already bound by the relay. While a rotation is unfinished, the computer does not sync, and says `a rotation is unfinished on this computer; run codeaf identity rotate to finish it`. + +To stop a rotation before step 7, run `codeaf identity rotate --abandon`. The old identity opens again. This computer keeps the unused replacement keys privately, outside the active rotation, so a later rotation can reuse the successor already bound by the relay. After step 7 the only way is forward: it says `already switched to the new identity`. If the computer that started the rotation is lost too, run `codeaf identity rotate --abandon` on another computer that still has the old identity. It opens the old identity again. @@ -59,6 +65,8 @@ Remote access is a different door. A machine you let in with `codeaf serve` is n ## Pair your other computers again — each one follows the replacement +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + After a rotation, each computer you keep is still on the old identity. It stops syncing, and says once: `your chats are moving to a new identity; when that is done, pair this computer again (/pair on the computer that moved them)`. When the relay has deleted the old identity, after the grace period, it says: @@ -89,9 +97,9 @@ A relay that does not know how to do this says `this relay cannot replace an ide A way to prove your identity with a written recovery phrase is not built. -## What it costs — every chat goes up again +## What rotation costs — uploads with storage on, local copies with storage off -A rotation uploads everything your chats hold, once, because the new identity starts empty. The plan it shows before you answer gives the number. A chat of 200 MB goes up as 200 MB. The transfer takes your network bandwidth and uses relay storage; the old and new copies count towards its limits during the grace period. There is no fixed price or duration promised by the command. +With hosted storage off, rotation completes with no store writes. Chats remain on your computers; any already-stored frames are left to the old identity’s grace-period erasure. With storage on, a rotation uploads everything your chats hold, once, because the new identity starts empty. The plan it shows before you answer gives the number. A chat of 200 MB goes up as 200 MB. The transfer takes your network bandwidth and uses relay storage; the old and new copies count towards its limits during the grace period. There is no fixed price or duration promised by the command. While the old identity is kept for the grace period, the relay holds both copies. diff --git a/internal/manual/chat/losing-every-device.md b/internal/manual/chat/losing-every-device.md index 2bff1d769f..1e7e8f727f 100644 --- a/internal/manual/chat/losing-every-device.md +++ b/internal/manual/chat/losing-every-device.md @@ -2,17 +2,17 @@ ## I lost all my devices — can I get my chats back -**No. There is no recovery.** Your chats are sealed under keys that only your computers hold. The relay has only ciphertext. Nobody, including the operator of the relay, can open it or hand you a key. There is no account, no email link and no reset. +**No. There is no recovery.** Your chats are sealed under keys that only your computers hold. If storage was enabled and uploads finished, the relay has only ciphertext. With hosted storage off, your new chats never reached it. Nobody, including the operator of the relay, can open it or hand you a key. There is no account, no email link and no reset. If you still have one computer that holds your identity, pair a new one from it: `/pair`, or see "I got a new laptop". If you have **none**, the chats on the relay cannot be read by anybody. They stay there sealed until the relay is cleared. Only two things can save you before that day: - **An identity file.** `codeaf identity export` writes your identity under a passphrase, and `codeaf identity import` reads it on a new computer. Keep the file and the passphrase in a safe place, apart from the computer. -- **One computer that you keep.** A second computer that is paired is a backup. +- **One computer that you keep.** A second computer is a backup only for content that actually reached it; pairing alone makes no chat backup while hosted storage is off. ## Can I change my identity — identity rotate -If one trusted computer remains, **`codeaf identity rotate` works now**. It makes a replacement identity, seals your chats and saved keys under it, and retires the relay's old copy after the grace period. Pair the computers you keep again from that replacement. Read *Locking out a lost computer for good* for the steps and limits. +If one trusted computer remains, **`codeaf identity rotate` works now**. It makes a replacement identity and reseals saved keys locally. With hosted relay storage off, it finishes with no resealing uploads and chats stay on your computers; with storage on, it reseals and uploads chats and saved keys. It retires the relay's old copy after the grace period. Pair the computers you keep again from that replacement. Read *Locking out a lost computer for good* for the steps and limits. Rotation needs the old identity on a surviving computer. It cannot recover an identity when every copy, including any exported identity file and its passphrase, is lost. Revoking or rotating cannot take back chats and keys already downloaded by a lost computer; change exposed provider keys at their providers. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 5e15d2aa66..f2275593a4 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -4335,6 +4335,8 @@ a chain ends after ten requests rather than waiting forever. ## Do my crew settings follow me to my other computer? +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + After pairing, crew seat model pins, allowed-model choices, the crew daily cap, per-task cap and free-route choice follow synced preferences within a few seconds, even with no chat open. Both computers need to be running codeaf and able to reach diff --git a/internal/manual/chat/naming-a-device.md b/internal/manual/chat/naming-a-device.md index cc8e1adbc4..3a122bd76b 100644 --- a/internal/manual/chat/naming-a-device.md +++ b/internal/manual/chat/naming-a-device.md @@ -2,7 +2,7 @@ ## Why does it show my hostname — the host name is the default until a device is named, and where a name appears -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index d9997d2363..bb3043945e 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -4,6 +4,8 @@ For an always-on computer on your own Railway account, see [A dev box](a-dev-box ## Two codes, two grants — remote access and your chats +Live `--at` access and pairing work with hosted relay storage off while the other computer is on. Continuing saved chats with that computer off needs storage approval in Settings → Devices → relay storage → enter. + You learn one thing: **a code shown on a screen you own, typed on the other computer.** A code can grant two different things. They stay two things, because they are two different powers. | | remote access (`codeaf serve`, `codeaf chat --at `) | your chats (`/pair`, `codeaf pair`) | diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index 3e4673a000..aff6c8be00 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -2,22 +2,26 @@ ## Hosted relay or my own — which one do I use, where does sync go by default, and which relay each channel uses -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -A relay is the small service your computers sync through. There are two kinds. Both speak the same wire: the chat cannot tell them apart. +A relay is the small service your computers sync through. There are two kinds. Both speak the same wire. Hosted storage requires account approval; your own relay with `--store` does not. - **A hosted relay** is run for you on a cloud account. You do nothing to run it. You give the chat its address. - **Your own relay** is the program `codeaf relay`. You run it on a computer you control. Either way the relay sees only ciphertext and the short list on "What can the relay see". -**Where sync goes by default.** Unset, each release channel syncs through its own hosted relay: stable releases and release candidates use `https://codeaf.agentfield.ai/fabric`, staging builds use `https://caf-fabric-staging.instrument-santosh.workers.dev`, and dev builds and local builds from source use `https://caf-fabric-dev.instrument-santosh.workers.dev`. Channels do not sync with each other: computers must run the same channel (or set the same `CODEAF_SYNC_URL`) to pair and share chats. A build whose `HostedRelayURL` is empty has no default, and then nothing is copied and pairing says `no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. +## Where sync goes by default — stable, staging and dev relay addresses + +Hosted storage needs approval in Settings → Devices → relay storage; pairing and live `--at` work while storage is off. + +Unset, each release channel selects its own hosted relay: stable releases and release candidates use `https://codeaf.agentfield.ai/fabric`, staging builds use `https://caf-fabric-staging.instrument-santosh.workers.dev`, and dev builds and local builds from source use `https://caf-fabric-dev.instrument-santosh.workers.dev`. Channels do not sync with each other: computers must run the same channel (or set the same `CODEAF_SYNC_URL`) to pair and share chats. A build whose `HostedRelayURL` is empty has no default, and then nothing is copied and pairing says `no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. ## Point codeaf at a relay — CODEAF_SYNC_URL, url or off -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Set `CODEAF_SYNC_URL` in the environment of every computer that shares the chats. @@ -33,7 +37,7 @@ Your own computers must all name the same relay and hold the same identity, or t ## Run my own relay — codeaf relay --listen and --store -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. ``` codeaf relay --listen :8787 --store /var/lib/codeaf @@ -50,7 +54,7 @@ There is no Docker image for the relay. The page `docs/relay.md` in the reposito ## Who can use my own relay -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. **Any caller with a valid signature may make an identity and store on your relay.** The relay has no list of allowed people: an identity is made from a key, and the relay accepts any key. Keep your relay on a private network, a tailnet or behind a proxy that checks who calls. One identity cannot read another's data. diff --git a/internal/manual/chat/relay-limits-and-refusals.md b/internal/manual/chat/relay-limits-and-refusals.md index bb2023f62b..7c75369877 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -6,7 +6,7 @@ An older service cannot promise that taking a chat displaces only the computer y ## Is there a storage limit on the relay — how much can I sync -It depends on the relay. +Hosted storage starts off and requires approval in Settings → Devices → relay storage → enter while privacy terms are being finished. A self-hosted relay with `--store` needs no request. Limits below apply when storage is on. **A hosted relay has limits.** The hosted relay in this repository sets, for each identity: @@ -16,17 +16,18 @@ It depends on the relay. - 2 chunks being sent at one moment; - 16 MiB for one chunk, and 1 MiB for any other message. -A chunk the relay already holds costs nothing again, so a computer that sends it twice is never refused for that. +With storage on, a chunk already held costs nothing again. Storage off refuses even a repeated put with `403 storage_off`; existing chunks remain readable. **Coordination storage has a separate limit on both relays.** Each identity starts with an 8 MiB allowance for directory and coordination records. A larger imported identity keeps its measured allowance. New growth beyond it is refused; existing records and saved chats are retained. A relay you run yourself also needs enough disk for encrypted chunks and all three coordination copies. Older directory requests keep their 1 MiB HTTP limit; staging a large request does not bypass retained storage capacity. ## How much sync space have I used -Open `/status` in a chat to see a dim `sync space` line, for example +When hosted storage is off, `/status` shows one dim `relay storage off` line instead of sync space. The cached permission is refreshed in the background at launch and after each `/status`. + +With storage on, open `/status` in a chat to see a dim `sync space` line, for example `sync space 2.8 GiB of 5 GiB`. It shows how big your relay storage is and how close you are to the relay limit: the amount stored, then the byte limit the -hosted relay reports. The same reading appears in `/status --json`. It is read when the chat opens and -again after each `/status`, so it can trail what the last few turns uploaded. +hosted relay reports. The same reading appears in `/status --json`. The permission refresher retains its last successful amount in this process. While hosted storage is on it makes no further permission requests, so this amount can trail new uploads. After a restart only the remembered on/off choice remains, and unknown usage draws no line. This counts everything stored for your identity on the hosted relay across all your computers and chats, not this chat alone. GiB and MiB are binary byte units, @@ -46,6 +47,10 @@ The hosted relay lets one network make **200 new identities in a day**. A networ Pairing has limits on both kinds of relay: 10 new codes in an hour for one network, and a relay holds at most 2,000 waiting pairings. The messages are on the pairing page, for example `too many pairings from this network; wait 7 min` and `sync is full right now; try again in a few minutes`. +## Relay storage is off — approval, retries and chats staying here + +**Relay storage is off** (`403`, code `storage_off`). The chat says `relay storage is off for this account, so chats stay on this computer; request access in Settings under Devices`, once while it waits. No upload requests are made when this computer knows storage is off. A refresh that sees approval wakes sync automatically. This is separate from every full refusal; reads, pairing and live `--at` still work. Running uploads retry through the local gate on their normal backoff, capped at 60 seconds. One process-wide waiter starts chats waiting for storage within a minute of another process learning approval. Approval discovery runs at launch, after `/status`, and every ten minutes while hosted storage is off; serving computers use it too. It stops polling once storage is on. + ## What do I see when the relay says no **The chat says one plain sentence for each refusal.** The turns you save stay on this computer. The storage-full, too-many-identities and slow-down refusals below retry automatically while the chat stays open, including when sync could not start; saved turns upload when a retry succeeds. What differs is what the chat says and how it tries again: diff --git a/internal/manual/chat/relay-what-it-stores-and-sees.md b/internal/manual/chat/relay-what-it-stores-and-sees.md index e6f75d87c8..40dc351509 100644 --- a/internal/manual/chat/relay-what-it-stores-and-sees.md +++ b/internal/manual/chat/relay-what-it-stores-and-sees.md @@ -1,20 +1,32 @@ # What the relay stores and what it can see -## What does the relay store — what is on the relay's disk +## Turn on relay storage (request access) — why don't my chats sync to my other computer -The relay holds two things for each identity, kept apart from every other identity: a **directory** (one record for each chat, one for each of your computers) and a **store** of chat content. +Hosted relay storage is off by default while its privacy terms are being finished. Pairing does not turn it on. New chats, files and saved keys stay on this computer; the relay refuses even encrypted uploads. Existing stored frames stay readable and nothing is deleted. + +Pairing, seeing your devices and `--at` still work while the other computer is on. With storage off, new chat turns cannot be continued on another computer while the first is off, recovered from the relay after losing a computer, or sent as task copies. Teams sharing, synced keys, preferences and shared memories also wait. The relay still keeps device and directory coordination metadata, including existing chat records and leases; storage off does not erase those records. + +After pairing, open **Settings → Devices → relay storage**. When it says **off**, press **enter** (`enter request access`). This opens a pre-filled GitHub issue; the panel also shows the request code and link if no browser opens. The request code is your identity id (`id_` followed by 32 hex digits). It is safe to post publicly and grants nothing by itself. + +Serving processes and chat windows check at launch, after `/status` in chat, and every ten minutes while hosted storage is off. Once approval is read, waiting chats and uploads start within a minute without a restart. Opening a chat never waits for a relay reading. `/status` shows the dim line `relay storage off` while hosted storage is off. Revocation stops new uploads and leaves earlier frames readable. + +A self-hosted `codeaf relay --store ` keeps storage on and needs no request. Approved hosted accounts still have the limits on *Relay limits and refusals*. + +## Does the relay store my chats — what is on the relay's disk + +Hosted relay storage is off by default while its privacy terms are being finished; request access in Settings → Devices → relay storage → enter. Pairing and live `--at` work without storage. With storage enabled, the relay holds two things for each identity, kept apart from every other identity: a **directory** (one record for each chat, one for each of your computers) and a **store** of chat content. The content is in the store as **encrypted chunks**. Each chunk is sealed under a key that only your own computers have. The relay cannot open a chunk. It keeps the chunks you send, and your saved keys too: they travel as one more sealed chunk, like a chat. Two things in the directory are sealed before they leave your computer: **the title of every chat** and **the name of every computer**. The relay stores them as sealed text and cannot read them. -There is no account on the relay. No email, no password, no name. Your identity is an id that starts with `id_`, made from your key. +There is no email or password sign-in on the relay. Storage approval is tied to your identity id. Your identity is an id that starts with `id_`, made from your key. ## What can the relay see — the metadata, my IP address, and what is not hidden from it **The relay cannot read:** your messages, your files, the titles of your chats, the names of your computers, your saved keys, the pairing code, the three words. -**The relay can see, and keeps or logs:** +**The relay can see the following metadata. Directory and coordination records can remain while storage is off; chunk metadata applies to content already stored or uploaded with storage on:** - your identity id, and the id of each computer you use (an id, never a name); - for each chat: its id, how big it is, when it last saved, which computer holds it, and the chat it was split from; @@ -25,13 +37,13 @@ There is no account on the relay. No email, no password, no name. Your identity - coordination metadata: cluster and member ids, roles, term and log position, learner progress, request sequence and digest; - worker routing: opaque assignment id and revision, selected chat and worker ids, process number, ownership number, status and times. -A relay you run yourself logs only the first 8 characters of the identity id, the kind of request, the status and two byte counts. It never logs a body. An operator of a relay you do not run can do more than that; you cannot check it. +The hosted relay has request logs and traces disabled. Rate counters retain network information for their fixed windows. A relay you run yourself logs only the first 8 characters of the identity id, the kind of request, the status and two byte counts. It never logs a body. An operator of a relay you do not run can do more than that; you cannot check it. The relay can also delete what it holds, or refuse to serve it. Keep a copy you care about on a computer of your own. ## Does the relay see my code, my prompts or my files -No. Turns, tool output and files are sealed on your computer before they are sent. The relay gets ciphertext and sizes. +Hosted storage is off until approved; no new chat or file chunks are kept while off. With storage on, turns, tool output and files are sealed on your computer before they are sent. The relay gets ciphertext and sizes. Your saved keys are sealed too, as said above. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index a60d40b1bb..5a4fc2f323 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -342,6 +342,8 @@ by default on Linux and macOS on every build. Native Windows is supported too; a ## codeaf pair — show a code, or use one, from the terminal +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + **`codeaf pair approve `** compares the four-digit check number and asks `y / n`: only `y` or `yes` approves, and `d`, Esc, Enter or anything else decline. Code pairing keeps its `y / n` question. **`codeaf pair`** on a new computer asks to join your devices and shows a link to approve from a computer that is already in (`codeaf pair approve `); **`codeaf pair --code`** shows a code that gives your chats to another computer. **`codeaf pair `** uses a code that another computer showed, for example `codeaf pair 42-715-302`. `CODEAF_SYNC_URL=` or `--via ` keeps the invitation on that service's own pairing page and includes `--via` in the token command; it never prints the hosted page link for a self-hosted invitation. `--via ` names the sync service to go through, and `--replace` keeps this computer’s old chat files locally while replacing shared keys and clearing shared memories, preferences and the shared key vault; provider keys in local settings remain. The page *Pairing your chats with a second computer* has the whole story. @@ -400,7 +402,7 @@ version; the map does not keep a second version number. ## The cell verbs — list a cell's turns and rewind it -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. They need a compatible furrow engine. `codeaf cell` is hidden from the +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. They need a compatible furrow engine. `codeaf cell` is hidden from the help text. **`codeaf cell log []`** lists a cell's turns, newest first, one line each (turn, parent, time, trigger, tools, receipt; a setup turn says `Setup` and its calls are marked `[external]`), and **`codeaf cell rewind []`** puts the cell's files and transcript back to that @@ -439,7 +441,7 @@ be read. A wrong passphrase and a damaged blob get the same refusal. computer you keep: a new root, every chat and your saved keys sealed again under it, and the old identity deleted by the relay after the grace period. It is how a lost computer is locked out for good. It asks first and says what it costs; `--abandon` stops a rotation that has not -switched yet. Read *Locking out a lost computer for good*. +switched yet. With hosted relay storage off it still completes, without resealing uploads: chats stay on your computers and the old stored frames wait for grace-period erasure. With storage on, successor proof carries approval unless the new identity already has its own setting. Read *Locking out a lost computer for good*. Each machine also has its own device key, made the first time and never exported. Your identity signs a small certificate for it, so two machines share one id but keep separate @@ -450,6 +452,8 @@ key the first time the identity is made on that machine. ## List your chats on every machine — codeaf cell list --all, and what its lines mean +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + **`codeaf cell list --all`** prints every chat you have on any of your machines, one line each: id, title, device, where it runs (`held on `, ` offline`, ` turns from : discard`, or `-` for one held here or let go), and how @@ -470,7 +474,7 @@ Only a terminal that sets `CODEAF_SYNC_URL` itself is asking for a different rel ## Sync your chats between machines — CODEAF_SYNC_URL and CODEAF_SYNC_INTERVAL_MS -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -489,6 +493,8 @@ to the relay until pairing; that identity alone does not connect your other comp ## How often chat turns sync — CODEAF_SYNC_INTERVAL_MS +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + After pairing, codeaf sends sealed chat turns to the relay configured by `CODEAF_SYNC_URL`. **`CODEAF_SYNC_INTERVAL_MS`** is how often unsaved turns are sent to the relay, in @@ -530,6 +536,8 @@ this exists and the chat behaves as it always did. ## What syncing a conversation cost — how much data it uploaded, codeaf cell report, and sending the numbers only if you choose +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Each time a conversation is synced to another machine, codeaf adds one line of counts to a file on this machine, `v3/sync/stats/.jsonl` in the codeaf home: how many turns, frames and objects that flush carried, how many bytes went up and came down, and how many put, get and has requests it made. Requests that diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index 1efffcb938..d636cb3d9d 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -2,6 +2,8 @@ ## Can I run it on another computer? +Live `--at` access and pairing work with hosted relay storage off while the other computer is on. Continuing saved chats with that computer off needs storage approval in Settings → Devices → relay storage → enter. + `--host` runs the chat surface on the machine you are sitting at, and runs the conversation on another machine, over ssh. @@ -307,7 +309,7 @@ purpose, and says `this window is reading this conversation, not typing into it` ## How do I work on the same conversation from two computers — another window, and another machine -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 48aeb73136..fc1e814440 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -72,7 +72,7 @@ older version saved stays in the profile, read by nothing, and is never reported as an unknown setting. Internal model slots that this surface cannot change are omitted; supported model controls remain in Models. -## General — interface preferences and hints +## General — interface preferences, hints and share teams between paired computers **General** contains mouse interaction, completed tool details, tool icons, chat switching, the task sidebar and updates. There is no background reminders row: codeaf @@ -82,7 +82,7 @@ positive: on shows contextual tips, off hides them. Keyboard instructions and setting explanations stay visible either way. **Completed tool details** offers **collapsed** or **expanded** without changing the meaning of an existing profile. -**Settings → General → share teams** is on by default for paired computers. It +**Settings → General → share teams** is on by default for paired computers. On the hosted relay it takes effect only with relay storage approval (Settings → Devices → relay storage); a self-hosted relay with `--store` needs no approval. It shares teams, their members and settings, and recent Traffic as read-only history. The choice stays on each computer. Turning it off on either computer stops sharing between them: that computer stops sending and receiving teams and Traffic. What @@ -160,7 +160,7 @@ routing, internal role overrides and context limits. **Devices** lists your paired computers in the same words and order as `/devices`, renames this device with `n`, removes another with `r`, and starts `/pair` from -`Add another device`. +`Add another device`. For a paired hosted account, **relay storage** shows **on** or **off**. With **off**, Enter requests access and shows the public request code and link even if no browser opens. Choosing this row in search clears the search and opens Devices. The row is absent with sync off, before pairing, or on a self-hosted relay. **Privacy** separates input history, unfinished drafts, anonymous usage reporting and **shared model recommendations** (formerly Model Pool). Recommendations can diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index e513e6970a..b4cf013e83 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -837,3 +837,7 @@ without printing it. A key saved by `codeaf connect openrouter` or `/settings` reaches a conversation when you next open it or switch models; `OPENROUTER_API_KEY` still wins. `your key was not accepted for this model` means the provider refused the key; doctor helps find the source to replace. + +## Chats on another computer and hosted relay storage + +Pairing and live `--at` access work while hosted relay storage is off. Hosted storage is off by default while its privacy terms are being finished, so chats and files stay on this computer until approval. After pairing, request access in Settings → Devices → relay storage → enter. The panel shows the public request code and link as well as opening a browser. Sync starts by itself after a background reading sees approval; `/status` refreshes it without holding up the chat. A self-hosted `codeaf relay --store ` needs no request. diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index cadc7495f3..07133f7726 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -2,6 +2,8 @@ ## Can a task run on my other computer +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Yes. Pair both computers with the same identity and run `codeaf serve --take-tasks` on the computer that will do the task. Both ends need Linux or macOS; Windows cannot send or receive these tasks yet. An ordinary open chat window does not take tasks. @@ -27,6 +29,8 @@ the conversation and task, once, and also stays on that computer's ledger. ## Run a task on my mac mini +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Pair your Mac mini and this computer, then run `codeaf serve --take-tasks` on the mini. Use its shown name, quoting names that contain spaces: @@ -142,6 +146,8 @@ setting names another computer, without a special placement sentence. ## codeaf serve --take-tasks takes tasks from paired computers +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Plain `codeaf serve` opens only the machine connection and does not take tasks. Run `codeaf serve --take-tasks` on the paired computer that should take tasks. It prints `takes tasks from your paired computers`. Pairing with the same identity is @@ -205,6 +211,8 @@ requires updating both ends before retrying. ## The task copy could not be sent +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + `the task copy changed while it was being sent; try again` keeps the copy here. Finish or pause the edits, then retry the task. `task copies need private storage and a relay blob store` means this chat's diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index a1c59bef8e..2efe3ccb9f 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -564,6 +564,8 @@ while it was closed. ## Team sharing and Traffic on another computer +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + The traffic itself is kept in the profile of the machine the conversations run on, in `teams//traffic.jsonl`, one line per message, only ever added to. It holds only messages written on this computer. Team sharing is on by default for paired @@ -623,6 +625,8 @@ Review manager choices in Teams. ## Shared team spending caps and continued members +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + Teams and their settings share automatically between paired computers by default; an unpaired computer is unaffected. A shared team's spending cap counts spending on each computer separately. A member continued on the other computer is metered diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 1c0ec7ae54..c21d2a9a4a 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -2,7 +2,7 @@ ## How do I use this on my laptop — pair a second computer with a code, connect another computer, and log in on a new machine -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -10,7 +10,7 @@ There is no account and no password to log in with. You pair: a code on one scre You learn one thing here: **a code shown on a screen you own, typed on the other computer.** -The computer that has your chats shows the code. The other computer types it. After that, both computers have the same chats. +The computer that has your chats shows the code. The other computer types it. After that, both computers share the identity; chats copy between them only when relay storage is on. This page is the code way. A new computer can also ask to join and be approved from a link, with a four-digit check: the page *Adding a computer to your devices, approving it, and removing it* tells it. @@ -41,13 +41,13 @@ Older chats become movable through independent copies after pairing; their origi ## What does /laptop do — the laptop command pairs chats with my other computer; /pair, /sync and /link -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. `/pair` also answers to `/sync`, `/link` and `/laptop`. Type `/pair` with nothing after it to show a code. Type `/pair ` to use one. `codeaf pair --code` shows a code in a terminal; `codeaf pair` with nothing after it asks to join by link instead. `codeaf pair ` uses one. ## Terminal pairing usage — codeaf pair -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. ``` usage: codeaf pair [--name ] | codeaf pair approve | codeaf pair [--replace] [--name ] | codeaf pair --code @@ -59,7 +59,7 @@ A computer that has no relay cannot pair. See "The relay" below. A chat that run ## The three words — the check on the computer that has your chats -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Both screens show the same three words. This is how you know the two computers are the ones you hold. @@ -103,7 +103,7 @@ The code is now dead. It cannot pair a third computer. ## The code did not work — one attempt for each code -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. **Each code takes 1 attempt.** A wrong code does not get a second try. The computer that shows the code throws it away and shows a new one at once. You do not press a key. It says: @@ -177,9 +177,9 @@ too many pairings from this network; wait 7 min ## What the pairing shares — the other computer holds everything this one holds -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. -**Pairing gives the other computer your whole identity.** Your chats, your saved keys and your sync are all part of it. The other computer now holds everything that this computer holds. +**Pairing gives the other computer your whole identity.** Your chats, your saved keys and your sync are all part of it. The other computer receives the identity keys; saved chats and vault content still need enabled relay storage to copy. It is not a view of your chats. It is a copy of you. Pair only a computer you own and trust. @@ -203,19 +203,19 @@ already paired; nothing changed ## I got a new laptop — move my chats to a new machine -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. 1. On the old computer, type `/pair`. 2. On the new computer, run `codeaf pair `, or type `/pair ` in the chat, or type the code on the first screen. 3. Compare the three words. Answer `y` on the old computer. -The new computer now has your chats. If both computers are on the same relay, chats sync between them. Read *Sync your chats between machines* in the terminal page for the sync settings. +The new computer now has your identity. If both computers are on the same relay and storage is enabled, chats sync between them. Read *Sync your chats between machines* in the terminal page for the sync settings. There is no phone app for continuing chats. A phone browser can open the `/p/` pairing-link page, but that page only shows the approval command and a link for a computer with codeaf installed; it does not turn the browser into a paired device. Use a supported desktop or server build to pair and continue chats. ## Devices and revoking — what you can stop, and what you cannot -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. When moving chats is available, `codeaf devices` lists this computer and every linked device, in up to two groups (in the chat, `/devices` and `/settings` → `Devices` list your paired computers). A group with no entries stays hidden. Before pairing, this computer's local name, platform and `this device` come first, followed by `Run codeaf pair to add another device.` A chat-sync list with no removable peer has that same next step instead of a removal hint. @@ -257,7 +257,7 @@ To make revoking take effect for real, give all your other computers a new ident ## Without a relay — identity export and import -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. You can move your identity by file. This needs no relay. It is the way to use when neither computer can reach a relay. @@ -270,7 +270,7 @@ Keep the file and the passphrase safe. Anybody who has both has your chats. ## Is my code sent anywhere before I pair — quiet first launch -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. On a new home, no device record reaches the relay, and no relay request, connection or chat upload is sent, until this home completes a pairing or starts offering a pairing code. Showing a code needs the relay even if nobody joins. A new device's `codeaf pair` also contacts the relay to offer its link; its chats start syncing only after approval. Opening or closing `+ Add another device` with `alt+d`, reading its instructions, listing `/devices` or `codeaf devices`, and ordinary local chats leave a new home **quiet**. @@ -282,10 +282,10 @@ The first-run line about the hosted relay is said once, when chats are first cop ## Where are my chats stored — where your chats are stored, the hosted relay, turning it off, self-host your own relay, is my code private -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. -Your chats live on your own computer first. They are also copied, sealed, -to a relay, so your other computers can open them. Which relay is one setting, +Your chats live on your own computer first. When relay storage is enabled they are also copied, sealed, +to the relay, so your other computers can open them. Which relay is one setting, `CODEAF_SYNC_URL`, and there is no sign-in and no account: - **Unset** uses this build's channel relay when it has one — `https://codeaf.agentfield.ai/fabric` on stable releases and release candidates, `https://caf-fabric-staging.instrument-santosh.workers.dev` on staging builds, and `https://caf-fabric-dev.instrument-santosh.workers.dev` on dev builds and local builds from source — and says so once, in one @@ -309,7 +309,7 @@ stops new copies going, and a relay you run is yours to empty. ## The relay — what it sees, your own relay, and turning pairing off -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. A relay is a small service that passes bytes between the two computers. Your chats sync end to end encrypted. The relay only carries ciphertext. It never sees the code, the three words or your chats. @@ -346,7 +346,7 @@ sync is off (CODEAF_SYNC_URL=off); pairing needs the sync service ## The pairing relay does not answer, is full or is too old -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Other things the relay can say while you pair: @@ -381,7 +381,7 @@ can also delay its answer. ## I lost my laptop — what to do -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. 1. On a computer you still have, run `codeaf devices`. 2. Run `codeaf devices revoke ` for the lost computer. @@ -389,7 +389,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win ## Which door do I use — pairing, --at or --host -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. - **The same chats on both computers:** pair them with `/pair`. This page. - **Work on one machine from the other, with no ssh:** `codeaf serve` on the machine that has the work, and `codeaf chat --at ` on the other. Read *Reaching a machine with a pairing code*. @@ -399,7 +399,7 @@ Moving chats and pairing are available by default on Linux, macOS and native Win ## Moving chats on Windows — vault permissions, refused names, symlinks, hard links, WSL and every build -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. `CODEAF_SYNC_URL=off` stops relay traffic; local chats still keep their saved turns. Older chats move through independent copies after pairing. Their original files and layout remain untouched for older builds. Nothing is deleted automatically to save disk. @@ -422,7 +422,7 @@ mode bits are recorded for later moves, but do not provide Windows access contro ## Moving chats needs a newer furrow engine than this build carries -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Chat movement needs the configured or embedded furrow's exchange verbs, daemon and changed-path sealing. codeaf checks these capabilities once at startup, without using a @@ -433,7 +433,7 @@ and existing remote access still work. Use a build carrying the newer engine, or ## Does a CA certificate bundle stay out as a private key — cacert.pem -Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. +Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. A PEM certificate bundle containing only `BEGIN CERTIFICATE` blocks is kept in saved history and can move with the workspace. A `.pem` suffix alone does not make it a private key. Files containing PEM private-key headers (including encrypted keys and OpenSSH keys), PuTTY private keys, and PKCS#12 key containers are withheld; a PEM containing both certificates and a private key is withheld too. The same folder exclusions still apply: a certificate inside an install folder left behind for rebuilding is rebuilt with that folder. @@ -472,6 +472,8 @@ Once this computer has its own saved pairing keys, a sent turn, tracked work, a ## What happens to chats made with chat --once before the first pairing? +On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. + If this computer has no saved pairing keys yet — for example it has only ever run `codeaf chat --once` — those chats are kept when it first pairs without `--replace`, and pairing does not discard or hide them. They are not sent at the moment of pairing. The next time codeaf's window opens on this computer, each one is saved for syncing and appears on your other computers' Home, where it can be continued. A computer that already has its own saved pairing keys follows the local-work refusal and explicit `--replace` rules above. diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index a2ab016a89..765fa5cfde 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -647,10 +647,14 @@ one line on a failed row is the whole of what it says. ## Are my memories shared across computers? Does /remember work on my other computer — hand edited memories follow my paired computers, memory missing on the new laptop +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Person and project memory follow your paired computers through the encrypted vault within one sync round, including hand-edited lines in the Memories place and lines written by older chats. No chat needs to be continued first. Concurrent edits of different lines both survive; for the same line the later edit wins, including forgetting a line. The service receives ciphertext only. Machine-scoped memories describe one computer and stay there. An unpaired home sends nothing. Pairing a computer that has its own memory merges those lines and preferences; explicit `--replace` replaces them. Chat-written memory still rides in each chat's own saved ledger as well. ## Memory in older local conversations and new moving chats +Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. + Memories use the local store on each computer. After pairing, an older conversation gets an independent movable copy with its earlier memory history sealed into `.cell/memories.jsonl`; the original is untouched. Person and project memory also sync independently through the encrypted vault. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 78c24860f3..9952e03b43 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4693,3 +4693,43 @@ func TestChatTeamSharingSafetyAnswers(t *testing.T) { }) } } + +func TestStorageOptInManualQuestions(t *testing.T) { + for _, question := range []string{ + "how do I turn on relay storage", + "why don't my chats sync to my other computer", + "request access to relay storage", + "does the relay store my chats", + } { + found := false + for _, section := range Chat().Search(question, DefaultResults) { + if section.Page == "relay-what-it-stores-and-sees" && strings.Contains(section.Body, "off by default") { + found = true + break + } + } + if !found { + t.Errorf("%q did not reach the hosted storage approval answer", question) + } + } +} + +func TestStorageOptInSettingsAndTeamsQuestions(t *testing.T) { + for _, probe := range []struct{ question, page, heading string }{ + {"Settings General share teams between paired computers", "settings", "General —"}, + {"Team sharing and Traffic on another computer", "team-manager", "Team sharing and Traffic"}, + {"Shared team spending caps and continued members", "team-manager", "Shared team spending caps"}, + } { + t.Run(probe.question, func(t *testing.T) { + for _, section := range Chat().Search(probe.question, DefaultResults) { + if section.Page == probe.page && strings.HasPrefix(section.Title, probe.heading) { + if !strings.Contains(section.Body, "relay storage approval") { + t.Fatalf("approval missing: %s", section.Body) + } + return + } + } + t.Fatalf("%q did not reach %s / %s", probe.question, probe.page, probe.heading) + }) + } +} diff --git a/internal/session/prompts/system.md b/internal/session/prompts/system.md index 8c400767e9..ebdbbb404e 100644 --- a/internal/session/prompts/system.md +++ b/internal/session/prompts/system.md @@ -196,3 +196,5 @@ BEFORE RUNNING A COMMAND, CHECK THE TRANSCRIPT. If its answer is already here, u # Critical - Don't end the turn while work the person asked for remains; phase boundary/todo flip/sub-step never stops: same turn. - HANDED-OFF WORK IS NOT WORK THAT REMAINS: end your reply once nothing independent of it is left. A task of your own still owes its deliverable whatever it hands out. + +Hosted relay chat/file storage is off by default while its privacy terms are being finished. Pairing, devices and live --at work while storage is off; offline chat transfer, relay recovery, teams sharing and synced keys require approval. Explain Settings → Devices → relay storage → enter to request access; a self-hosted relay with --store needs no request. Pairing alone does not make a backup. diff --git a/relay/hosted/README.md b/relay/hosted/README.md index d32434f7a6..4e344b14b8 100644 --- a/relay/hosted/README.md +++ b/relay/hosted/README.md @@ -291,3 +291,28 @@ metrics and update prompts off, no dependency on a writable home. Use that wrapp local Wrangler commands too. The project `.npmrc` keeps npm's own installation cache and logs below the ignored `.wrangler/npm-cache` directory, so `npm ci && npm test` also works with a read-only home. `npm ci` installs only the lockfile's dependencies. + +## Hosted storage approvals + +Hosted R2 writes are closed by default, including dev and staging, while the privacy terms are being finished. Pairing, presence, directory coordination, live tunnels and existing frame reads remain available. Account approval is stored separately from newcomer admission. A self-hosted Go relay does not use this policy. + +Build the operator-only command with `go build -o /tmp/codeaf-relay-admin ./cmd/codeaf-relay-admin` from the repository root; it is not packaged in releases or installers. An operator supplies their own Ed25519 seed file, one unpadded base64url 32-byte seed per file with owner-only permissions. The default is `~/.config/codeaf-relay-admin/admin.key`; `CODEAF_RELAY_ADMIN_KEY` or `--key` overrides it. Never place a private key in this repository. + +```sh +codeaf-relay-admin public-key --key +codeaf-relay-admin approve id_<32-hex> --relay prod --key +codeaf-relay-admin revoke id_<32-hex> --relay staging --key +``` + +`--relay` defaults to `prod`; `staging`, `dev` and an explicit HTTP URL are also accepted. URL prefixes such as `/fabric` are preserved. The command signs the exact JSON body after `codeaf/relay-admin/v1\n` and posts to `/v1/admin/storage`. The front Worker verifies an Ed25519 signature from `CAF_ADMIN_KEYS`, the arrived host and a ten-minute clock window. The identity object rejects replayed or reordered timestamps with `409 stale`. Revocation stops future writes without deleting existing frames. + +`CAF_ADMIN_KEYS` contains comma-separated unpadded base64url raw public keys; an empty value permits no approvals. The committed public key in `[vars]` is retained by every channel configuration. `CAF_LIMITS.storageOpen` accepts `true` or `1` to open storage for everyone later; no deployment configuration enables it by default. + + +## Storage handoff during rotation + +Relays advertise handoff support through the `storage` field in signed store stats. The client always sends the original freeze body, then posts a device-signed `POST /v1/store/handoff` as the source identity before resealing uploads. Its JSON body is `{"successor":"id_…","successor_key":"","proof":""}`. The successor identity key signs `codeaf/storage-handoff/v1\n\n`. + +The hosted object checks its committed rotation view: a source that is neither frozen nor retired receives `409 not_rotating`. WebCrypto verifies the successor's key-derived ID and proof. The first accepted successor is permanently bound in the source's local `meta`, surviving thaw and object restart. A different successor receives `409 conflict`; the same successor may retry. This does not change coordination commands, records or the shared evaluator. + +Only an `on` approval can be inherited, only into a successor with no approval record, and only while storage is closed globally. The response is `200 {"successor":…, "storage":"on"|"off"}` with the successor's resulting state. A frozen rotation journal repeats this handoff before uploads; `--abandon` keeps its bound successor keys for reuse. Storage-off rotation completes locally with no store writes. An older relay receives no handoff request. From 4311f2422c6c6da4e229e3f1b40c8df4d2b28ea3 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 21:28:30 -0500 Subject: [PATCH 05/18] Bind the storage handoff to the freezing device and keep rotation keys scoped Only the device whose freeze committed may hand its approval to a successor, so a lost computer cannot take the binding first. A cancelled rotation keeps its successor keys only when a hosted handoff was accepted, and reuses them only for the same source on the same relay; every other cancel behaves as before. The off-state check reads each file once, and the manual sections this branch changed are back under the length limit, with the idle-request page saying that an off account asks once every ten minutes whether it has been approved. Co-Authored-By: Claude Opus 5.5 --- internal/directory/rotation.go | 16 +- internal/directory/storage_handoff_test.go | 6 +- .../manual/chat/add-or-remove-a-computer.md | 10 +- internal/manual/chat/commands.md | 2 + .../continuing-a-chat-on-another-computer.md | 13 +- .../manual/chat/conversations-and-teams.md | 2 +- .../manual/chat/coordination-and-workers.md | 8 +- internal/manual/chat/home.md | 4 + internal/manual/chat/how-tasks-run.md | 20 +- .../manual/chat/lock-out-a-lost-computer.md | 2 +- .../manual/chat/relay-hosted-or-your-own.md | 2 +- .../manual/chat/running-on-another-machine.md | 6 +- .../chat/use-this-on-another-computer.md | 22 ++- internal/rotate/journal.go | 25 +-- internal/rotate/rotate.go | 12 +- internal/rotate/steps.go | 11 +- internal/rotate/storage_optin_test.go | 174 +++++++++++++++--- internal/syncsetup/rotate.go | 7 +- internal/syncsetup/storage_optin_test.go | 23 +++ .../syncsetup/storage_reads_linux_test.go | 150 +++++++++++++++ internal/syncsetup/storage_refresh.go | 10 +- internal/syncsetup/storage_refresh_test.go | 78 -------- relay/hosted/src/raft/hosted.js | 3 +- relay/hosted/test/raft/storage.test.js | 25 +++ 24 files changed, 473 insertions(+), 158 deletions(-) create mode 100644 internal/syncsetup/storage_reads_linux_test.go diff --git a/internal/directory/rotation.go b/internal/directory/rotation.go index 65c67069f9..0f2cc64e35 100644 --- a/internal/directory/rotation.go +++ b/internal/directory/rotation.go @@ -153,25 +153,27 @@ type Gate struct { func (g Gate) Freeze(ctx context.Context) error { return g.move(ctx, RotationReq{Op: OpFreeze}) } // HandoffTo carries hosted storage permission after freezing and before uploads. -// Relays without the stats field receive no additional request. -func (g Gate) HandoffTo(ctx context.Context, successor identity.Identity) error { +// It reports whether a binding was accepted; relays without the stats field +// receive no additional request and need no saved successor after cancellation. +func (g Gate) HandoffTo(ctx context.Context, successor identity.Identity) (bool, error) { if g.Store == nil { - return nil + return false, nil } counts, err := g.Store.Stats(ctx) if err != nil { - return err + return false, err } if counts.Storage == "" { - return nil + return false, nil } client, ok := g.Client.(interface { HandoffStorage(context.Context, string, identity.Identity) error }) if !ok { - return ErrTooOld + return false, ErrTooOld } - return client.HandoffStorage(ctx, g.Source, successor) + err = client.HandoffStorage(ctx, g.Source, successor) + return err == nil, err } // HandoffStorage signs consent with the successor root; the HTTP request is diff --git a/internal/directory/storage_handoff_test.go b/internal/directory/storage_handoff_test.go index 9225c588a4..8f91c25531 100644 --- a/internal/directory/storage_handoff_test.go +++ b/internal/directory/storage_handoff_test.go @@ -79,9 +79,13 @@ func TestStorageOptInFreezeNegotiatesExactLegacyBytes(t *testing.T) { if err := gate.Freeze(context.Background()); err != nil { t.Fatal(err) } - if err := gate.HandoffTo(context.Background(), next); err != nil { + accepted, err := gate.HandoffTo(context.Background(), next) + if err != nil { t.Fatal(err) } + if accepted != (state != "") { + t.Fatalf("handoff accepted=%v with storage=%q", accepted, state) + } want := []string{"/fabric/v1/identity/rotation", "/fabric/v1/store/stats"} if state != "" { want = append(want, "/fabric/v1/store/handoff") diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index f80676d246..32cdc7ae64 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -2,7 +2,7 @@ ## How do I add another computer — the Add another device card (add another machine), codeaf pair, a link to paste, and pairing a server with no browser -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing and moving chats are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. The original files stay untouched and remain usable in older codeaf builds. @@ -76,7 +76,7 @@ Pairing is available on Linux, macOS and native Windows with a compatible engine ## The pair words — approve, --code and --via -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. ``` usage: codeaf pair [--name ] | codeaf pair approve | codeaf pair [--replace] [--name ] | codeaf pair --code @@ -101,9 +101,9 @@ On the approving screen the number reads `Check number 4821. Approve only if the ## Approve a new device — the approve screen, a and d, and decline -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. -When you open a link on a device that is already in (paste it, or type `/pair `), the screen says who is asking: +On an already paired device, paste a link or type `/pair ` to see who is asking: ``` A new device wants to pair. @@ -233,7 +233,7 @@ chats no longer sync under the replaced membership. ## The link did not work — a pairing link or token that ran out, was declined or was answered already -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. These are the sentences a link pairing says when it stops, in the words the screen uses. Nothing was changed on either device when you read one of them. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index e97130eb1a..391c03f31c 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -34,6 +34,8 @@ its next saved turn, just like changes from the model's shell commands. A failed command keeps its output and exit status too. Shell paths, `@` names and slash words remain literal, not mentions or send tags. +## What can ! shell commands do — input, timeouts, approvals and workspace permissions + Commands receive no interactive input or terminal. Use non-interactive flags; editors, prompts and terminal apps are not supported. Each command starts a fresh shell in the workspace: `cd` and `export` apply only within that command. Large diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index 3024b2ff8a..48c8459a51 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -4,7 +4,7 @@ To continue on this machine, select the chat on home and choose `continue here`. ## Continue on another computer — continuing a chat on another computer, why can't I type in this chat, my message did not send and home opened with a question -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing and moving chats are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -225,12 +225,17 @@ a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry - `could not continue this chat here`: the move failed. The row stays where it was; choose `retry` or try again. - A move does not fail because another chat on this computer is saving its secrets to the sync service at that moment. Its last step waits up to 30 seconds for that save to finish, then arrives. - `could not continue this chat here because the sync service is not answering; your words are still in the box`: the service went away during the move. Nothing was sent and the chat stays where it was; send again once the other machines are reachable. + +## Why can't I take an open chat from another computer — older relays and --host + +Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. + - `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here` means the chat is open on another computer and the sync service is too old to take it from there safely. Everything else works, including continuing a chat the other computer has closed. Close the chat on that computer, then choose `continue here` again. - Under `codeaf chat --host`, `continue here` is not offered and a chat the remote host already holds never shows as held elsewhere. With no way to continue set up, an off row has no `continue here`: enter says why. ## taking a chat back, and two computers continuing the same chat — which one opens -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. A chat has one holder at a time. To take a chat back from the computer that took it, continue it there the same way: press enter on its row on that computer and choose `continue here`. Nothing has to be returned or switched off first. A computer with the chat open when another takes it says ` continued this chat; this window now only shows it`, stops taking new turns, and keeps what it had not yet sent as a branch (` turns from : open separately / discard`). A tool call the model tries there answers with that same line. Taking the chat back leaves a `node_modules` or other install folder that computer already has alone. @@ -307,6 +312,10 @@ Your repository's `.git`, including its history, branches and settings, moves wi The current working files are checked separately. A `.env` or source file that the guard recognises as holding a key is left out of the chat's file capture and carried through the encrypted vault. If you committed that secret earlier, its historical Git copy still moves with the repository. An untracked `.env` with no committed copy arrives only through the vault. +## How are secret files screened before saving or moving a chat — .env, ignored folders and changing files + +Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. + Before each seal, codeaf asks which files will be captured and screens those candidates, including a full candidate list for a first reply or a capture that needs a full scan. If a file changes between screening and capture, saving plans and screens again. If repeated edits prevent a stable capture, it says `files kept changing while saving this chat; try again` and keeps the calls for a later save. A wholly git-ignored directory is neither entered nor screened unless it holds tracked files. An ignored file in an ordinary directory is still screened: root `.env`, `config/.env.local` and a private key in a `*.pem` file travel through the encrypted vault to paired computers. A withheld file stays out of local saved packs and the uploaded workspace capture. Tools still see a `.env` file's variables through the vault. An unreadable folder stays out until its permissions recover; its newly eligible files are then screened with a fresh capture plan. Restores and arrivals also screen before an automatic safety capture. ## Why does home appear while my chats are still syncing diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index fe4abed2d4..c900c35472 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -450,7 +450,7 @@ profile or changes the requesting chat's model. ## Do my teams show on my other computer? -The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. The local switch is +The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. Use **Settings → General → share teams**. Sharing includes teams, members, handles, the manager and team settings, plus recent Traffic as read-only history. Team changes show within a few seconds, even with no chat open. The Teams dropdown, Teams page diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 92d42c8c00..3cd5c430d9 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -90,7 +90,13 @@ The sync connection carries presence hints and committed lease acknowledgments. ## Does syncing keep sending requests when nothing is happening? -A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round when relay storage is on. Hosted storage is off by default while privacy terms are being finished; request access in Settings → Devices → relay storage → enter. Pairing, presence, directory coordination and live `--at` remain available while storage is off, but new chat/file uploads and vault sharing wait. A self-hosted relay with `--store` needs no approval. Over ten idle minutes, caught-up open homes make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. Home reads its roster and backstop from the local applied directory. Its separate relay watch supplies advisory online presence, pinging every 10 seconds. A computer whose last socket closes is marked offline after a 15-second reconnect grace period; a silent connection is marked offline within 30 seconds. Home can then offer its recent chat before the 90-second lease expires. Continuing a chat and all ownership decisions still use fresh majority reads. A committed change wakes peer delivery and the next state round pulls the changed vault. If an older hosted relay has no learner door, the home keeps its existing five-second state polling. On the consensus directory, a group with no work or held chats can stop its periodic coordination timer after an idle barrier has reached every voter. The first operation wakes coordination and must obtain a fresh majority response. A saved leader address cannot answer that operation by itself. Losing the majority leaves syncing unavailable and keeps local work intact. +A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round when relay storage is on. Hosted storage is off by default while privacy terms are being finished; request access in Settings → Devices → relay storage → enter. Pairing, presence, directory coordination and live `--at` remain available while storage is off, but new chat/file uploads and vault sharing wait. A self-hosted relay with `--store` needs no approval. While hosted storage is off or unknown, each process makes one stats request at startup and every ten minutes to ask whether the account has been approved. On a relay with a learner door, over ten idle minutes, caught-up open homes with storage on make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. + +## How does an idle home notice offline computers and wake coordination? + +Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. + +Home reads its roster and backstop from the local applied directory. Its separate relay watch supplies advisory online presence, pinging every 10 seconds. A computer whose last socket closes is marked offline after a 15-second reconnect grace period; a silent connection is marked offline within 30 seconds. Home can then offer its recent chat before the 90-second lease expires. Continuing a chat and all ownership decisions still use fresh majority reads. A committed change wakes peer delivery and the next state round pulls the changed vault. If an older hosted relay has no learner door, the home keeps its existing five-second state polling. On the consensus directory, a group with no work or held chats can stop its periodic coordination timer after an idle barrier has reached every voter. The first operation wakes coordination and must obtain a fresh majority response. A saved leader address cannot answer that operation by itself. Losing the majority leaves syncing unavailable and keeps local work intact. ## Hosted idle presence sweeps and sleeping connections diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 967b8c6eae..3c51c84a08 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -448,6 +448,10 @@ A chat that moves to another computer brings, from the relay and sealed on the w - what the chat spent: the chat's share of the usage and spend figures is read again from its sealed record on the new computer, so its cost shows there and a rewind never drops it; - the memories that chat wrote, kept in the chat's own ledger and read back into the new computer's memory. +## What stays on this computer when a chat moves — task history, install folders, secrets and spending limits + +Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. + The project's task history (the list of finished tasks) and the git bookkeeping inside a task's working copy are not carried: they are made again on the other computer from the chat's own record. It leaves behind, and lists on the card under `not brought along:`: install folders a lockfile rebuilds (`node_modules`, `.venv`, `target`, `vendor`, `Pods`), files this computer cannot read, and one of two names that differ only in letter case or accent. A read-only folder arrives read-only. A secret file (`.env`) is not in the chat's files: it travels in the key vault. Stored limit values, model picks and personal memory follow separately through the encrypted vault. Spending against limits remains per computer; machine-scoped memory and local paths stay local. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index a4dd595fe1..e4169c8da2 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3232,6 +3232,10 @@ A task handed to a program such as senior-dev cannot wait at all, because it sta moment it is approved: its `depends_on` may name only work that has already landed. A program's run that ended done may be named by any task; one still going may not. +## Choosing a task's model and tool-call checkpoints — propose_task model and max_steps + +These optional arguments choose the task's model and checkpoint size. Bad values return a refusal the model can correct. + **`model`** — which model this task runs on. Set only when you asked for a particular model or class of model for this work. Left out, the task runs on `task.model` if set, otherwise on whatever model the conversation is on at the moment of admission — settled once, then @@ -3244,6 +3248,8 @@ and only then a stop with `stopped: 200 steps and no finish`, the number being t checkpoint that was in force. A negative value answers `Invalid arguments: max_steps cannot be negative`. Zero or absent means the default. +## Which checks can propose_task run — quotes and shell characters + **`checks`** — the repeatable verification, described in *What propose_task needs from you* above. Each entry must be ONE rerunnable command, and one that even a permit-everything policy would still stop and ask about is refused outright. A character that joins, redirects @@ -3258,7 +3264,11 @@ that ends the line, refuses it. The refusal names the character and says what pa expands commands in "". Such a character may stand only inside a single-quoted argument, where it is text`. The checker's own shell reads a command the same way, so a check admitted here is one the checker can run, and what it must never run is stopped at -both. A check that leads with a directory change +both. + +## Where task checks run and how long a check can be — propose_task checks and divide_work + +The optional `checks` argument names rerunnable commands. A check that leads with a directory change (`cd && `) is refused like any other composition, and its refusal ends with the form that passes: `A check runs from the root of the task's own copy: leave the directory change out and name each file by its path`. It is never quietly repaired, because @@ -3269,18 +3279,22 @@ rerunnable command: this check is 1001 bytes and a check may be at most 1000`. T part declares is taken off all of them and given once to the task that divided them, which is the only one that can honestly make it after its parts are home. +## Why does a task say the call was refused — invalid arguments and repeated proposals + Proposals refused side by side in one reply are ONE attempt, and a refusal they share counts once: three refused together for one reason are not three repeats, and a refusal whose words changed is not the same failure again, so neither earns the note about repeating a call. -That refusal, and every `Invalid arguments:` sentence like it, is mail for the model, not +A check refusal, and every `Invalid arguments:` sentence like it, is mail for the model, not for you: the row in the conversation reads only `the call was refused`, the task's card reads `not started · the call was refused`, and the sentence itself — the field name, the quoted command, the repair — stays in the tool's own result, where the model reads it and fixes the call, and behind `ctrl+o` on that row for you if you ask. A command that genuinely ran and failed is the opposite case, and its row still opens with its own output. +## How many task calls can make no progress — propose_task no_progress and restarts + **`no_progress`** — how many tool calls in a row may teach nothing, ask nothing new, save nothing and leave nothing new in its working copy before the task is stopped as spinning. Default **6**. On the limit the report is `stopped: 6 steps without progress`, the landing turn runs, and what @@ -3291,7 +3305,7 @@ The same number is also **how many calls in a row may produce byte-identical con the second look is asked about it** — the repeat checkpoint above. That one stops nothing on its own: raising `no_progress` for work that is legitimately repetitive moves both. -Both step limits are recorded in the checkpoint, so they survive a restart along with the +Both `max_steps` and `no_progress` are recorded in the checkpoint, so they survive a restart along with the rest of the task. ## Does a long request lose requirements when work is checked or handed off? diff --git a/internal/manual/chat/lock-out-a-lost-computer.md b/internal/manual/chat/lock-out-a-lost-computer.md index 1ac38323c4..1eeae0e5ae 100644 --- a/internal/manual/chat/lock-out-a-lost-computer.md +++ b/internal/manual/chat/lock-out-a-lost-computer.md @@ -49,7 +49,7 @@ Hosted chat/file sync requires storage approval in Settings → Devices → rela If `codeaf identity rotate` stops half way, because the network dropped or the computer went to sleep, run it again. It finds what it had done and finishes. It never makes a second new identity. If saved replacement keys are damaged, keep `rotation-successor.json`; deleting it would lose the replacement already bound by the relay. While a rotation is unfinished, the computer does not sync, and says `a rotation is unfinished on this computer; run codeaf identity rotate to finish it`. -To stop a rotation before step 7, run `codeaf identity rotate --abandon`. The old identity opens again. This computer keeps the unused replacement keys privately, outside the active rotation, so a later rotation can reuse the successor already bound by the relay. After step 7 the only way is forward: it says `already switched to the new identity`. +To stop a rotation before step 7, run `codeaf identity rotate --abandon`. The old identity opens again. Only after a hosted relay accepts the successor binding does this computer keep the replacement keys privately, outside the active rotation, for another rotation of the same identity on that same relay. Canceling before acceptance, using a relay without the storage capability, or using a self-hosted relay discards the candidate; the next rotation makes fresh keys. After step 7 the only way is forward: it says `already switched to the new identity`. If the computer that started the rotation is lost too, run `codeaf identity rotate --abandon` on another computer that still has the old identity. It opens the old identity again. diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index aff6c8be00..05db73ef6d 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -2,7 +2,7 @@ ## Hosted relay or my own — which one do I use, where does sync go by default, and which relay each channel uses -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing and moving chats are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index d636cb3d9d..a78eb72cbb 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -309,12 +309,10 @@ purpose, and says `this window is reading this conversation, not typing into it` ## How do I work on the same conversation from two computers — another window, and another machine -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -These are two different things and they are easy to run together. - **Another window** is a second codeaf on the *same* machine holding a conversation you can see on home. Its row says `another window` in the right margin — and `another window · enter brings it here` while the cursor is on it. `enter` on it never opens a @@ -620,7 +618,7 @@ The task roster lists this far conversation's work. Its rows come from the far bytes to the far conversation's `attachments/` folder. Generated and viewed pictures take the reverse road automatically so their far bytes can be painted in this terminal. -## Reminders and watches over --host — they work, and they are that machine's automations +## Where does a reminder I set up over --host actually run — reminders, watches and that machine's automations `remind me at 6`, `tell me when CI on main goes red` and `every Monday post the standup` all work over `--host` and `--at`. The conversation over there proposes the automation, its diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index c21d2a9a4a..21bd35d4fc 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -2,13 +2,13 @@ ## How do I use this on my laptop — pair a second computer with a code, connect another computer, and log in on a new machine -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Pairing and moving chats are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. There is no account and no password to log in with. You pair: a code on one screen, typed on the other. -You learn one thing here: **a code shown on a screen you own, typed on the other computer.** +Use a code shown on a screen you own. The computer that has your chats shows the code. The other computer types it. After that, both computers share the identity; chats copy between them only when relay storage is on. @@ -32,11 +32,15 @@ Dashes and spaces in the code do not matter. `42-715-302`, `42 715 302` and `427 The code is good for 10 minutes. It works once. -## Sync my chats — how to sync between paired computers +## Sync my chats — get my chats on my other computer Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. -Pair both computers first with `codeaf pair` or `/pair`, on Linux, macOS or native Windows. New chats save their turns locally and sync them to your paired -computers through the saved or hosted relay. `CODEAF_SYNC_URL=off` stops relay traffic. +Pair both computers first with `codeaf pair` or `/pair`, on Linux, macOS or native Windows. New chats save their turns locally and sync through the saved or hosted relay when storage is on. Hosted sync to paired computers needs approval in Settings → Devices → relay storage → enter; self-hosted `--store` needs none. Pairing, presence and live `--at` work while storage is off. `CODEAF_SYNC_URL=off` stops relay traffic. + +## Do older chats sync after pairing — independent copies and untouched originals + +Hosted sync needs approval in Settings → Devices → relay storage → enter; self-hosted `--store` needs none. Pairing, presence and live `--at` work while storage is off. + Older chats become movable through independent copies after pairing; their original layout and files stay untouched for older builds. Recent chats are prepared in the background, and opening any other older chat prepares its copy. A never-paired home sends no relay requests. ## What does /laptop do — the laptop command pairs chats with my other computer; /pair, /sync and /link @@ -123,7 +127,9 @@ that code did not work, or it timed out; ask for a new one A code that ran out of time (10 minutes) says the same. Ask for a new code with `/pair` on the computer that has your chats. -Other things you can meet, each in one sentence: +## Why did pairing fail — wrong code shape, expired code, another device or network limits + +Pairing works while relay storage is off. These are other code-pairing refusals, each in one sentence: A code with the wrong shape is refused before anything is sent: @@ -301,6 +307,10 @@ to the relay, so your other computers can open them. Which relay is one setting, *Reaching a machine with a pairing code*). The hosted relay and your own speak the same way, so nothing else changes. +## Is my code private on the relay — encrypted chats, files and visible metadata + +Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. + **Is my code private?** Everything a chat holds, its turns and the files it saved, is sealed on your computer with a key only your own computers have, before it is sent. The relay keeps ciphertext: it cannot read your messages, your code, or even the names of your chats. It can diff --git a/internal/rotate/journal.go b/internal/rotate/journal.go index 07cbd8e2c7..3aa7064b7e 100644 --- a/internal/rotate/journal.go +++ b/internal/rotate/journal.go @@ -35,18 +35,19 @@ type Item struct { // Journal is everything a crashed rotation needs. It holds secrets (the new // identity and both device keys) and is written 0600, atomically. type Journal struct { - V uint16 `json:"V"` - State State `json:"state"` - OldID string `json:"old_id"` - OldPublic string `json:"old_public"` // hex of the old signing public key: enough to verify the old device's cert - OldDevice json.RawMessage `json:"old_device"` // identity.Dev document, to ask the relay to retire - NewID string `json:"new_id"` - New json.RawMessage `json:"new"` // identity document - NewDevice json.RawMessage `json:"new_device"` // identity.Dev document - GraceMS int64 `json:"grace_ms"` - Items []Item `json:"items,omitempty"` - LocalOnly bool `json:"local_only,omitempty"` - Vault bool `json:"vault,omitempty"` // the resealed vault is on the new relay + V uint16 `json:"V"` + State State `json:"state"` + OldID string `json:"old_id"` + OldPublic string `json:"old_public"` // hex of the old signing public key: enough to verify the old device's cert + OldDevice json.RawMessage `json:"old_device"` // identity.Dev document, to ask the relay to retire + NewID string `json:"new_id"` + New json.RawMessage `json:"new"` // identity document + NewDevice json.RawMessage `json:"new_device"` // identity.Dev document + GraceMS int64 `json:"grace_ms"` + Items []Item `json:"items,omitempty"` + LocalOnly bool `json:"local_only,omitempty"` + HandoffRelay string `json:"handoff_relay,omitempty"` // Retain canceled keys only for the hosted relay that accepted them. + Vault bool `json:"vault,omitempty"` // the resealed vault is on the new relay } func path(home string) string { return filepath.Join(home, File) } diff --git a/internal/rotate/rotate.go b/internal/rotate/rotate.go index 78fa55db90..de0cc506b2 100644 --- a/internal/rotate/rotate.go +++ b/internal/rotate/rotate.go @@ -67,6 +67,9 @@ type Env struct { Home string Identity identity.Identity // the identity on disk now Device identity.Dev // this machine's device under it + // HostedRelay is the normalized hosted URL, empty for other relays. Only an + // accepted binding on this service requires keeping a canceled successor. + HostedRelay string // Connect binds the relay to a signer: the old identity's device, then the // new one's. Connect func(reqsign.Signer) Relay @@ -196,7 +199,7 @@ func (e Env) mint() (Journal, error) { if json.Unmarshal(raw, &j) != nil { return Journal{}, errors.New("rotate: saved successor keys are damaged; keep " + successorFile) } - if j.OldID == e.Identity.ID() { + if j.OldID == e.Identity.ID() && e.HostedRelay != "" && j.HandoffRelay == e.HostedRelay { if _, err := identity.Unmarshal(j.New); err != nil { return Journal{}, err } @@ -287,8 +290,8 @@ func (r *run) refusedFirst(err error) error { } // Abandon stops a rotation that has not switched: the old identity is thawed -// and its successor keys are kept outside the active journal so a later -// rotation can reuse the relay’s permanently bound successor. With no journal +// and keeps its successor keys only when a hosted relay accepted their binding. +// A later rotation of that source on the same relay must reuse them. With no journal // it thaws anyway, which is how another computer undoes a rotation whose // keeper lost its disk. func (e Env) Abandon(ctx context.Context) error { @@ -309,6 +312,9 @@ func (e Env) Abandon(ctx context.Context) error { if err := r.old.Gate.Thaw(ctx); err != nil { return err } + if r.j.HandoffRelay == "" { + return remove(e.Home) + } r.j.State = Minted r.j.Items = nil r.j.Vault = false diff --git a/internal/rotate/steps.go b/internal/rotate/steps.go index 4ee412ccd5..9136c410fc 100644 --- a/internal/rotate/steps.go +++ b/internal/rotate/steps.go @@ -83,11 +83,18 @@ func (r *run) seal(ctx context.Context) error { // A frozen journal already holds the successor keys. The separate handoff // retries safely after a crash without changing any coordination command. if gate, ok := r.old.Gate.(interface { - HandoffTo(context.Context, identity.Identity) error + HandoffTo(context.Context, identity.Identity) (bool, error) }); ok { - if err := gate.HandoffTo(ctx, r.newID); err != nil { + accepted, err := gate.HandoffTo(ctx, r.newID) + if err != nil { return err } + if accepted && r.HostedRelay != "" { + r.j.HandoffRelay = r.HostedRelay + if err := save(r.Home, r.j); err != nil { + return err + } + } } if r.StorageAllowed != nil { allowed, err := r.StorageAllowed(ctx, r.new.Store) diff --git a/internal/rotate/storage_optin_test.go b/internal/rotate/storage_optin_test.go index ed6da9386c..7372d8127b 100644 --- a/internal/rotate/storage_optin_test.go +++ b/internal/rotate/storage_optin_test.go @@ -12,6 +12,9 @@ import ( "github.com/Agent-Field/codeaf/internal/reqsign" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" ) @@ -78,28 +81,129 @@ func TestStorageOptInRotationOffFinishesWithoutWrites(t *testing.T) { } func TestStorageOptInCanceledRotationKeepsBoundSuccessor(t *testing.T) { - r := newRig(t) - r.env.After = crashAt(string(Frozen)) - if _, err := r.env.Rotate(t.Context()); err != errCrash { - t.Fatal(err) - } - j, _, err := load(r.home) - if err != nil { - t.Fatal(err) - } - if err := r.env.Abandon(t.Context()); err != nil { - t.Fatal(err) - } - if Pending(r.home) { - t.Fatal("cancellation still blocks ordinary sync") - } - r.env.After = nil - result, err := r.env.Rotate(t.Context()) - if err != nil { - t.Fatal(err) - } - if result.NewID != j.NewID { - t.Fatal("new successor would conflict with first handoff") + for _, mode := range []string{"self-hosted", "no-storage-field", "before-handoff", "refused-handoff", "accepted-same-relay", "accepted-other-relay"} { + t.Run(mode, func(t *testing.T) { + r := newRig(t) + if mode != "self-hosted" { + r.env.HostedRelay = "https://hosted.test/fabric" + connect := r.env.Connect + source := connect(signer{r.old.PublicKey(), r.dev}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/identity/rotation": + var req directory.RotationReq + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + t.Error(err) + return + } + var err error + switch req.Op { + case directory.OpFreeze: + err = source.Gate.Freeze(request.Context()) + case directory.OpThaw: + err = source.Gate.Thaw(request.Context()) + case directory.OpRetire: + err = source.Gate.Retire(request.Context(), 0) + default: + t.Errorf("unexpected rotation %s", req.Op) + } + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Write([]byte(`{}`)) + case "/v1/store/stats": + state := "off" + if mode == "no-storage-field" { + state = "" + } + json.NewEncoder(w).Encode(blobstore.Counts{Storage: state}) + case "/v1/store/handoff": + if mode == "refused-handoff" { + http.Error(w, `{"err":"unavailable"}`, 503) + return + } + w.Write([]byte(`{"storage":"off"}`)) + default: + t.Errorf("unexpected request %s", request.URL.Path) + } + })) + defer server.Close() + sign := func(*http.Request, []byte) {} + r.env.Connect = func(sig reqsign.Signer) Relay { + side := connect(sig) + if reqsign.IDOf(sig.IdentityKey()) == r.old.ID() { + side.Gate = directory.Gate{Client: directory.NewHTTP(server.URL, sign, server.Client()), Store: blobstore.NewHTTP(server.URL, sign, server.Client()), Source: r.old.ID()} + } + return side + } + } + r.env.StorageAllowed = func(context.Context, blobstore.Store) (bool, error) { return false, nil } + stop := Sealed + if mode == "before-handoff" { + stop = Frozen + } + accepted := strings.HasPrefix(mode, "accepted-") + r.env.After = crashAt(string(stop)) + if _, err := r.env.Rotate(t.Context()); err == nil || (mode != "refused-handoff" && err != errCrash) { + t.Fatalf("stop: %v", err) + } + j, _, err := load(r.home) + if err != nil { + t.Fatal(err) + } + if err := r.env.Abandon(t.Context()); err != nil { + t.Fatal(err) + } + if Pending(r.home) { + t.Fatal("cancellation still blocks ordinary sync") + } + raw, err := os.ReadFile(filepath.Join(r.home, successorFile)) + if accepted { + if err != nil { + t.Fatal(err) + } + var saved Journal + if err := json.Unmarshal(raw, &saved); err != nil { + t.Fatal(err) + } + if saved.HandoffRelay != r.env.HostedRelay { + t.Fatalf("saved relay %q", saved.HandoffRelay) + } + info, err := os.Stat(filepath.Join(r.home, successorFile)) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("saved keys mode: %v %v", info, err) + } + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unbound successor was retained: %v", err) + } + // An independent relay has no binding to this canceled successor. + if mode == "accepted-other-relay" { + second := &fakeRelay{clock: r.relay.clock, ns: map[string]*namespace{}} + r.env.Connect = second.connect + r.env.HostedRelay = "https://other.test/fabric" + } + // Stop after minting so even a deliberately refused relay can expose its candidate. + r.env.After = crashAt(string(Minted)) + if _, err := r.env.Rotate(t.Context()); err != errCrash { + t.Fatal(err) + } + next, _, err := load(r.home) + if err != nil { + t.Fatal(err) + } + reuse := mode == "accepted-same-relay" + if (next.NewID == j.NewID) != reuse { + t.Fatalf("successor reused=%v, want %v", next.NewID == j.NewID, reuse) + } + if reuse { + r.env.After = nil + result, err := r.env.Rotate(t.Context()) + if err != nil || result.NewID != j.NewID || Pending(r.home) { + t.Fatalf("same-relay completion: %+v %v", result, err) + } + } + }) } } @@ -223,3 +327,29 @@ func (s storageHandoffWrites) PutFrame(ctx context.Context, b []byte) (blobstore s.before() return s.Store.PutFrame(ctx, b) } + +func TestReview2UnrelatedRelayRotationMintsFreshSuccessor(t *testing.T) { + r := newRig(t) + r.env.After = crashAt(string(Frozen)) + if _, err := r.env.Rotate(t.Context()); err != errCrash { + t.Fatal(err) + } + oldJournal, _, err := load(r.home) + if err != nil { + t.Fatal(err) + } + if err := r.env.Abandon(t.Context()); err != nil { + t.Fatal(err) + } + // A new self-hosted relay has no approval binding to the canceled successor. + second := &fakeRelay{clock: r.relay.clock, ns: map[string]*namespace{}} + r.env.Connect = second.connect + r.env.After = nil + result, err := r.env.Rotate(t.Context()) + if err != nil { + t.Fatal(err) + } + if result.NewID == oldJournal.NewID { + t.Fatal("an unrelated relay rotation reused the canceled rotation's complete private identity") + } +} diff --git a/internal/syncsetup/rotate.go b/internal/syncsetup/rotate.go index 99a8c16f04..c460d0fe7b 100644 --- a/internal/syncsetup/rotate.go +++ b/internal/syncsetup/rotate.go @@ -36,9 +36,14 @@ func scratchDir(home string) string { return filepath.Join(home, "rotation-scrat // machine; a chat it does not know is fetched into a scratch folder. func (s *Sync) Rotation(eng cellstore.Engine, held func(id string) (cell.Cell, bool), name string, say func(string)) rotate.Env { eng.WorkspaceOf = func(c cell.Cell) string { return workspaceOf(c.Root) } + hostedRelay := "" + if IsHostedRelay(s.Relay) { + hostedRelay = normalizeStorageURL(s.Relay) + } return rotate.Env{ Home: s.Home, Identity: s.Identity, Device: s.Device, Say: say, - Connect: s.relayOf, + HostedRelay: hostedRelay, + Connect: s.relayOf, StorageAllowed: func(ctx context.Context, successor blobstore.Store) (bool, error) { if !IsHostedRelay(s.Relay) { return true, nil diff --git a/internal/syncsetup/storage_optin_test.go b/internal/syncsetup/storage_optin_test.go index 11131b6538..ae19f4c18f 100644 --- a/internal/syncsetup/storage_optin_test.go +++ b/internal/syncsetup/storage_optin_test.go @@ -169,3 +169,26 @@ func TestStorageOptInDefaultPortsAndCase(t *testing.T) { t.Fatal("port normalization") } } + +func TestStorageOptInRotationBindingRelay(t *testing.T) { + for _, relay := range []string{ProductionRelayURL, StagingRelayURL, DevRelayURL} { + raw := strings.Replace(relay, "https://", "HTTPS://", 1) + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + u.Host = strings.ToUpper(u.Host) + ":443" + u.Path += "/" + s := &Sync{Relay: u.String()} + env := s.Rotation(cellstore.Engine{}, nil, "", nil) + if env.HostedRelay != relay { + t.Fatalf("binding relay %q, want %q", env.HostedRelay, relay) + } + } + for _, relay := range []string{"http://own.test:8787", "https://own.test"} { + s := &Sync{Relay: relay} + if env := s.Rotation(cellstore.Engine{}, nil, "", nil); env.HostedRelay != "" { + t.Fatalf("self-hosted binding %q", env.HostedRelay) + } + } +} diff --git a/internal/syncsetup/storage_reads_linux_test.go b/internal/syncsetup/storage_reads_linux_test.go new file mode 100644 index 0000000000..889fabe430 --- /dev/null +++ b/internal/syncsetup/storage_reads_linux_test.go @@ -0,0 +1,150 @@ +package syncsetup + +import ( + "context" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "golang.org/x/sys/unix" +) + +// File opens observe the complete production sample, including reads inside +// dependencies; seam counters alone cannot detect duplicated quiet checks. +func watchStorageReads(t *testing.T, home string) func() map[string]int { + t.Helper() + fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { unix.Close(fd) }) + // Close events keep consecutive opens of the same file from coalescing. + if _, err := unix.InotifyAddWatch(fd, home, unix.IN_OPEN|unix.IN_CLOSE_NOWRITE); err != nil { + t.Fatal(err) + } + return func() map[string]int { + t.Helper() + counts := map[string]int{} + buf := make([]byte, 64<<10) + for { + n, err := unix.Read(fd, buf) + if err == unix.EAGAIN { + return counts + } + if err != nil { + t.Fatal(err) + } + for at := 0; at < n; { + mask := binary.NativeEndian.Uint32(buf[at+4 : at+8]) + size := int(binary.NativeEndian.Uint32(buf[at+12 : at+16])) + name := strings.TrimRight(string(buf[at+unix.SizeofInotifyEvent:at+unix.SizeofInotifyEvent+size]), "\x00") + if mask&unix.IN_Q_OVERFLOW != 0 { + t.Fatal("file-read measurement overflowed") + } + if mask&unix.IN_OPEN != 0 { + counts[name]++ + } + at += unix.SizeofInotifyEvent + size + } + } + } +} + +func TestStorageOptInSharedWaiterWork(t *testing.T) { + for _, override := range []bool{false, true} { + for _, n := range []int{1, 10, 50} { + for _, mode := range []string{"on", "off", "self-hosted"} { + t.Run(fmt.Sprintf("env=%v/%s/%d", override, mode, n), func(t *testing.T) { + home := t.TempDir() + relay := ProductionRelayURL + if mode == "self-hosted" { + relay = "http://own.test" + } + t.Setenv(URLVar, "") + if override { + t.Setenv(URLVar, relay) + } else { + if err := os.Unsetenv(URLVar); err != nil { + t.Fatal(err) + } + if err := SaveRelayURL(home, relay); err != nil { + t.Fatal(err) + } + } + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + state := mode + if state == "self-hosted" { + state = "on" + } + if err := SaveStorage(home, relay, id.ID(), state); err != nil { + t.Fatal(err) + } + timers, requests := 0, 0 + reads := watchStorageReads(t, home) + var next func() + p := storageProcessFor(home) + p.fetch = func(context.Context) error { requests++; return nil } + p.after = func(wait time.Duration, fn func()) func() bool { + if wait > StoragePollEvery { + t.Fatalf("wait %v", wait) + } + timers++ + next = fn + return func() bool { next = nil; return true } + } + woke := 0 + if mode == "off" { + for i := 0; i < n; i++ { + p.waiting[uint64(i)] = func() { woke++ } + } + } + p.check(false) + // Discard setup reads, then observe every production file open during + // one minute of steady-state waiting, including nested quiet checks. + reads() + timers, requests = 0, 0 + if next != nil { + fn := next + next = nil + fn() + } + wantReads, wantTimers := map[string]int{}, 0 + if mode == "off" { + wantReads[identity.File], wantReads[StorageFile] = 1, 1 + if !override { + wantReads[SavedURLFile] = 1 + } + wantTimers = 1 + } + actual := reads() + if !reflect.DeepEqual(actual, wantReads) || timers != wantTimers || requests != 0 { + t.Fatalf("reads=%v want=%v timers=%d requests=%d", actual, wantReads, timers, requests) + } + t.Logf("env=%v chats=%d state=%s file-reads/min=%v timer-arms/min=%d relay-requests=%d", override, n, mode, actual, timers, requests) + if mode == "off" { + // A raw file change models another process sharing this home. + raw := fmt.Sprintf(`{"relay":%q,"identity":%q,"storage":"on"}`, relay, id.ID()) + if err := os.WriteFile(filepath.Join(home, StorageFile), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + fn := next + fn() + if woke != n || next != nil { + t.Fatalf("wake=%d timer=%v", woke, next != nil) + } + } + }) + } + } + } + +} diff --git a/internal/syncsetup/storage_refresh.go b/internal/syncsetup/storage_refresh.go index fd326d44cd..a089e611b3 100644 --- a/internal/syncsetup/storage_refresh.go +++ b/internal/syncsetup/storage_refresh.go @@ -37,8 +37,6 @@ type storageProcess struct { read func() (string, string, string) fetch func(context.Context) error now func() time.Time - load func(string) (identity.Identity, error) - state func(string, string, string) string } func storageProcessFor(home string) *storageProcess { @@ -48,19 +46,17 @@ func storageProcessFor(home string) *storageProcess { if p == nil { ctx := context.Background() p = &storageProcess{home: home, ctx: ctx, waiting: make(map[uint64]func()), opened: make(map[uint64]func()), now: time.Now} - p.load = identity.Load - p.state = StorageState p.after = func(d time.Duration, f func()) func() bool { return time.AfterFunc(d, f).Stop } p.read = func() (string, string, string) { relay := Resolve(home) - if !relay.On() || !IsHostedRelay(relay.URL) || Quiet(home) { + if !relay.On() || !IsHostedRelay(relay.URL) || identity.Solo(home) { return "", "", "" } - id, err := p.load(home) + id, err := identity.Load(home) if err != nil { return "", "", "" } - return relay.URL, id.ID(), p.state(home, relay.URL, id.ID()) + return relay.URL, id.ID(), StorageState(home, relay.URL, id.ID()) } p.fetch = func(ctx context.Context) error { s, ok, err := Open(home) diff --git a/internal/syncsetup/storage_refresh_test.go b/internal/syncsetup/storage_refresh_test.go index 0b52917f83..cb3db77a0f 100644 --- a/internal/syncsetup/storage_refresh_test.go +++ b/internal/syncsetup/storage_refresh_test.go @@ -3,94 +3,16 @@ package syncsetup import ( "context" "errors" - "fmt" "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/reqsign" "io" "net/http" - "os" - "path/filepath" "strings" "testing" "time" ) -func TestStorageOptInSharedWaiterWork(t *testing.T) { - for _, n := range []int{1, 10, 50} { - for _, mode := range []string{"on", "off", "self-hosted"} { - t.Run(fmt.Sprintf("%s/%d", mode, n), func(t *testing.T) { - home := t.TempDir() - relay := ProductionRelayURL - if mode == "self-hosted" { - relay = "http://own.test" - } - t.Setenv(URLVar, relay) - id, err := identity.Ensure(home) - if err != nil { - t.Fatal(err) - } - state := mode - if state == "self-hosted" { - state = "on" - } - if err := SaveStorage(home, relay, id.ID(), state); err != nil { - t.Fatal(err) - } - reads, timers, requests := 0, 0, 0 - var next func() - p := storageProcessFor(home) - p.load = func(home string) (identity.Identity, error) { reads++; return identity.Load(home) } - p.state = func(home, relay, id string) string { reads++; return StorageState(home, relay, id) } - p.fetch = func(context.Context) error { requests++; return nil } - p.after = func(wait time.Duration, fn func()) func() bool { - if wait > StoragePollEvery { - t.Fatalf("wait %v", wait) - } - timers++ - next = fn - return func() bool { next = nil; return true } - } - woke := 0 - if mode == "off" { - for i := 0; i < n; i++ { - p.waiting[uint64(i)] = func() { woke++ } - } - } - p.check(false) - // Setup resolves the identity once. Count actual identity and permission - // file reads in one minute of steady-state local waiting. - reads, timers, requests = 0, 0, 0 - if next != nil { - fn := next - next = nil - fn() - } - wantReads, wantTimers := 0, 0 - if mode == "off" { - wantReads, wantTimers = 2, 1 - } - if reads != wantReads || timers != wantTimers || requests != 0 { - t.Fatalf("reads=%d timers=%d requests=%d", reads, timers, requests) - } - t.Logf("chats=%d state=%s file-reads/min=%d timer-arms/min=%d relay-requests=%d", n, mode, reads, timers, requests) - if mode == "off" { - // A raw atomic file change models another process sharing this home. - raw := fmt.Sprintf(`{"relay":%q,"identity":%q,"storage":"on"}`, relay, id.ID()) - if err := os.WriteFile(filepath.Join(home, StorageFile), []byte(raw), 0600); err != nil { - t.Fatal(err) - } - fn := next - fn() - if woke != n || next != nil { - t.Fatalf("wake=%d timer=%v", woke, next != nil) - } - } - }) - } - } -} - func TestStorageOptInFailedRefreshPreservesCache(t *testing.T) { home := t.TempDir() id, err := identity.Ensure(home) diff --git a/relay/hosted/src/raft/hosted.js b/relay/hosted/src/raft/hosted.js index fe207911d6..367d0298f3 100644 --- a/relay/hosted/src/raft/hosted.js +++ b/relay/hosted/src/raft/hosted.js @@ -838,7 +838,8 @@ export class HostedIdentity { await this.ensure(fromBase64Url(c.request.headers.get('codeaf-identity'))); const authority=await this.authorize(c.device,c.request.headers.get('codeaf-cert'),false,fresh); checkTime(c.request.headers.get('codeaf-time'),this.readNow(authority.app.now)); - if(!['frozen','retired'].includes(authority.identity.rotation?.state))throw new Wire('not_rotating',409); + const rotation=authority.identity.rotation; + if(!['frozen','retired'].includes(rotation?.state)||rotation.by!==c.device)throw new Wire('not_rotating',409); const successor=await checkStorageHandoff(this.tenant.identity,c.body); await this.finishRead(authority.barrier,null); this.tenant.storage.bindSuccessor(successor); diff --git a/relay/hosted/test/raft/storage.test.js b/relay/hosted/test/raft/storage.test.js index 09bd2c3288..bb4372c968 100644 --- a/relay/hosted/test/raft/storage.test.js +++ b/relay/hosted/test/raft/storage.test.js @@ -191,3 +191,28 @@ test('R8 a handoff from an unfrozen identity is refused without binding a succes assert.deepEqual(await h.call(c,'POST','/v1/store/handoff',accepted),{status:200,body:{successor:accepted.successor,storage:'off'}}); } finally {await h.close();} }); + + +test('R1 only the device that froze the identity can bind its hosted successor', {timeout:60000}, async () => { + const h=await hostedFixture({storageOpen:false}); + const makeHandoff=()=>{ + const root=generateKeyPairSync('ed25519'),key=root.publicKey.export({type:'spki',format:'der'}).subarray(-32); + const successor='id_'+createHash('sha256').update(key).digest('hex').slice(0,32); + return {successor,successor_key:key.toString('hex'),proof:sign(null,Buffer.from('codeaf/storage-handoff/v1\n'+h.identity+'\n'+successor),root.privateKey).toString('hex')}; + }; + try { + await h.start();const keeper=h.client(),other=h.client(); + for(const device of [keeper,other])assert.equal((await h.call(device,'GET','/v1/store/stats')).body.storage,'off'); + const frozen=await h.call(keeper,'POST','/v1/identity/rotation',{op:'freeze'}); + assert.equal(frozen.status,200);assert.equal(frozen.body.rotation.by,keeper.device); + const competing=makeHandoff(); + assert.deepEqual(await h.call(other,'POST','/v1/store/handoff',competing),{status:409,body:{err:'not_rotating'}}); + // A durable competing binding would make the keeper's different successor conflict. + await h.start();const accepted=makeHandoff(); + assert.deepEqual(await h.call(keeper,'POST','/v1/store/handoff',accepted),{status:200,body:{successor:accepted.successor,storage:'off'}}); + assert.deepEqual(await h.call(other,'POST','/v1/store/handoff',competing),{status:409,body:{err:'not_rotating'}}); + assert.equal((await h.call(keeper,'POST','/v1/identity/rotation',{op:'retire'})).status,200); + assert.deepEqual(await h.call(other,'POST','/v1/store/handoff',accepted),{status:409,body:{err:'not_rotating'}}); + assert.deepEqual(await h.call(keeper,'POST','/v1/store/handoff',accepted),{status:200,body:{successor:accepted.successor,storage:'off'}}); + } finally {await h.close();} +}); From 63e247406b2cbbb68320b26ab061742163279074 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 22:22:41 -0500 Subject: [PATCH 06/18] Let the relay's own answer decide whether it gates storage A relay whose stats carry a storage state is treated as gating whatever its hostname: the Settings row, the /status line, the local gate and the off-state refresher all follow that answer. A relay that answers without the field, such as the self-hosted relay, is asked once per process and then left alone. Before any answer the hosted hostnames stay closed and every other address stays open. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/approvals.go | 5 +- cmd/codeaf/storage_optin_test.go | 47 +++++++ cmd/codeaf/syncdrive.go | 4 +- internal/manual/chat/commands.md | 2 +- .../manual/chat/coordination-and-workers.md | 2 +- .../manual/chat/relay-limits-and-refusals.md | 2 +- .../chat/relay-what-it-stores-and-sees.md | 10 +- internal/syncsetup/storage.go | 72 +++++++--- internal/syncsetup/storage_optin_test.go | 30 +++- .../syncsetup/storage_reads_linux_test.go | 17 ++- internal/syncsetup/storage_refresh.go | 55 ++++++-- internal/syncsetup/storage_refresh_test.go | 128 ++++++++++++++++++ internal/tui3/storage_optin_test.go | 22 ++- internal/tui3/syncspace.go | 12 +- 14 files changed, 348 insertions(+), 60 deletions(-) diff --git a/cmd/codeaf/approvals.go b/cmd/codeaf/approvals.go index b9e18e73e7..d1cb36a5de 100644 --- a/cmd/codeaf/approvals.go +++ b/cmd/codeaf/approvals.go @@ -281,7 +281,7 @@ func (d *approvalsDoor) SyncService() string { return syncsetup.Resolve(d.dir).U // RelayStorage reads this computer's account without creating keys or asking the network. func (d *approvalsDoor) RelayStorage() (code, state string) { relay := syncsetup.Resolve(d.dir) - if !relay.On() || !syncsetup.IsHostedRelay(relay.URL) || !syncsetup.MayTalk(d.dir) { + if !relay.On() || !syncsetup.MayTalk(d.dir) { return "", "" } id, err := identity.Load(d.dir) @@ -289,5 +289,8 @@ func (d *approvalsDoor) RelayStorage() (code, state string) { return "", "" } code = reqsign.IDOf(id.PublicKey()) + if !syncsetup.StorageGated(d.dir, relay.URL, code) { + return "", "" + } return code, syncsetup.StorageState(d.dir, relay.URL, code) } diff --git a/cmd/codeaf/storage_optin_test.go b/cmd/codeaf/storage_optin_test.go index 1ae6e99ed4..c099ac9d69 100644 --- a/cmd/codeaf/storage_optin_test.go +++ b/cmd/codeaf/storage_optin_test.go @@ -130,6 +130,53 @@ func TestStorageOptInSettingsOnlyPairedHosted(t *testing.T) { } } +func TestStorageOptInSettingsRelayAnswerDecides(t *testing.T) { + celltest.Available(t) + dir := t.TempDir() + t.Setenv(home.EnvVar, dir) + id, err := identity.Ensure(dir) + if err != nil { + t.Fatal(err) + } + var requests atomic.Int32 + var absent atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + answer := `{"storage":"off"}` + if absent.Load() { + answer = `{"objects":0}` + } + _, _ = w.Write([]byte(answer)) + })) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + door := newApprovalsDoor(dir) + if code, _ := door.RelayStorage(); code != "" { + t.Fatal("unknown custom relay has row") + } + if _, err := chatSyncStats(t.Context()); err != nil { + t.Fatal(err) + } + if code, state := door.RelayStorage(); code != id.ID() || state != "off" { + t.Fatalf("reported row: %s %s", code, state) + } + absent.Store(true) + if _, err := chatSyncStats(t.Context()); err != nil { + t.Fatal(err) + } + if code, _ := door.RelayStorage(); code != "" { + t.Fatal("no-field answer retained row") + } + for range 3 { + if _, err := chatSyncStats(t.Context()); err != nil { + t.Fatal(err) + } + } + if requests.Load() != 2 { + t.Fatalf("no-field requests: %d", requests.Load()) + } +} + func TestStorageOptInQuitReceiptAfterRevocation(t *testing.T) { armFinalSaving() retainLocalDurability(func() bool { return true }) diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index 18a6a09456..69f9911333 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -232,7 +232,7 @@ func chatSyncStats(ctx context.Context) (blobstore.Counts, error) { if err != nil { return blobstore.Counts{}, err } - if syncsetup.IsHostedRelay(s.Relay) && s.StorageAllowed() { + if !syncsetup.StorageNeedsRefresh(s.Home, s.Relay, s.Identity.ID()) { return syncsetup.StorageCounts(s.Home, s.Relay, s.Identity.ID()), nil } before := s.StorageAllowed() @@ -240,7 +240,7 @@ func chatSyncStats(ctx context.Context) (blobstore.Counts, error) { if err == nil && !before && s.StorageAllowed() { syncDrives.storageOpened() } - if syncsetup.IsHostedRelay(s.Relay) && counts.Storage != "on" { + if syncsetup.StorageGated(s.Home, s.Relay, s.Identity.ID()) && counts.Storage != "on" { counts.Storage = "off" } return counts, err diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 391c03f31c..b9d6a3bb62 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1962,7 +1962,7 @@ the Memories tab. Tasks controls delegated work; AI teams sets defaults for groups of AI chats. Permissions controls approvals. Spending holds limits. Connections holds accounts, keys, web search and remote connection settings. Devices lists your paired computers, renames this one, removes another, and starts -`/pair` from `Add another device`. A paired hosted account also has `relay storage`: when off, Enter opens a request and shows its code and link, including from Settings search. Self-hosted storage needs no request. +`/pair` from `Add another device`. A paired account on a relay that reports storage permission also has `relay storage`: when off, Enter opens a request and shows its code and link, including from Settings search. Self-hosted storage needs no request. Privacy holds history, drafts and data-sharing choices. Old saved keys and values are unchanged. Search accepts old labels and keys, diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index 3cd5c430d9..b9f05db4b0 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -90,7 +90,7 @@ The sync connection carries presence hints and committed lease acknowledgments. ## Does syncing keep sending requests when nothing is happening? -A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round when relay storage is on. Hosted storage is off by default while privacy terms are being finished; request access in Settings → Devices → relay storage → enter. Pairing, presence, directory coordination and live `--at` remain available while storage is off, but new chat/file uploads and vault sharing wait. A self-hosted relay with `--store` needs no approval. While hosted storage is off or unknown, each process makes one stats request at startup and every ten minutes to ask whether the account has been approved. On a relay with a learner door, over ten idle minutes, caught-up open homes with storage on make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. +A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round when relay storage is on. Hosted storage is off by default while privacy terms are being finished; request access in Settings → Devices → relay storage → enter. Pairing, presence, directory coordination and live `--at` remain available while storage is off, but new chat/file uploads and vault sharing wait. A self-hosted relay with `--store` needs no approval. A relay that reports storage off gets one stats request at startup and every ten minutes; unknown hosted addresses use that same closed default. An unanswered custom address gets one startup request. An answer without storage keeps storage on and stops probing; known storage on makes no permission requests. On a relay with a learner door, over ten idle minutes, caught-up open homes with storage on make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. ## How does an idle home notice offline computers and wake coordination? diff --git a/internal/manual/chat/relay-limits-and-refusals.md b/internal/manual/chat/relay-limits-and-refusals.md index 7c75369877..693786df21 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -49,7 +49,7 @@ Pairing has limits on both kinds of relay: 10 new codes in an hour for one netwo ## Relay storage is off — approval, retries and chats staying here -**Relay storage is off** (`403`, code `storage_off`). The chat says `relay storage is off for this account, so chats stay on this computer; request access in Settings under Devices`, once while it waits. No upload requests are made when this computer knows storage is off. A refresh that sees approval wakes sync automatically. This is separate from every full refusal; reads, pairing and live `--at` still work. Running uploads retry through the local gate on their normal backoff, capped at 60 seconds. One process-wide waiter starts chats waiting for storage within a minute of another process learning approval. Approval discovery runs at launch, after `/status`, and every ten minutes while hosted storage is off; serving computers use it too. It stops polling once storage is on. +**Relay storage is off** (`403`, code `storage_off`). The chat says `relay storage is off for this account, so chats stay on this computer; request access in Settings under Devices`, once while it waits. No upload requests are made when this computer knows storage is off. A refresh that sees approval wakes sync automatically. This is separate from every full refusal; reads, pairing and live `--at` still work. Running uploads retry through the local gate on their normal backoff, capped at 60 seconds. One process-wide waiter starts chats waiting for storage within a minute of another process learning approval. Approval discovery runs at launch, after `/status`, and every ten minutes while a relay that reports storage is off (or a hosted address is unknown); serving computers use it too. It stops polling once storage is on. ## What do I see when the relay says no diff --git a/internal/manual/chat/relay-what-it-stores-and-sees.md b/internal/manual/chat/relay-what-it-stores-and-sees.md index 40dc351509..df4b775f99 100644 --- a/internal/manual/chat/relay-what-it-stores-and-sees.md +++ b/internal/manual/chat/relay-what-it-stores-and-sees.md @@ -8,9 +8,15 @@ Pairing, seeing your devices and `--at` still work while the other computer is o After pairing, open **Settings → Devices → relay storage**. When it says **off**, press **enter** (`enter request access`). This opens a pre-filled GitHub issue; the panel also shows the request code and link if no browser opens. The request code is your identity id (`id_` followed by 32 hex digits). It is safe to post publicly and grants nothing by itself. -Serving processes and chat windows check at launch, after `/status` in chat, and every ten minutes while hosted storage is off. Once approval is read, waiting chats and uploads start within a minute without a restart. Opening a chat never waits for a relay reading. `/status` shows the dim line `relay storage off` while hosted storage is off. Revocation stops new uploads and leaves earlier frames readable. +Revocation stops new uploads and leaves earlier frames readable. A self-hosted `codeaf relay --store ` keeps storage on and needs no request. Approved hosted accounts still have the limits on *Relay limits and refusals*. -A self-hosted `codeaf relay --store ` keeps storage on and needs no request. Approved hosted accounts still have the limits on *Relay limits and refusals*. +## How relay storage permission is discovered — custom addresses, startup and status + +The relay's answer decides, whatever its hostname. When it reports storage permission, Settings shows the relay storage row and `/status` shows the dim line `relay storage off` while off. A relay that answers without storage permission, including the self-hosted Go relay and older hosted Workers, keeps storage on and has no row or line. + +Before any answer, the three hosted addresses on *Hosted relay or your own relay* start closed; other addresses start open. A paired process probes an unanswered custom address once at startup. A successful answer is remembered for this account and relay, including when the field is absent. Sync off and unpaired homes make no requests. + +Serving processes and chat windows check at launch, after `/status` in chat, and every ten minutes while a relay that reports storage is off (or a hosted address is still unknown). Storage on or an answer without the field stops requests. Once approval is read, waiting chats and uploads start within a minute without a restart. Opening a chat never waits for a relay reading. ## Does the relay store my chats — what is on the relay's disk diff --git a/internal/syncsetup/storage.go b/internal/syncsetup/storage.go index 1e51d4fa84..b8f89cfd8d 100644 --- a/internal/syncsetup/storage.go +++ b/internal/syncsetup/storage.go @@ -23,9 +23,10 @@ const StoragePollEvery = time.Minute const StorageRequestURL = "https://github.com/Agent-Field/CodeAF/issues/new" type storageReading struct { - Relay string `json:"relay"` - Identity string `json:"identity"` - Storage string `json:"storage"` + Relay string `json:"relay"` + Identity string `json:"identity"` + Storage string `json:"storage"` + ReportsStorage *bool `json:"reports_storage,omitempty"` } // IsHostedRelay recognizes all hosted channels, including an explicitly saved URL. @@ -39,35 +40,60 @@ func IsHostedRelay(raw string) bool { return false } -// StorageState reads only local state. An unknown hosted permission is closed; -// older and self-hosted relays retain their existing enabled storage policy. -func StorageState(home, relay, identity string) string { +// storagePolicy distinguishes a cached answer without storage from no answer. +// A legacy on reading at a custom address could have come from the old +// no-field default; it needs one probe before claiming a permission row. +func storagePolicy(home, relay, identity string) (state string, gating, answered bool) { raw, err := os.ReadFile(filepath.Join(home, StorageFile)) var reading storageReading if err == nil && json.Unmarshal(raw, &reading) == nil && - normalizeStorageURL(reading.Relay) == normalizeStorageURL(relay) && reading.Identity == identity && - (reading.Storage == "on" || reading.Storage == "off") { - return reading.Storage + normalizeStorageURL(reading.Relay) == normalizeStorageURL(relay) && reading.Identity == identity { + switch reading.Storage { + case "on", "off": + if reading.ReportsStorage != nil { + if !*reading.ReportsStorage { + return "on", false, true + } + return reading.Storage, true, true + } + if reading.Storage == "off" || IsHostedRelay(relay) { + return reading.Storage, true, true + } + return "on", false, false + case "": + return "on", false, true + } } if IsHostedRelay(relay) { - return "off" + return "off", true, false } - return "on" + return "on", false, false +} + +// StorageState reads only local state. Before an answer, hosted services are +// closed and other relays are open; afterwards the relay's answer decides. +func StorageState(home, relay, identity string) string { + state, _, _ := storagePolicy(home, relay, identity) + return state } -// SaveStorage remembers a successful stats answer atomically. Missing state -// means closed on our hosts and enabled on a relay that predates this field. +// StorageGated reports whether this relay exposes account storage permission. +func StorageGated(home, relay, identity string) bool { + _, gating, _ := storagePolicy(home, relay, identity) + return gating +} + +// SaveStorage remembers a successful stats answer atomically. An empty state +// records a relay without storage permission, so it need not be probed again. func SaveStorage(home, relay, identity, state string) error { - if state != "on" && state != "off" { - state = "on" - if IsHostedRelay(relay) { - state = "off" - } + reports := state != "" + if reports && state != "on" { + state = "off" } if err := os.MkdirAll(home, 0700); err != nil { return err } - raw, err := json.Marshal(storageReading{normalizeStorageURL(relay), identity, state}) + raw, err := json.Marshal(storageReading{Relay: normalizeStorageURL(relay), Identity: identity, Storage: state, ReportsStorage: &reports}) if err != nil { return err } @@ -86,11 +112,15 @@ func SaveStorage(home, relay, identity, state string) error { if err := os.Rename(f.Name(), filepath.Join(home, StorageFile)); err != nil { return err } - storageSaved(home, relay, identity, state) + effective := state + if effective == "" { + effective = "on" + } + storageSaved(home, relay, identity, effective) return nil } -// GateStore keeps every hosted upload door on the same local permission check. +// GateStore keeps every relay upload door on the same local permission check. func GateStore(store *blobstore.HTTP, home, relay, identity string) *blobstore.HTTP { store.CanStore = func() bool { return StorageState(home, relay, identity) == "on" } store.OnStorageOff = func() { _ = SaveStorage(home, relay, identity, "off") } diff --git a/internal/syncsetup/storage_optin_test.go b/internal/syncsetup/storage_optin_test.go index ae19f4c18f..703d62ea92 100644 --- a/internal/syncsetup/storage_optin_test.go +++ b/internal/syncsetup/storage_optin_test.go @@ -66,8 +66,8 @@ func TestStorageOptInCacheAndUploadDoors(t *testing.T) { t.Fatal("revoked store made a request") } _ = SaveStorage(home, ProductionRelayURL, id, "") - if StorageState(home, ProductionRelayURL, id) != "off" { - t.Fatal("missing hosted answer enabled storage") + if StorageState(home, ProductionRelayURL, id) != "on" { + t.Fatal("no-field hosted answer must enable legacy storage") } _ = os.WriteFile(filepath.Join(home, StorageFile), []byte("{"), 0600) if StorageState(home, ProductionRelayURL, id) != "off" { @@ -89,9 +89,10 @@ func TestStorageOptInDriveStopsBeforeAnyNetworkOrEngineWork(t *testing.T) { } func TestStorageOptInWireRefusalClosesAllLocalUploaders(t *testing.T) { + const relay = "http://127.0.0.1:18831" home := t.TempDir() id := "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - if err := SaveStorage(home, ProductionRelayURL, id, "on"); err != nil { + if err := SaveStorage(home, relay, id, "on"); err != nil { t.Fatal(err) } calls := 0 @@ -99,8 +100,8 @@ func TestStorageOptInWireRefusalClosesAllLocalUploaders(t *testing.T) { calls++ return &http.Response{StatusCode: 403, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"err":"storage_off"}`))}, nil })} - first := GateStore(blobstore.NewHTTP(ProductionRelayURL, func(*http.Request, []byte) {}, hc), home, ProductionRelayURL, id) - second := GateStore(blobstore.NewHTTP(ProductionRelayURL, func(*http.Request, []byte) {}, hc), home, ProductionRelayURL, id) + first := GateStore(blobstore.NewHTTP(relay, func(*http.Request, []byte) {}, hc), home, relay, id) + second := GateStore(blobstore.NewHTTP(relay, func(*http.Request, []byte) {}, hc), home, relay, id) _, _ = first.PutFrame(t.Context(), nil) if _, err := second.PutFrame(t.Context(), nil); !errors.Is(err, wireauth.ErrStorageOff) || calls != 1 { t.Fatalf("refusal was not remembered: %v requests=%d", err, calls) @@ -192,3 +193,22 @@ func TestStorageOptInRotationBindingRelay(t *testing.T) { } } } + +func TestStorageOptInLegacyCustomOnCacheDoesNotInventCapability(t *testing.T) { + home := t.TempDir() + const relay = "http://127.0.0.1:18831" + const id = "id_account" + raw := `{"relay":"http://127.0.0.1:18831","identity":"id_account","storage":"on"}` + if err := os.WriteFile(filepath.Join(home, StorageFile), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if StorageGated(home, relay, id) || StorageState(home, relay, id) != "on" || !StorageNeedsRefresh(home, relay, id) { + t.Fatal("legacy on cache cannot prove a custom relay reported storage") + } + if err := SaveStorage(home, relay, id, "on"); err != nil { + t.Fatal(err) + } + if !StorageGated(home, relay, id) || StorageNeedsRefresh(home, relay, id) { + t.Fatal("explicit on answer must be gating and idle") + } +} diff --git a/internal/syncsetup/storage_reads_linux_test.go b/internal/syncsetup/storage_reads_linux_test.go index 889fabe430..273fedcbf4 100644 --- a/internal/syncsetup/storage_reads_linux_test.go +++ b/internal/syncsetup/storage_reads_linux_test.go @@ -59,11 +59,11 @@ func watchStorageReads(t *testing.T, home string) func() map[string]int { func TestStorageOptInSharedWaiterWork(t *testing.T) { for _, override := range []bool{false, true} { for _, n := range []int{1, 10, 50} { - for _, mode := range []string{"on", "off", "self-hosted"} { + for _, mode := range []string{"on", "off", "custom-off", "self-hosted"} { t.Run(fmt.Sprintf("env=%v/%s/%d", override, mode, n), func(t *testing.T) { home := t.TempDir() relay := ProductionRelayURL - if mode == "self-hosted" { + if mode == "self-hosted" || mode == "custom-off" { relay = "http://own.test" } t.Setenv(URLVar, "") @@ -83,7 +83,10 @@ func TestStorageOptInSharedWaiterWork(t *testing.T) { } state := mode if state == "self-hosted" { - state = "on" + state = "" + } + if mode == "custom-off" { + state = "off" } if err := SaveStorage(home, relay, id.ID(), state); err != nil { t.Fatal(err) @@ -102,7 +105,7 @@ func TestStorageOptInSharedWaiterWork(t *testing.T) { return func() bool { next = nil; return true } } woke := 0 - if mode == "off" { + if mode == "off" || mode == "custom-off" { for i := 0; i < n; i++ { p.waiting[uint64(i)] = func() { woke++ } } @@ -118,7 +121,7 @@ func TestStorageOptInSharedWaiterWork(t *testing.T) { fn() } wantReads, wantTimers := map[string]int{}, 0 - if mode == "off" { + if mode == "off" || mode == "custom-off" { wantReads[identity.File], wantReads[StorageFile] = 1, 1 if !override { wantReads[SavedURLFile] = 1 @@ -130,9 +133,9 @@ func TestStorageOptInSharedWaiterWork(t *testing.T) { t.Fatalf("reads=%v want=%v timers=%d requests=%d", actual, wantReads, timers, requests) } t.Logf("env=%v chats=%d state=%s file-reads/min=%v timer-arms/min=%d relay-requests=%d", override, n, mode, actual, timers, requests) - if mode == "off" { + if mode == "off" || mode == "custom-off" { // A raw file change models another process sharing this home. - raw := fmt.Sprintf(`{"relay":%q,"identity":%q,"storage":"on"}`, relay, id.ID()) + raw := fmt.Sprintf(`{"relay":%q,"identity":%q,"storage":"on","reports_storage":true}`, relay, id.ID()) if err := os.WriteFile(filepath.Join(home, StorageFile), []byte(raw), 0600); err != nil { t.Fatal(err) } diff --git a/internal/syncsetup/storage_refresh.go b/internal/syncsetup/storage_refresh.go index a089e611b3..78cf91d054 100644 --- a/internal/syncsetup/storage_refresh.go +++ b/internal/syncsetup/storage_refresh.go @@ -32,6 +32,7 @@ type storageProcess struct { dirty bool counts blobstore.Counts countsRelay, countsIdentity string + probeRelay, probeIdentity string // These seams count actual scheduled work without relying on elapsed time. after func(time.Duration, func()) func() bool read func() (string, string, string) @@ -49,14 +50,18 @@ func storageProcessFor(home string) *storageProcess { p.after = func(d time.Duration, f func()) func() bool { return time.AfterFunc(d, f).Stop } p.read = func() (string, string, string) { relay := Resolve(home) - if !relay.On() || !IsHostedRelay(relay.URL) || identity.Solo(home) { + if !relay.On() || identity.Solo(home) { return "", "", "" } id, err := identity.Load(home) if err != nil { return "", "", "" } - return relay.URL, id.ID(), StorageState(home, relay.URL, id.ID()) + state, gating, answered := storagePolicy(home, relay.URL, id.ID()) + if !answered && !gating { + state = "" + } + return relay.URL, id.ID(), state } p.fetch = func(ctx context.Context) error { s, ok, err := Open(home) @@ -72,8 +77,9 @@ func storageProcessFor(home string) *storageProcess { } // StartStorageRefresh shares one asynchronous signed reading at startup and -// every ten minutes while hosted storage is unknown or off. Callers join its -// lifetime independently; chat opening never waits for a network answer. +// every ten minutes while a gating relay is unknown or off. Other relays get +// one discovery request until an answer establishes their storage policy. +// Callers join its lifetime independently; chat opening never waits for a network answer. func StartStorageRefresh(ctx context.Context, home string, initial ...bool) func() { p := storageProcessFor(home) p.mu.Lock() @@ -115,10 +121,11 @@ func (s *Sync) RefreshStorage(ctx context.Context) (blobstore.Counts, error) { if !ok { return blobstore.Counts{}, nil } + p := storageProcessFor(s.Home) + p.mu.Lock() + p.probeRelay, p.probeIdentity = normalizeStorageURL(s.Relay), s.Identity.ID() + p.mu.Unlock() counts, err := store.Stats(ctx) - storageProcesses.Lock() - p := storageProcesses.homes[s.Home] - storageProcesses.Unlock() if p != nil { p.mu.Lock() p.lastRefresh = p.now() @@ -191,8 +198,14 @@ func (p *storageProcess) check(refresh bool) { p.active = true p.disarm() p.mu.Unlock() - relay, _, state := p.read() - if relay != "" && state != "on" && refresh { + relay, id, state := p.read() + p.mu.Lock() + ask := state == "off" || state == "" && (p.probeRelay != normalizeStorageURL(relay) || p.probeIdentity != id) + if relay != "" && ask && refresh { + p.probeRelay, p.probeIdentity = normalizeStorageURL(relay), id + } + p.mu.Unlock() + if relay != "" && ask && refresh { ctx, cancel := context.WithTimeout(p.ctx, requestTimeout) _ = p.fetch(ctx) cancel() @@ -212,7 +225,7 @@ func (p *storageProcess) check(refresh bool) { delete(p.waiting, id) } } - if relay != "" && state != "on" && (p.users > 0 || len(p.waiting) > 0) { + if relay != "" && state == "off" && (p.users > 0 || len(p.waiting) > 0) { wait := StorageRefreshEvery if p.users > 0 && !p.lastRefresh.IsZero() { wait = max(time.Millisecond, StorageRefreshEvery-p.now().Sub(p.lastRefresh)) @@ -293,3 +306,25 @@ func StorageCounts(home, relay, identity string) blobstore.Counts { counts.Storage = "on" return counts } + +// StorageNeedsRefresh keeps startup and /status on the same request policy. +// It reserves an unanswered custom relay probe atomically across callers. +// An unanswered custom relay is probed at most once in this process, even if +// that request fails. Hosted unknown/off permissions retain approval discovery. +func StorageNeedsRefresh(home, relay, id string) bool { + state, gating, answered := storagePolicy(home, relay, id) + if state == "off" { + return true + } + if answered || gating { + return false + } + p := storageProcessFor(home) + p.mu.Lock() + defer p.mu.Unlock() + if p.probeRelay == normalizeStorageURL(relay) && p.probeIdentity == id { + return false + } + p.probeRelay, p.probeIdentity = normalizeStorageURL(relay), id + return true +} diff --git a/internal/syncsetup/storage_refresh_test.go b/internal/syncsetup/storage_refresh_test.go index cb3db77a0f..6e67a70bd2 100644 --- a/internal/syncsetup/storage_refresh_test.go +++ b/internal/syncsetup/storage_refresh_test.go @@ -4,11 +4,14 @@ import ( "context" "errors" "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell/celltest" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/reqsign" "io" "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" "time" ) @@ -106,3 +109,128 @@ func TestStorageOptInSignedRefreshUpdatesWaitersAndRunningDrives(t *testing.T) { } } } + +// Exercise the real signed stats wire at an arbitrary relay address. +func TestStorageOptInRelayAnswerDecides(t *testing.T) { + celltest.Available(t) + for _, answer := range []string{`{"storage":"off"}`, `{"storage":"on"}`, `{"objects":0}`} { + t.Run(answer, func(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/v1/store/stats" || r.Header.Get("Codeaf-Sig") == "" { + t.Errorf("unexpected stats: %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(answer)) + })) + defer srv.Close() + t.Setenv(URLVar, srv.URL) + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + if StorageState(home, srv.URL, id.ID()) != "on" { + t.Fatal("unknown custom relay must start open") + } + p := storageProcessFor(home) + p.users = 1 + var next func() + p.after = func(_ time.Duration, fn func()) func() bool { + next = fn + return func() bool { next = nil; return true } + } + defer p.disarm() + p.check(true) + if requests.Load() != 1 { + t.Fatalf("startup requests=%d want 1", requests.Load()) + } + if strings.Contains(answer, `"off"`) { + if StorageState(home, srv.URL, id.ID()) != "off" || next == nil { + t.Fatal("relay's off answer must close gate and continue refresh") + } + } else { + if StorageState(home, srv.URL, id.ID()) != "on" || next != nil { + t.Fatal("no-field answer must leave gate open without a timer") + } + p.check(true) + if requests.Load() != 1 { + t.Fatalf("repeated no-field probe: %d", requests.Load()) + } + // A new process reading the persisted answer also remains idle. + fresh := &storageProcess{ctx: t.Context(), users: 1, read: p.read, fetch: p.fetch, after: p.after, now: time.Now} + fresh.check(true) + if requests.Load() != 1 { + t.Fatal("no-field answer was not cached") + } + } + }) + } +} + +func TestStorageOptInUnansweredCustomProbeFailure(t *testing.T) { + celltest.Available(t) + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + t.Setenv(URLVar, srv.URL) + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + p := storageProcessFor(home) + p.users = 1 + p.after = func(time.Duration, func()) func() bool { + t.Error("failed custom probe armed a timer") + return func() bool { return true } + } + for range 3 { + p.check(true) + } + if requests.Load() != 1 || StorageState(home, srv.URL, id.ID()) != "on" || StorageNeedsRefresh(home, srv.URL, id.ID()) { + t.Fatalf("failed discovery requests=%d state=%s", requests.Load(), StorageState(home, srv.URL, id.ID())) + } +} + +func TestStorageOptInDiscoveryQuietHomes(t *testing.T) { + for _, mode := range []string{"sync-off", "unpaired", "no-identity"} { + t.Run(mode, func(t *testing.T) { + home := t.TempDir() + t.Setenv(URLVar, "http://127.0.0.1:1") + if mode == "sync-off" { + t.Setenv(URLVar, "off") + if _, err := identity.Ensure(home); err != nil { + t.Fatal(err) + } + } + if mode == "unpaired" { + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + } + p := storageProcessFor(home) + p.users = 1 + p.fetch = func(context.Context) error { t.Error("quiet home requested stats"); return nil } + p.after = func(time.Duration, func()) func() bool { + t.Error("quiet home armed timer") + return func() bool { return true } + } + p.check(true) + }) + } +} + +func TestStorageOptInCustomProbeSharedByCallers(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { t.Fatal(err) } + const relay = "http://127.0.0.1:18831" + for i:=0;i<50;i++ { + want := i==0 + if got:=StorageNeedsRefresh(home,relay,id.ID());got!=want { t.Fatalf("caller %d requests=%v want=%v",i,got,want) } + } +} diff --git a/internal/tui3/storage_optin_test.go b/internal/tui3/storage_optin_test.go index 59f360d9b8..9da80ce435 100644 --- a/internal/tui3/storage_optin_test.go +++ b/internal/tui3/storage_optin_test.go @@ -23,7 +23,7 @@ func TestStorageOptInDevicesRequestAndStatus(t *testing.T) { code := "id_" + strings.Repeat("a", 32) door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} a, r := openSettingsDevices(t, door, time.Now()) - a.syncService = syncsetup.ProductionRelayURL + a.syncService = "http://127.0.0.1:18831" found := false for at, item := range a.sheet.items { if item.dev != nil && item.dev.storage { @@ -96,7 +96,7 @@ func TestStorageOptInSearchRequestShowsFallbackFrame(t *testing.T) { code := "id_" + strings.Repeat("b", 32) door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} a, r := openSettingsDevices(t, door, time.Now()) - a.syncService = syncsetup.ProductionRelayURL + a.syncService = "http://127.0.0.1:18831" old := processOpener processOpener = func(string) error { return errors.New("no browser") } t.Cleanup(func() { processOpener = old }) @@ -119,7 +119,7 @@ func TestStorageOptInRequestFallbackNarrowPendingDevicesFrame(t *testing.T) { code := "id_" + strings.Repeat("c", 32) door := &storageApprovals{fakeApprovals: devicesFake(), code: code, state: "off"} a, r := openSettingsDevices(t, door, time.Now()) - a.syncService = syncsetup.ProductionRelayURL + a.syncService = "http://127.0.0.1:18831" a.Update(tea.WindowSizeMsg{Width: 56, Height: 40}) for i, item := range a.sheet.items { if item.dev != nil && item.dev.storage { @@ -138,3 +138,19 @@ func TestStorageOptInRequestFallbackNarrowPendingDevicesFrame(t *testing.T) { t.Fatalf("narrow request frame: %s", plain(frame(a))) } } + +func TestStorageOptInStatusPrefersCurrentPermission(t *testing.T) { + door := &storageApprovals{fakeApprovals: devicesFake(), code: "id_account", state: "off"} + a := newTestApp(&fakeAgent{model: "m"}) + a.approvals = door + a.syncService = "http://127.0.0.1:18831" + a.syncSpace = blobstore.Counts{Storage: "on"} + if !strings.Contains(a.statusText(), "relay storage") { + t.Fatal("cached refusal hidden by earlier usage answer") + } + door.state = "on" + a.syncSpace.Storage = "off" + if strings.Contains(a.statusText(), "relay storage") { + t.Fatal("cached approval hidden by earlier usage answer") + } +} diff --git a/internal/tui3/syncspace.go b/internal/tui3/syncspace.go index 831ea467bf..768c098414 100644 --- a/internal/tui3/syncspace.go +++ b/internal/tui3/syncspace.go @@ -8,7 +8,6 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/chatlist" - "github.com/Agent-Field/codeaf/internal/syncsetup" ) // syncSpaceWithin keeps an optional relay reading from outliving its usefulness. @@ -78,12 +77,13 @@ func (a *app) syncSpaceWord() string { // Failed readings retain only the previous permission, never stale usage totals. func (a *app) relayStorageOff() bool { - if !syncsetup.IsHostedRelay(a.syncService) { + if a.syncService == "" || a.syncService == "off" { return false } - if a.syncSpace.Storage != "" { - return a.syncSpace.Storage == "off" + // Another local process or a refused upload may have refreshed permission + // after the last usage reading was painted. + if _, state := a.relayStorageAccount(); state != "" { + return state == "off" } - _, state := a.relayStorageAccount() - return state == "off" + return a.syncSpace.Storage == "off" } From cf6aa6c88a9cdfa803dbf33974b092e3fd48eeef Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 22:24:04 -0500 Subject: [PATCH 07/18] Format the storage refresher test Co-Authored-By: Claude Opus 5.5 --- internal/syncsetup/storage_refresh_test.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/syncsetup/storage_refresh_test.go b/internal/syncsetup/storage_refresh_test.go index 6e67a70bd2..9e6a45da49 100644 --- a/internal/syncsetup/storage_refresh_test.go +++ b/internal/syncsetup/storage_refresh_test.go @@ -225,12 +225,16 @@ func TestStorageOptInDiscoveryQuietHomes(t *testing.T) { } func TestStorageOptInCustomProbeSharedByCallers(t *testing.T) { - home := t.TempDir() - id, err := identity.Ensure(home) - if err != nil { t.Fatal(err) } - const relay = "http://127.0.0.1:18831" - for i:=0;i<50;i++ { - want := i==0 - if got:=StorageNeedsRefresh(home,relay,id.ID());got!=want { t.Fatalf("caller %d requests=%v want=%v",i,got,want) } - } + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + const relay = "http://127.0.0.1:18831" + for i := 0; i < 50; i++ { + want := i == 0 + if got := StorageNeedsRefresh(home, relay, id.ID()); got != want { + t.Fatalf("caller %d requests=%v want=%v", i, got, want) + } + } } From 006aed0ff610e5e2e54ac8bfc0cc238f580e183b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 22:26:24 -0500 Subject: [PATCH 08/18] Answer "share a file with it" from the attaching page The new relay-storage sections say share and files often enough that the plain question reached the sync pages instead; a short section in the asker's words points at /attach. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/attaching-files.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md index 683f4067af..f4c7c44ece 100644 --- a/internal/manual/chat/attaching-files.md +++ b/internal/manual/chat/attaching-files.md @@ -50,6 +50,15 @@ Inside the paste editor, `ctrl+x` does the same thing. There is no recovery afte is discarded, though ordinary unsent draft recovery still applies to the rest of the message. +## Share a file with it — give codeaf a file to read + +To share a file with codeaf, attach it to your next message: `/attach ` (or `/upload +`, the same command) puts the file on the tray above the message box, and it goes with +the next thing you send. Dropping the file onto the terminal does the same. A bare `/attach` +opens a browser so you can find the file and look at it first. This is how you hand codeaf a +log, a CSV, a PDF or a screenshot; "Send a file with your message" below has the path rules +and the tray, and "Every refusal /attach can give you" has what it says when it cannot. + ## Send a file with your message — /attach, how do I share a file with codeaf, how do I send it a photo or an image `/attach ` puts an ordinary file — a log, a CSV, a PDF, a stack trace you saved — From 3f7a69e6ec8d1bb746bb8dd402e7a286bfe7656e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 22:29:41 -0500 Subject: [PATCH 09/18] Release storage-wake locks from a defer and read the admin key path through env Both storage-opened paths now copy what they need inside a short locked helper that unlocks from a defer, so an absorbed panic cannot leave the drive book or a live drive locked; the admin command reads CODEAF_RELAY_ADMIN_KEY through the one environment door. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf-relay-admin/main.go | 3 ++- cmd/codeaf/syncdrive.go | 14 ++++++++++---- cmd/codeaf/syncdrive_live.go | 19 ++++++++++++------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/cmd/codeaf-relay-admin/main.go b/cmd/codeaf-relay-admin/main.go index 171023c8c1..86e9e93376 100644 --- a/cmd/codeaf-relay-admin/main.go +++ b/cmd/codeaf-relay-admin/main.go @@ -20,6 +20,7 @@ import ( "strings" "time" + "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/syncsetup" "github.com/Agent-Field/codeaf/internal/wireauth" ) @@ -63,7 +64,7 @@ func run(ctx context.Context, args []string, out io.Writer, hc *http.Client) err flags := flag.NewFlagSet("codeaf-relay-admin", flag.ContinueOnError) flags.SetOutput(io.Discard) relay := flags.String("relay", "prod", "relay name or URL") - keyPath := flags.String("key", os.Getenv("CODEAF_RELAY_ADMIN_KEY"), "private seed file") + keyPath := flags.String("key", env.Get("CODEAF_RELAY_ADMIN_KEY"), "private seed file") if err := flags.Parse(args); err != nil { return err } diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index 69f9911333..258b5bf1ee 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -251,13 +251,19 @@ func (b *driveBook) storageOpened() { if b == nil { return } + for _, d := range b.openDrives() { + d.storageOpened() + } +} + +// openDrives copies the open drives under the lock, so waking them happens +// outside it. +func (b *driveBook) openDrives() []*liveDrive { b.mu.Lock() + defer b.mu.Unlock() drives := make([]*liveDrive, 0, len(b.drive)) for _, d := range b.drive { drives = append(drives, d) } - b.mu.Unlock() - for _, d := range drives { - d.storageOpened() - } + return drives } diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index 1929f68e81..861ef9ac8a 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -332,18 +332,23 @@ func (l *liveDrive) catchUpAfter(used *syncsetup.Drive) { // storageOpened shortens only a capacity wait; it never replaces an active writer. func (l *liveDrive) storageOpened() { + if d := l.endStorageWait(); d != nil { + d.StorageOpened() + } else { + l.try() + } +} + +// endStorageWait clears the storage wait and its retry timer under the lock and +// answers the running drive, if there is one, so waking it happens outside. +func (l *liveDrive) endStorageWait() *syncsetup.Drive { l.mu.Lock() - d := l.drive + defer l.mu.Unlock() l.storageWaiting = false if l.stopRetry != nil { l.stopRetry() l.stopRetry = nil } l.retryAt = time.Time{} - l.mu.Unlock() - if d != nil { - d.StorageOpened() - } else { - l.try() - } + return l.drive } From 34ddf2b2f9a39ca86063cce6c876a061a9732c11 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 23:18:00 -0500 Subject: [PATCH 10/18] Say "sync storage" to people, and say it once where it matters The Settings row, the /status line, the refusal, the quit receipt and the request issue now say "sync storage", the name the Devices tab's sync service already uses, and the device pages keep their vocabulary law. The manual's storage requirement is one full section per page plus one short sentence only where a section claims something travels between computers, instead of the same paragraph pasted into almost every section. The team-pairing test now states its storage reading and covers paired with storage off. A serving home starts asking about approval only after its first eligible state round, so a revoked device still makes one request and stops, and a revoked or deleted answer ends the asking for good. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_pair_test.go | 13 +- cmd/codeaf/final_save.go | 2 +- cmd/codeaf/storage_optin_test.go | 2 +- internal/chatlist/copy.go | 2 +- .../chat/a-dev-box-on-your-own-railway.md | 6 +- .../manual/chat/add-or-remove-a-computer.md | 32 ++--- internal/manual/chat/commands.md | 12 +- .../continuing-a-chat-on-another-computer.md | 112 +++++++++--------- .../manual/chat/conversations-and-teams.md | 18 +-- .../manual/chat/coordination-and-workers.md | 8 +- internal/manual/chat/getting-started.md | 6 +- internal/manual/chat/home.md | 34 ++++-- internal/manual/chat/how-tasks-run.md | 2 - internal/manual/chat/keys.md | 4 +- .../manual/chat/lock-out-a-lost-computer.md | 14 +-- internal/manual/chat/losing-every-device.md | 6 +- internal/manual/chat/models-and-cost.md | 2 +- internal/manual/chat/naming-a-device.md | 2 +- .../chat/reaching-this-machine-without-ssh.md | 6 +- .../manual/chat/relay-hosted-or-your-own.md | 16 ++- .../manual/chat/relay-limits-and-refusals.md | 12 +- .../chat/relay-what-it-stores-and-sees.md | 18 +-- .../manual/chat/running-from-the-terminal.md | 20 ++-- .../manual/chat/running-on-another-machine.md | 19 ++- internal/manual/chat/settings.md | 10 +- internal/manual/chat/starting-codeaf.md | 4 +- .../manual/chat/tasks-on-another-computer.md | 8 +- internal/manual/chat/team-manager.md | 4 +- .../chat/use-this-on-another-computer.md | 74 +++++++----- internal/manual/chat/what-i-remember.md | 4 +- internal/manual/chat_test.go | 6 +- internal/rotate/steps.go | 2 +- internal/session/prompts/system.md | 2 +- internal/syncsetup/state.go | 13 +- internal/syncsetup/storage.go | 2 +- internal/syncsetup/storage_optin_test.go | 12 ++ internal/syncsetup/storage_refresh.go | 39 +++++- internal/syncsetup/storage_refresh_test.go | 77 ++++++++++++ internal/tui3/settings_devices.go | 7 +- internal/tui3/statusnote.go | 2 +- internal/tui3/storage_optin_test.go | 12 +- internal/wireauth/wireauth.go | 2 +- 42 files changed, 419 insertions(+), 229 deletions(-) diff --git a/cmd/codeaf/chatv3_pair_test.go b/cmd/codeaf/chatv3_pair_test.go index b591a851a2..ec7b12a57b 100644 --- a/cmd/codeaf/chatv3_pair_test.go +++ b/cmd/codeaf/chatv3_pair_test.go @@ -95,7 +95,8 @@ func TestTeamPairedFollowsPairingAndSyncOff(t *testing.T) { if teamPaired() { t.Fatal("missing identity was paired") } - if _, err := identity.EnsureSolo(root); err != nil { + id, err := identity.EnsureSolo(root) + if err != nil { t.Fatal(err) } if teamPaired() { @@ -104,6 +105,16 @@ func TestTeamPairedFollowsPairingAndSyncOff(t *testing.T) { if err := identity.EndSolo(root); err != nil { t.Fatal(err) } + relay := syncsetup.Resolve(root) + if err := syncsetup.SaveStorage(root, relay.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + if teamPaired() { + t.Fatal("paired identity with storage off was team-paired") + } + if err := syncsetup.SaveStorage(root, relay.URL, id.ID(), "on"); err != nil { + t.Fatal(err) + } if !teamPaired() { t.Fatal("shared identity was unpaired") } diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go index e8f043fd96..1ee153ea8e 100644 --- a/cmd/codeaf/final_save.go +++ b/cmd/codeaf/final_save.go @@ -25,7 +25,7 @@ const quitSavingWord = "saving this chat to your other devices…" const quitStillSavingWord = "still saving — ctrl+c leaves it saved here" const quitStillLocalWord = "still saving on this computer — ctrl+c leaves the written turns here" const quitLegacySavingWord = "codeaf is still saving this chat in the background; unsaved turns stay on this computer." -const quitStorageOffWord = "saved here; relay storage is off for this account, so it stays on this computer" +const quitStorageOffWord = "saved here; sync storage is off for this account, so it stays on this computer" const quitSavedHereWord = "saved here; it reaches your other devices when the sync service at %s answers" // finalSaving keeps one failure until cleanup has returned the terminal. diff --git a/cmd/codeaf/storage_optin_test.go b/cmd/codeaf/storage_optin_test.go index c099ac9d69..8082f2c6ad 100644 --- a/cmd/codeaf/storage_optin_test.go +++ b/cmd/codeaf/storage_optin_test.go @@ -197,7 +197,7 @@ func TestStorageOptInQuitReceiptAfterRevocation(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.TrimSpace(string(raw)) != "saved here; relay storage is off for this account, so it stays on this computer" { + if strings.TrimSpace(string(raw)) != "saved here; sync storage is off for this account, so it stays on this computer" { t.Fatalf("receipt: %s", raw) } } diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go index c930e55502..8a11e9f99c 100644 --- a/internal/chatlist/copy.go +++ b/internal/chatlist/copy.go @@ -19,7 +19,7 @@ const ( ContinueVerb = "Continue here" LostRace = "another device continued this chat first" NoIdentity = "this machine has no identity yet: codeaf identity import" - RelayStorageOff = "relay storage is off for this account, so chats stay on this computer; request access in Settings under Devices" + RelayStorageOff = "sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices" SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync service's address" Unreachable = "the sync service is not answering" ReadTimedOut = "the sync service did not answer in time" diff --git a/internal/manual/chat/a-dev-box-on-your-own-railway.md b/internal/manual/chat/a-dev-box-on-your-own-railway.md index 035aa947af..63a95c4458 100644 --- a/internal/manual/chat/a-dev-box-on-your-own-railway.md +++ b/internal/manual/chat/a-dev-box-on-your-own-railway.md @@ -2,8 +2,6 @@ ## Can I run codeaf on my own server or an always-on computer in the cloud? -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. - Yes. `codeaf devbox up ` creates an always-on computer on **your own Railway account** and pairs it into your identity. You pay Railway for its compute and persistent volume. Your laptop can sleep while the box keeps running. @@ -62,7 +60,7 @@ removal of the one-time setup values. Then open `codeaf chat --at build-box`. ## How saved provider keys, settings and shared memories reach the box -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. The box starts `codeaf serve` with `CODEAF_SERVE_SYNC=1`, so it pulls the identity's saved provider keys, settings and shared memories at startup and every five seconds by default, @@ -154,7 +152,7 @@ to admit another computer yet; use the creating laptop. ## Send a task to my dev box -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). A dev box also takes tasks. It starts `codeaf serve --take-tasks` whenever its installed build lists that option, so a chat on your laptop can run one task on the diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index 32cdc7ae64..9001d56a55 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -2,7 +2,9 @@ ## How do I add another computer — the Add another device card (add another machine), codeaf pair, a link to paste, and pairing a server with no browser -Pairing and moving chats are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. The original files stay untouched and remain usable in older codeaf builds. @@ -39,7 +41,7 @@ The card goes away once you have two devices. After that, `/pair` or Settings ## Do older chats move after pairing? -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -70,13 +72,13 @@ The approving device says `desk-b is now paired. Your chats can continue there.` ## What does codeaf pair --code do -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. `codeaf pair --code` shows a code such as `04-063-213` on this computer. On the other computer, run `codeaf pair ` to share this computer's chats. Compare the three words and approve on the computer showing the code. The page *Pairing your chats with a second computer* explains each step. ## The pair words — approve, --code and --via -Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. ``` usage: codeaf pair [--name ] | codeaf pair approve | codeaf pair [--replace] [--name ] | codeaf pair --code @@ -93,7 +95,7 @@ A link is good for 10 minutes and one approval. If it ran out, run `codeaf pair` ## The four-digit check — how I know the right computer is asking -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. The new device shows a check number of four digits. The device that approves shows the same four digits. Look at both screens. Approve only when they match. If they do not match, decline: somebody else may have used the link. @@ -101,7 +103,7 @@ On the approving screen the number reads `Check number 4821. Approve only if the ## Approve a new device — the approve screen, a and d, and decline -Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. On an already paired device, paste a link or type `/pair ` to see who is asking: @@ -131,7 +133,7 @@ Every other device that is on says it too, a moment later, from the same message ## What does the dot next to a computer mean — the devices row, online and offline -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. When you have two or more devices, home shows one row of them: @@ -145,7 +147,7 @@ Under the row, `alt+m bring work here` brings the newest chat of an online devic ## Does the other computer appear on the device list straight after pairing — a just-paired device, devices empty after pairing -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Yes, on both sides. When a pairing finishes — a code such as `04-063-213` on both screens, a link typed into `codeaf pair approve`, either end of it — the computer it finished on writes @@ -156,7 +158,7 @@ says one line about it, `paired; this computer will appear on your other devices ## Which devices do I have — /devices, how to remove or revoke one, a device that was revoked, and I was told this machine was removed -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Type `/devices` to list them: @@ -185,8 +187,6 @@ The list is in a fixed order: this device first, then devices that are online, t ## Devices in /settings — where your computers are on the settings page, add another device, rename this one, or remove (revoke) one from settings -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - Open `/settings` (or `ctrl+,`) and walk with `←` and `→` to `Devices`, between `Connections` and `Privacy` — or type `devices`, `remove`, `revoke`, `pair` or `add device` in settings search. While loading, the tab says `looking up your devices…`. Loaded, it begins with the same dim `sync service: 127.0.0.1:8901 (your own)` or `sync service: codeaf.agentfield.ai (hosted)` line as `/devices` and the terminal. It lists this computer even before pairing, with the same platform and presence columns and in the same order and words as `/devices` — `●` online, `○` away (`seen 3h ago`, or `offline`), `this device`, and `removed` — followed by `Add another device`. The open tab refreshes on its own when a computer joins, renames itself or is removed. @@ -207,7 +207,7 @@ After a successful removal, its confirmation stays visible even if refreshing th ## I was told this device was removed — how to bring it back -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Home shows `removed by another of your devices — pair again to sync` on its own dim line with the Add another device card. The line remains visible when this connection cannot offer that card. The footer keeps only key hints. `/pair` repeats that notice, then says `on a device still in your devices, open /pair; enter its code here to pair again` and shows `Code from another device`. Enter the other device’s code and confirm pairing. It cannot offer a code from the removed membership or approve another device’s link. If this computer was removed while its window was closed, the explanation appears on Home on the next launch too. The launch check and the live check share one notice, so learning it both ways does not repeat it. @@ -219,7 +219,7 @@ this device was removed by another of your devices, so this chat stays here only ## Can a removed computer rejoin — /devices, pairing again and retained local chats -Pairing again restores device access. Sending new chats, keys or shared state through the hosted relay also requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +Pairing again restores device access; sending new chats, keys or shared state through the hosted sync service needs sync storage approval (Settings → Devices → sync storage). `/devices` on a removed computer says the same line in place of the list. A terminal refusal uses `this device was removed by another of your devices, so this chat stays here only — run codeaf pair to bring it back`. Home says `removed by another of your devices — pair again to sync`. The terminal command `codeaf devices` gives its removal explanation once, without following it with `nothing is paired yet`. It appears the moment the other device removes this one, even if codeaf has been open for days, in the open chat and on the home screen alike, and once only. Your chats on this device are safe and stay on it. They no longer follow your other devices. To bring the device back, run `codeaf pair` here, then approve the link it shows from a device that is still in (the steps above). If this computer has chats with submitted turns, run `codeaf pair --replace` instead. Its existing chats stay on this computer but no longer sync; shared memories and settings are cleared. Approve the new link from a computer still in your devices. A computer that has only opened codeaf, even with a provider connected, and whose chats contain only startup headers needs no `--replace`; saved knowledge, automations, tracked work and unknown local files still require the explicit replacement road. An active, already-paired computer is refused with the names of its other devices; `codeaf devices` lists them. @@ -233,9 +233,9 @@ chats no longer sync under the replaced membership. ## The link did not work — a pairing link or token that ran out, was declined or was answered already -Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. -These are the sentences a link pairing says when it stops, in the words the screen uses. Nothing was changed on either device when you read one of them. +These are the screen's sentences when link pairing stops. Nothing was changed on either device when you read one of them. - `that is not a codeaf pairing link or code; it looks like https://codeaf.agentfield.ai/p/k7m2q9xd#... or k7m2q9xd.`: what was pasted is neither shape. Paste the whole link or the whole `.` token. Links from your own sync service are accepted too. - `the link ran out before anyone approved it; run `codeaf pair` again for a new one`: a link is good for 10 minutes. @@ -250,7 +250,7 @@ Too many requests from one network are refused too: at most 10 new links in an h ## I quit on my laptop and opened my desktop after pairing -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). After a clean quit and final durable save, bare `codeaf` on another paired computer within 24 hours opens the released chat automatically. A sleeping or disconnected laptop still needs `alt+c` on Home. Local unsaved edits, another open local window, a named chat, `--host`, `--at` or `--session` prevent automatic continuation. A running chat never moves by itself. The arrival retains its `Moved from` banner and `not brought along:` facts; `esc` goes Home. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index b9d6a3bb62..c73de1c873 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -29,7 +29,7 @@ restores the ordinary prompt. Combined stdout/stderr appears as plain text while the command runs, preserving line breaks and indentation. Wide lines wrap; terminal control sequences are removed for safe display. The command and output remain in history after reopening. The model waits for your next message before responding. -On the hosted relay, sharing captured file changes requires relay storage approval in Settings → Devices → relay storage; a self-hosted relay with `--store` needs none. In a chat that can move between computers, file changes from `!` are included in +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). In a chat that can move between computers, file changes from `!` are included in its next saved turn, just like changes from the model's shell commands. A failed command keeps its output and exit status too. Shell paths, `@` names and slash words remain literal, not mentions or send tags. @@ -337,7 +337,7 @@ command). ## Pairing commands — /pair, /pair , /sync, /link and /laptop -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). | Command | Aliases | Argument | Effect | |---|---|---|---| @@ -1962,7 +1962,7 @@ the Memories tab. Tasks controls delegated work; AI teams sets defaults for groups of AI chats. Permissions controls approvals. Spending holds limits. Connections holds accounts, keys, web search and remote connection settings. Devices lists your paired computers, renames this one, removes another, and starts -`/pair` from `Add another device`. A paired account on a relay that reports storage permission also has `relay storage`: when off, Enter opens a request and shows its code and link, including from Settings search. Self-hosted storage needs no request. +`/pair` from `Add another device`. A paired account on a relay that reports storage permission also has `sync storage`: when off, Enter opens a request and shows its code and link, including from Settings search. Self-hosted storage needs no request. Privacy holds history, drafts and data-sharing choices. Old saved keys and values are unchanged. Search accepts old labels and keys, @@ -2298,4 +2298,8 @@ nothing was left behind, `/setup` says so and does nothing. `/setup now` starts a **setup turn**: the agent installs the installable items and brings back what a move left behind, into user space. A new chat's commands already have the network and this computer's ordinary access; `/setup` is not needed to open the network or to install outside the workspace. Each setup call asks first unless approvals are open (`--yolo` or the allow posture), or the arrival card already approved that exact listed command. The setup approval floor still applies in a host-bound chat. Calls are recorded as external and the turn says `Setup` in `codeaf cell log`. -Limits: a setup turn is never replayed on another machine (the agent does it again there), never attempts impossible items, and can be rewound like any turn. When it ends, codeaf looks at what is now on this machine and updates the record. Existing cells keep their declared class: an older sandboxed cell still has its folder limits and opens its network for the setup turn only. Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. A compatible furrow engine is required. Without a cell there is nothing to set up. +Limits: a setup turn is never replayed on another machine (the agent does it again there), never attempts impossible items, and can be rewound like any turn. When it ends, codeaf looks at what is now on this machine and updates the record. Existing cells keep their declared class: an older sandboxed cell still has its folder limits and opens its network for the setup turn only. Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. A compatible furrow engine is required. Without a cell there is nothing to set up. + +## Why pairing commands do not turn on sync storage — request access in Devices + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index 48c8459a51..c4dd2c203c 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -4,7 +4,9 @@ To continue on this machine, select the chat on home and choose `continue here`. ## Continue on another computer — continuing a chat on another computer, why can't I type in this chat, my message did not send and home opened with a question -Pairing and moving chats are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -25,14 +27,10 @@ If you continue it, the other device's window stops taking new turns and says `< ## Where an older chat's copy lives — the .moving folder, README.txt, can I delete it -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - An older chat's copy is kept in a folder called `.moving` beside the original, in codeaf's state folder for that project. The name is historical: nothing in it is a move in progress. It holds the live copies this build writes to, so deleting it deletes those chats. The folder carries a `README.txt` that says so. The originals beside it stay untouched for older codeaf builds. ## My typed message after continue here — is it kept, does it send by itself -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - before showing this question for a send, codeaf checks which computer holds the chat now. an older listing alone cannot raise it. if that check fails, nothing is sent and the whole message stays in the box, including pasted text and attachments. the chat says ` has not answered; your message is still in the box, press enter to send it`. press enter to check again. changing the draft while a check waits keeps the current words unsent; the chat says `draft changed before sending; press enter to send your current words`. a chat this window already holds, or one that has never been shared, sends without asking the service. an ordinary check for a shared copy waits up to five seconds. immediately after this window's own successful move, a message can wait up to 30 seconds for the chat to finish opening, then sends once. a window that lost the chat, or another window holding it, cannot give this window permission to send. @@ -41,28 +39,24 @@ if Enter raised the continue card, a successful move restores those same words, ## which draft comes back after continuing a chat -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - a move opens one conversation for that chat. sending clears the words from its saved drafts, including matching copies left by an earlier window. taking the chat back, reopening it or restarting codeaf then shows an empty box unless you typed a new draft. taking a chat from Home restores that chat's own saved draft, or leaves its box empty. words typed for a different chat stay with that chat, even when both work in one place on disk. a draft held behind a send's continuation card stays with the chat that raised it. continuing a chat this window already holds keeps its open conversation and typed words. ## Read a finished task's worker conversation after moving its chat -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Open `/task` and choose the finished task. Its saved worker conversation and steps remain readable with its outcome after moving the chat, even when the original working copy is no longer available. A finished task offers no restart action; typing `r` or `R` writes those letters in the box. ## What does leave it there do -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. On `Continue this chat here?`, `leave it there` closes the question without moving the chat. Your typed words stay in the box and nothing is sent. If the question opened because you tried to send from an older window, this answer is already selected. Choose `1 continue here` to bring the chat here before sending. ## Esc on a continue card returns to my chat, and each card keeps its own draft -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - If Enter in a displaced chat raises the continue card, `esc` or `2 leave it there` returns to that same chat window with its full draft still in the box. If you opened the card from a Home row, leaving the card keeps you on Home. during an automatic continue, the first `esc` withdraws its opening and any waiting choice. the automatic chat is not opened: it finishes arriving and is let go, so it stays on this computer, free to continue from anywhere. a further `esc` navigates as usual while that finishes. if the abandoned move has not answered after two minutes, its request is cancelled before the waiting choice can begin. inspecting another chat's card and choosing `leave it there` or `esc` leaves the automatic arrival alone. @@ -77,8 +71,6 @@ these are Home's listing sentences. a message left unsent by a failed send check ## Why does continue here say this chat was last held here — my only computer asks to continue -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - A chat this computer held last, whose hold expired with no other computer holding it, opens without a question: there is nothing to continue from. Enter sends, and turns this computer saved while it could not reach your other devices are sent to them when the chat opens. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. The card still says `this chat was last held here; last saved turn 2h ago` when this window's own earlier move failed. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. @@ -87,7 +79,9 @@ If unpublished work needs to be kept separately, the number of kept turns counts ## What does held on tower mean — a row on home and the verb it offers -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Home lists chats from your other devices. A chat another device still holds says `held on `. The hold alone does not establish that work is running there. One that a device let go of, or that went off because its lid closed, says ` offline`. @@ -95,7 +89,9 @@ Press enter on either row to continue it here. The verb on the card depends on t ## Continue on this machine — Continue where you left off, alt+c and alt+x -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. If you open codeaf and a chat on another device was running or went off in the last 24 hours, and that device is offline now (its lid is closed) or has let go of the chat, home offers it first of all: @@ -116,7 +112,9 @@ To bring work from a device that is online, use the devices row: `alt+m bring wo ## What happens when the chat arrives — the banner Moved from tower, and what was not brought -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. When the chat opens on this device it starts with one line that says how long the move took: @@ -142,7 +140,7 @@ got it ## What was not brought along — folders, commands and tests on the arrival card -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). When a chat arrives on a paired computer, its arrival card can include these lines: @@ -160,13 +158,15 @@ Typing while either arrival card is up goes into the message box, including the ## Does a moved chat have the network — installs and access on this computer -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. A new chat is host-bound and keeps that class when moved. Its commands run on the computer that now holds it, with the network and the same access as any program you run there. Moving chats does not restrict commands to their workspace. `/setup` prepares missing tools and folders after a move; it does not enable ordinary network access. Existing cells declared sandboxed by an earlier build keep their declared class when moved or reopened. ## What folder does the moved chat work in — old absolute paths in earlier turns -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. A moved copy works in the workspace it arrived in. Before its next model request, the agent gets a session note naming the device it came from, the old folder and the new folder. Paths in earlier turns under the old folder now name the same relative paths under this workspace. The note is context for the agent, not a message you typed, and remains available after reopening or compacting. @@ -176,7 +176,7 @@ Taking a chat back into its original project restores that project in place. The ## Why does the project label say copy from another machine -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. A chat arriving here for the first time works in its own copy. It is filed under the folder it came from, apart from conversations in this computer's own folder of the same name. Home and the keys-row label say `proj-a (copy from another machine)`. This remains a copy even if a folder with the original path happens to exist on this computer. Taking back a chat that already works in your own project restores that project in place. An unknown origin is left unnamed. @@ -186,7 +186,7 @@ name. ## This chat is not syncing — the sync service is not answering, working offline -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). If this computer holds the chat and renewals have failed for longer than one renewal period, one dim line says @@ -200,13 +200,15 @@ only shows it` notice still applies. ## Reopen a kept copy after another computer takes the chat -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). When unsent turns were kept as a separate copy, reopening that copy checks who holds the copy itself. Another computer may still hold the original chat. The previous window finishes saving and stops syncing before the reopened copy starts its own sync, so a late save cannot change which copy it opens. Taking that copy elsewhere still stops new tool work in its older window. ## Continue here does nothing — continue here did nothing, the engine store is busy, or a chat is already continuing here -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. while a chat you explicitly chose is moving here, the card says `continuing here…`. another explicit continue during that move says `a chat is already continuing here; wait for it to open` on the card. an automatic startup move instead lets your explicit choice go next; see “my choice while an automatic chat is arriving”. @@ -214,7 +216,7 @@ every failure stays where you are looking: on Home's card with `1 retry` and `2 ## why continue here failed — retry or leave it there -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry confirms the holder and turn of ownership now displayed on that card. @@ -228,14 +230,12 @@ a failed move keeps the draft and shows `1 retry` and `2 leave it there`. retry ## Why can't I take an open chat from another computer — older relays and --host -Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. - - `taking an open chat from another computer waits for a sync service update; all else works, so close it there and continue here` means the chat is open on another computer and the sync service is too old to take it from there safely. Everything else works, including continuing a chat the other computer has closed. Close the chat on that computer, then choose `continue here` again. - Under `codeaf chat --host`, `continue here` is not offered and a chat the remote host already holds never shows as held elsewhere. With no way to continue set up, an off row has no `continue here`: enter says why. ## taking a chat back, and two computers continuing the same chat — which one opens -Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. A chat has one holder at a time. To take a chat back from the computer that took it, continue it there the same way: press enter on its row on that computer and choose `continue here`. Nothing has to be returned or switched off first. A computer with the chat open when another takes it says ` continued this chat; this window now only shows it`, stops taking new turns, and keeps what it had not yet sent as a branch (` turns from : open separately / discard`). A tool call the model tries there answers with that same line. Taking the chat back leaves a `node_modules` or other install folder that computer already has alone. @@ -270,7 +270,9 @@ need another upload before the move. ## I reopened an old local chat after the other computer let go — where are the newer turns -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Reopening an older copy does not replace newer history on your other devices, even if the other computer has closed the chat. This window only shows its local copy until you choose `continue here`. Submitting a message opens that question and keeps your words in the box. Continuing brings the newer history here before writing can resume. @@ -284,7 +286,9 @@ Turns already shared do not become kept edits. Usage, reconciled task cost, nami ## The chat is arriving on this computer — why it waits to open -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. While a chat is moving onto this computer, pressing enter on its row says `this chat is arriving on this computer; it opens by itself in a moment`. Nothing is wrong and nothing needs doing: the move takes a second or two, and the chat opens by itself when it ends, with the other computer's latest turns in it. Opening it earlier is refused on purpose, because a window opened halfway would show the chat as it was before the move and could not save your turns. @@ -292,13 +296,15 @@ When the holder is off (its lid is shut), the row reads ` offline` and y ## Can the chat on the other computer still open the job logs it mentions — logs, saved tool output, task journals and the task history after a move -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Yes. A chat's messages name files that sit beside the chat and not in your project: the log of a background job (`log at …/logs/jobs/2.log`), the full output of a tool result that was cut short (`full: logs/stubs/9c2f.txt`), and the journals of its tasks. They move with the chat. The other computer puts the logs and saved output back in the chat's own folder, so the same names open there. The task journals (each task's transcript, trajectory and saved output) are part of the chat itself, so they arrive whole inside it at the paths the chat reads them from. The first message it receives after the move says where they are now (`… that earlier messages name under were carried: they are under here`). A path in an old message still shows the old folder; use the new one. ## Job log and saved tool output limits after a move — task journals stay whole -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Three limits keep a move light, and they apply to job logs and saved tool output only. A single such file over 1 MiB stays behind, and so does whatever does not fit in 16 MiB together (the oldest go first). A task journal has no size limit and always moves whole. A file that looks like it holds a secret (a key, a token) stays behind too. Each one is named under `not brought along:` with the reason and `stayed on the machine that made it`; read it there by asking that machine, and never assume the other computer has it. @@ -306,7 +312,9 @@ Two things are made again on the other computer and are not carried: the project ## What moves with a chat — is my git history sent, branches, secrets in my repo -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Your repository's `.git`, including its history, branches and settings, moves with the chat to your own paired devices. A task's commits travel too, and its working-copy bookkeeping is rebuilt on arrival. A key in an old commit remains in that history: codeaf does not remove or rewrite it. Git history travels sealed end to end; the sync service sees only ciphertext, including when the history contains keys. @@ -314,25 +322,25 @@ The current working files are checked separately. A `.env` or source file that t ## How are secret files screened before saving or moving a chat — .env, ignored folders and changing files -Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. - Before each seal, codeaf asks which files will be captured and screens those candidates, including a full candidate list for a first reply or a capture that needs a full scan. If a file changes between screening and capture, saving plans and screens again. If repeated edits prevent a stable capture, it says `files kept changing while saving this chat; try again` and keeps the calls for a later save. A wholly git-ignored directory is neither entered nor screened unless it holds tracked files. An ignored file in an ordinary directory is still screened: root `.env`, `config/.env.local` and a private key in a `*.pem` file travel through the encrypted vault to paired computers. A withheld file stays out of local saved packs and the uploaded workspace capture. Tools still see a `.env` file's variables through the vault. An unreadable folder stays out until its permissions recover; its newly eligible files are then screened with a fresh capture plan. Restores and arrivals also screen before an automatic safety capture. ## Why does home appear while my chats are still syncing -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. The first screen draws while this computer publishes its device record, shares its saved keys and sends the first saved turn in the background. A slow sync service does not leave the terminal blank. New tool work waits for the initial ownership check; local startup saves are sent once that check finishes. ## Does every chat copy my whole project — disk space, shared saved files, older chats -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). New chats on this computer share one store of saved file contents. Opening four chats in the same project does not make four private copies of every unchanged file. Each chat keeps its own history, call log and transport scratch; restoring one does not move another chat's saved head. A chat whose older private store already exists keeps using that store in place, without migration. Nothing is deleted automatically. `codeaf cell gc` is an explicit command and removes only files the saved copy can restore. ## Why did dist or node_modules not move — gitignored files, build output, ignored folders -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Every chat's capture follows Git's ignore rules. Untracked ignored output stays on the computer that made it: it is neither stored in the local file history nor uploaded. Tracked files travel even if an ignore rule matches their name. A wholly ignored directory is not walked or screened. Lockfile rebuild folders still follow the existing rebuild rules. @@ -342,21 +350,19 @@ If Git cannot read the project's ignore rules, saving stops with `cannot read wh ## Does saving slow my command — pending save, quit while saving, moving before the save finishes -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - A command's result returns after its call log is written, while its file capture finishes in the background. The next tool call waits until the previous capture has finished reading files. Continuing the chat on this computer, uploading it, closing it, quitting codeaf and `codeaf cell gc` also wait for both the call log and the saved files to reach durable storage. A storage failure is reported and stops that boundary. Opening a chat screens its folder in the background without sealing an empty turn. A command submitted before that screen finishes waits for it. A window that only views a chat held elsewhere does not seal turns. A failed capture keeps the finished call in its local log for the next save; codeaf never runs that command again to recover it. ## Quitting while saving — local confirmation and a slow upload A save wait lasts at most 30 seconds. After the screen closes, a pending sync save says `saving this chat to your other devices…`. After about three seconds, a confirmed local save adds `still saving — ctrl+c leaves it saved here`; without confirmation, it says `still saving on this computer — ctrl+c leaves the written turns here`. -If the turns are saved locally but not uploaded, the final sentence is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service’s host. If that pending upload was refused because storage is off, the successful receipt is `saved here; relay storage is off for this account, so it stays on this computer`. That is a successful exit. Ctrl+c during this wait leaves promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`; that failure may include the local recovery path. +If the turns are saved locally but not uploaded, the final sentence is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service’s host. If that pending upload was refused because storage is off, the successful receipt is `saved here; sync storage is off for this account, so it stays on this computer`. That is a successful exit. Ctrl+c during this wait leaves promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`; that failure may include the local recovery path. If the last upload fails when you quit, for example because the connection dropped, this computer does not let the chat go. Your other computer keeps showing it as running here and does not open the older save by itself; the unsent turns stay on this computer until an upload succeeds. ## I pressed alt+m before the other chat appeared — still arriving, home on a second computer -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Device presence can arrive before its list of chats. If you press `alt+m` early, home says `chats are still arriving from your other computer; this opens when they are ready`. It keeps your request and opens the ordinary confirmation once the chat arrives; it never takes the chat without that confirmation. A recent chat left on another computer is offered when home opens, including one that computer closed cleanly. @@ -366,8 +372,6 @@ a chat this computer has never held brings its saved contents in parallel, even ## Does first start send anything — local identity before pairing -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - The first start may create a local identity for this computer. That file sends nothing: there is no sync traffic or network use until you pair or offer a pairing code. Reading the add-device instructions leaves it quiet. Offering a pairing code or running `codeaf pair` contacts the sync service for that pairing. `CODEAF_SYNC_URL=off` blocks sync traffic even after pairing. ## What survives a crash or power loss — durability, killed process, lost call records @@ -382,7 +386,7 @@ A saved attachment reference keeps the source computer's path spelling with slas ## Can I rewind an old chat on my new laptop — where does the copy start -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Yes, after the older chat has a movable copy and you continue it on your paired laptop. Its first saved file state is the folder as it stood when the copy was made. Rewind can reach that first saved turn; file states from before the copy were never saved and cannot be reconstructed. The whole earlier conversation is still there to read. @@ -392,7 +396,7 @@ The original older chat is untouched. A turn typed into the original by an older ## I quit on my laptop and opened my desktop — do I need alt+c -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). A clean quit lets go after the last save is durable. Opening bare `codeaf` on your other paired computer within 24 hours continues the released chat automatically, with the usual `Moved from` banner and `not brought along:` lines. `esc` goes Home. A sleeping laptop has not let go: Home offers the chat and `alt+c` continues it, asking only if local work needs a choice. @@ -404,8 +408,6 @@ A running chat on the other computer never moves automatically. Neither does a n ## my choice while an automatic chat is arriving — continue a different chat, join it or cancel -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - bare startup still chooses the most recently released eligible chat and checks local safety first. input before that check answers cancels the automatic choice. if its move is already running, confirming a different chat on Home or using `alt+c` chooses what opens next. Home says `continuing next; is still on its way here`, with the titles from the continue cards. the automatic move settles first and is let go with its files and kept work intact; it never opens over your choice. the next move uses exactly the computer and turn of ownership you confirmed. `esc` cancels the waiting choice and the automatic chat still settles and is let go. @@ -420,7 +422,7 @@ An older chat that worked in your home folder, or in a folder that holds it, is ## A save has not reached the other computer yet — upload failed or another device continued first -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). A saved chat reaches your other computers only after its encrypted objects have been uploaded and its new saved head has been accepted. If an upload fails, the @@ -430,8 +432,6 @@ chat first; this does not discard the work saved on your computer. ## Which computer coordinates: cluster status and cluster lead -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - On the hosted service, or a self-hosted service with persistent storage, an open paired home joins as a learner. Three persistent voters keep coordination available when your laptops sleep. `codeaf cluster status` shows this computer after joining. Continue chats through the ordinary continue door. See the coordination and workers page for command refusals and setup details. ## Can a worker access all my chats or approve pairing? @@ -452,7 +452,7 @@ No. The worker saves its claim and an execution intent before invoking tools. A ## Why can an older computer list chats but not continue a chat that is still running? -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Older builds can still list, save, publish and release chats through a compatible service. A service using quorum-backed coordination refuses an older request that tries to take a live chat without naming its observed holder and ownership number. It uses the existing refusal, `another device continued this chat first`; an old build does not necessarily show an extra update hint. Quit on the computer holding the chat before continuing it elsewhere, or update codeaf on the computer trying to continue. A released or expired chat can still be taken. A conditional choice never silently changes to a newer holder. @@ -464,7 +464,7 @@ Yes. Directory requests from older builds keep the existing 1 MiB request limit, ## Why did Enter show Continue here instead of sending my message -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Before sending, codeaf checks that this window still holds the chat. If another computer holds it, its latest saved turn is newer, or this window's earlier move failed, Enter opens **Continue this chat here?** or leaves the window showing the chat only. The words stay in the box. Choose **continue here** to bring the saved chat here, then press Enter to send. @@ -477,15 +477,13 @@ A new chat can send its first message normally. A sleeping computer can also res ## A move that failed partway — are my typed words safe, can the old copy still send -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - If something changes the chat's files while a move is putting them in place, codeaf stops the move and lets go of the chat. It says the move could not finish and that your words are still in the box. The window keeps showing the chat or returns to Home, and it cannot send on the old copy. Try **continue here** again. Every failed move keeps the captured draft, including its whitespace, caret, pasted text and attachments, even if the old session closed during the move. Losing a race, a connection failure or a refused move does the same. A failed move never sends those words by itself. ## Repair sync — codeaf doctor and codeaf doctor --fix for unfinished continues, duplicate rows and a busy engine store -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Run `codeaf doctor` from a terminal to inspect this computer's engine store and cross-computer sync state without changing anything. When an issue is present, `engine store` and `sync` rows report: @@ -517,3 +515,7 @@ presence; it does not guess which computers are online. ## Sync notices between older and current builds A current window understands the earlier outage and removed-computer notices too. It uses one outage line while local work continues, and points to `/pair` for recovery in a chat. A current engine keeps the earlier notice spelling for an older window, so that window's existing outage and removal handling still works. + +## Why can't I continue new turns from my offline computer — sync storage is off + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Existing saved content remains readable; turning storage off deletes nothing. The refusal is `sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices`. Once approval is read in the background, waiting chats start within a minute without restarting. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index c900c35472..9d070f924c 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -450,7 +450,7 @@ profile or changes the requesting chat's model. ## Do my teams show on my other computer? -The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. Use +Team sharing is on by default for paired computers and needs sync storage approval on the hosted service (Settings → Devices → sync storage). Use **Settings → General → share teams**. Sharing includes teams, members, handles, the manager and team settings, plus recent Traffic as read-only history. Team changes show within a few seconds, even with no chat open. The Teams dropdown, Teams page @@ -459,7 +459,7 @@ Both computers need to be running codeaf and able to reach the relay. An unpaired computer or a computer with sharing off keeps its teams to itself. Turning it off on either computer stops sharing between them: that computer stops sending and receiving teams and Traffic. What was already exchanged stays, -including teams and encrypted copies in the vault. Unpaired computers are unaffected. +including teams and encrypted copies in the vault. Open a member whose chat is on another paired computer to see `Continue this chat here?` on Home. Choose **continue here** to move the chat @@ -481,7 +481,7 @@ wait while this computer finishes sending an earlier change to the relay. ## Will deleting a team on my laptop delete it on my desktop? -The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. With **Settings → General → +Team sharing is on by default for paired computers and needs sync storage approval on the hosted service (Settings → Devices → sync storage). With **Settings → General → share teams** enabled on both computers, a deleted team stays deleted. A desktop that was offline catches up when it reconnects. Renaming its old copy while offline does not bring the team back. Deleting a team does not delete its conversations. @@ -495,7 +495,7 @@ consistently, and a removed member cannot remain the manager. ## Does Traffic sync between computers? -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Traffic sharing is on by default for paired computers through **Settings → General → share teams**. Turning it off on either computer stops sharing between them. @@ -521,7 +521,9 @@ on the other computer's history. ## How do I turn on team sharing? -The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. If it was turned off, open +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + +Team sharing is on by default for paired computers. If it was turned off, open **Settings → General → share teams** on that computer and choose **on** to resume. Sharing exchanges teams, members, manager choices and settings, and recent Traffic as read-only history while the switch stays enabled on both paired computers. @@ -534,7 +536,7 @@ Turn **share teams** to **off** to stop all team sharing immediately. Teams alre ## Stop sharing teams with my other computer -The share-teams preference is on by default for paired computers, but sharing through the hosted relay waits for storage approval in Settings → Devices → relay storage → enter. Open **Settings → General → +Team sharing is on by default for paired computers and needs sync storage approval on the hosted service (Settings → Devices → sync storage). Open **Settings → General → share teams** on either computer and choose **off**. Turning it off on either computer stops sharing teams, members, manager choices and settings, and recent Traffic between them immediately. That computer stops sending and receiving teams and Traffic. Teams already received @@ -549,7 +551,7 @@ arrive within a few seconds; recent Traffic can take about 2 minutes. ## First team sharing: backup, duplicate names and global managers -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Paired computers exchange teams automatically while **Settings → General → share teams** is on, its default. An unpaired computer is unaffected. Before the @@ -571,7 +573,7 @@ the original teams. Review manager choices in Teams. ## Does a shared team's spending cap cover both computers? -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). A team's spending cap counts spending on each computer separately. Sharing the team and its cap does not combine the usage meters. A member continued on the diff --git a/internal/manual/chat/coordination-and-workers.md b/internal/manual/chat/coordination-and-workers.md index b9f05db4b0..8ba90142c6 100644 --- a/internal/manual/chat/coordination-and-workers.md +++ b/internal/manual/chat/coordination-and-workers.md @@ -90,12 +90,10 @@ The sync connection carries presence hints and committed lease acknowledgments. ## Does syncing keep sending requests when nothing is happening? -A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round when relay storage is on. Hosted storage is off by default while privacy terms are being finished; request access in Settings → Devices → relay storage → enter. Pairing, presence, directory coordination and live `--at` remain available while storage is off, but new chat/file uploads and vault sharing wait. A self-hosted relay with `--store` needs no approval. A relay that reports storage off gets one stats request at startup and every ten minutes; unknown hosted addresses use that same closed default. An unanswered custom address gets one startup request. An answer without storage keeps storage on and stops probing; known storage on makes no permission requests. On a relay with a learner door, over ten idle minutes, caught-up open homes with storage on make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. +A home that has never paired sends no sync traffic. `CODEAF_SYNC_URL=off` also blocks new sync requests from clients that were opened before the setting changed. Presence pings on an open view are hints; they do not write chat ownership. On the hosted relay and the native relay, an open idle home uses its learner’s applied vault pointer to notice shared memory and preference changes. Local edits are detected locally and pushed on the next state round when sync storage is on. A relay that reports storage off gets one stats request at startup and every ten minutes; unknown hosted addresses use that same closed default. An unanswered custom address gets one startup request. An answer without storage keeps storage on and stops probing; known storage on makes no permission requests. On a relay with a learner door, over ten idle minutes, caught-up open homes with storage on make zero HTTP requests, zero peer or forwarding frames and zero client attempts, including while one home leads and after it sleeps and the anchors resume. A caught-up idle home sends no recurring state polls or peer keepalives. ## How does an idle home notice offline computers and wake coordination? -Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. - Home reads its roster and backstop from the local applied directory. Its separate relay watch supplies advisory online presence, pinging every 10 seconds. A computer whose last socket closes is marked offline after a 15-second reconnect grace period; a silent connection is marked offline within 30 seconds. Home can then offer its recent chat before the 90-second lease expires. Continuing a chat and all ownership decisions still use fresh majority reads. A committed change wakes peer delivery and the next state round pulls the changed vault. If an older hosted relay has no learner door, the home keeps its existing five-second state polling. On the consensus directory, a group with no work or held chats can stop its periodic coordination timer after an idle barrier has reached every voter. The first operation wakes coordination and must obtain a fresh majority response. A saved leader address cannot answer that operation by itself. Losing the majority leaves syncing unavailable and keeps local work intact. ## Hosted idle presence sweeps and sleeping connections @@ -218,3 +216,7 @@ Native and hosted watch connections carry committed receipts to clients that ask ## Cluster status or lead after this device was removed On a removed computer, `codeaf cluster status` and `codeaf cluster lead` fail with the same removal and pairing guidance as `codeaf devices`. This applies to the default relay and to your own relay. Pair that computer again with `codeaf pair` to restore access. A removed device is different from a cluster temporarily unable to coordinate. + +## Why shared state waits for sync storage — approval and serving computers + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Eligible serving homes start approval discovery after their first state round; quiet or removed homes do not start it. A stats refusal for a revoked or removed device stops polling. diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 283b1a1d3b..6ebb8b8a3e 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -51,7 +51,9 @@ screen. Its heading is `Basic settings`, one line, with the keys line directly u ## I already use codeaf on a second computer — the code field on the first screen -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -62,7 +64,7 @@ have a code from another device? use your own relay: codeaf pair --via ``` -On the computer that has your chats, type `/pair` to show a code like `42-715-302`. Type that code in the field. Compare the three words on both screens. The other computer asks `y / n`. On `y`, this computer has your identity; chats copy here only when relay storage is enabled. Read *Pairing your chats with a second computer* for the rest. +On the computer that has your chats, type `/pair` to show a code like `42-715-302`. Type that code in the field. Compare the three words on both screens. The other computer asks `y / n`. On `y`, this computer has your identity; chats copy here only when sync storage is enabled. Read *Pairing your chats with a second computer* for the rest. ## Skipping setup and reading its header diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 3c51c84a08..0f9e664e7a 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -289,7 +289,9 @@ updates the tab and the row in Sessions together. ## A chat is running on my other computer — how does home show it, chats on my other machines, "held on tower", "tower offline", why does it say held on build-box offline, merge / discard -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -306,7 +308,7 @@ happened. The row says where the chat is only where that tells you something: ## Why a chat says held on build-box or build-box offline -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). For a chat on another paired computer, read the two words apart: `held on build-box` says that computer still holds the chat, including while it is idle, and `build-box offline` says that computer has not answered for 25 seconds (it is switched off, asleep, frozen or offline; the word appears within 30 seconds, and goes at once when it answers again) and has not let the chat go, so the chat's last saved turns are what you would get. The name is only the device's. A chat on a computer that is `offline` can still be continued here; the card says how many turns may still be there. A count of one reads `1 turn`, never `1 turns`. The device is the name you gave that machine. A chat whose name will not open shows `untitled`, and a machine whose name will not open shows the first eight characters of @@ -338,7 +340,9 @@ same rows in a terminal (see "The cell verbs" in running-from-the-terminal). ## Continue a chat here that another machine left — "tower offline", takeover, "continue here", the chat is running on another computer -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -356,7 +360,7 @@ question takes the whole screen, with the sentence wrapped. ## Continue here with unsaved edits, another device continuing first, or an unreachable computer -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). When continuing a chat from another paired computer: @@ -369,7 +373,7 @@ When continuing a chat from another paired computer: ## Is my project folder changed when I continue a chat here — what comes with a continued chat, the files, binary files, file modes, the executable bit, the .env, where the copy lives, no git needed -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Continuing a chat brings all of it: the whole conversation, and every file the chat worked on, byte for byte: text and binary files, files that were never in a git repository, the @@ -397,7 +401,9 @@ identity on this machine, no secrets are fetched and no file is written. ## Do saved provider keys and credentials follow me to another computer -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -415,7 +421,7 @@ is kept beside it as `credentials.json.damaged`. ## Do budgets, settings and model choices move between computers -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Your stored model picks, display and approval defaults, and spending-limit values follow your paired computers through the encrypted vault. Each setting merges independently; the later edit wins. Spending counted against a limit stays **per computer**, so a daily limit is not a combined fleet allowance. Local paths, caches, service addresses, automations and operator environment pins stay on their computer. @@ -429,7 +435,7 @@ chat while you are still fetching it, this machine keeps exactly what it had. ## Is my git history sent — secrets in my repo, .git, committed keys, branches -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Yes. When you continue a chat on your own paired device, the repository's `.git` travels as it is, including its history, branches, settings and any keys in old commits. Task commits travel too. Git history is sealed end to end between your devices; the relay sees only ciphertext. codeaf does not screen, remove or rewrite committed secrets. @@ -437,7 +443,7 @@ The working-tree guard is separate: a `.env` or source file it recognises as hol ## What a move carries and what it leaves behind — job logs, task history, kept branches, do spend and usage numbers sync between computers, memory, withheld folders -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). A chat that moves to another computer brings, from the relay and sealed on the way: @@ -450,7 +456,7 @@ A chat that moves to another computer brings, from the relay and sealed on the w ## What stays on this computer when a chat moves — task history, install folders, secrets and spending limits -Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). The project's task history (the list of finished tasks) and the git bookkeeping inside a task's working copy are not carried: they are made again on the other computer from the chat's own record. @@ -458,7 +464,7 @@ It leaves behind, and lists on the card under `not brought along:`: install fold ## Does dist or other gitignored output move — ignored files and secret files -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Untracked Git-ignored output stays on the computer that made it and is named on the arrival card with `(git ignores them)`. It is neither captured locally nor uploaded. Tracked files still move. A wholly ignored directory is not walked or screened; an ignored secret file in an ordinary directory, such as `.env` or `config/.env.local`, is screened and still travels through the key vault to paired computers. @@ -2737,7 +2743,7 @@ from a previous visit cannot replace the current visit's selection or draft. ## I quit on my laptop and opened my desktop — straight back into the chat -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). After a clean quit lets a paired computer's chat go, bare `codeaf` on another paired computer continues that chat automatically within 24 hours. You arrive with the `Moved from` banner and the usual `not brought along:` facts; `esc` goes Home. @@ -2758,3 +2764,7 @@ An empty new chat on one of your other devices stays off Home until it has a sav Turning sync off with `CODEAF_SYNC_URL=off` (or leaving sync off before pairing) means "do not talk to the sync service", never "forget my chats". Home still lists every chat on this computer — chats this version saved and chats from older versions alike — each once, and each opens and can be written. When an older chat was already copied into this version's store, Home lists and opens that copy even while sync is off, and the untouched original stays on disk beside it. Saved sync hold receipts from an earlier session stay dormant while sync is off, so sending a message in any local chat works without asking the sync service. + +## Why are chats and files still on this computer after pairing — sync storage + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Home can list paired devices while uploads wait; an offline computer cannot supply new turns that never reached the service. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index e4169c8da2..ed6ceb7421 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3205,8 +3205,6 @@ pointer — they still point at your turn, never at the parent task's own journa ## The optional arguments on propose_task -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. - These arguments are optional. Bad values return a refusal the model can correct. **`computer`** — the shown name of a paired computer that runs diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index b12c8971b9..23b9a31c2f 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -51,11 +51,13 @@ already explains that the chat stays on this computer until you pair it again. ## Quitting while the sync service is slow — waiting for a final save +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + The screen closes after at most one second while a slow close finishes. The launcher keeps its connection open for the final publish and release before it exits. A successful quit with nothing waiting stays quiet. While a sync save waits, the restored terminal says `saving this chat to your other devices…`. After about three seconds, a confirmed local save adds `still saving — ctrl+c leaves it saved here`; without that confirmation, it says `still saving on this computer — ctrl+c leaves the written turns here`. -The launcher waits up to 30 seconds for the final saving reply. If only upload is outstanding, the successful receipt is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service's host. If that pending upload was refused because storage is off, the successful receipt is `saved here; relay storage is off for this account, so it stays on this computer`. Ctrl+c during this restored-terminal wait exits promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`. +The launcher waits up to 30 seconds for the final saving reply. If only upload is outstanding, the successful receipt is `saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers`, using your service's host. If that pending upload was refused because storage is off, the successful receipt is `saved here; sync storage is off for this account, so it stays on this computer`. Ctrl+c during this restored-terminal wait exits promptly with the same receipt when local saving is confirmed. Without confirmation, it says `saving on this computer has not finished; its written turns stay here` and exits unsuccessfully. Only a failed local write says `codeaf could not save this chat`. ## One Enter sends — checking which computer holds this chat diff --git a/internal/manual/chat/lock-out-a-lost-computer.md b/internal/manual/chat/lock-out-a-lost-computer.md index 1eeae0e5ae..99cb41d4ed 100644 --- a/internal/manual/chat/lock-out-a-lost-computer.md +++ b/internal/manual/chat/lock-out-a-lost-computer.md @@ -2,11 +2,9 @@ ## A computer was stolen and I want it shut for good — codeaf identity rotate gives the computers you keep a replacement identity -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - Stopping a computer with `codeaf devices revoke ` cuts it off from the relay. It does not undo what it holds. That computer has your whole identity. It can read everything it already has, and it can make itself a new device. -`codeaf identity rotate` is the real fix. It makes a replacement identity and moves your saved keys to it. With hosted relay storage off, rotation still finishes without uploading chats or resealed keys: chats stay on the computers that hold them. Previously stored frames remain under the old identity until grace-period erasure. The old identity is shut. The lost computer holds the old one, so it can read nothing of what you do from then on. +`codeaf identity rotate` is the real fix. It makes a replacement identity and moves your saved keys to it. With hosted sync storage off, rotation still finishes without uploading chats or resealed keys: chats stay on the computers that hold them. Previously stored frames remain under the old identity until grace-period erasure. The old identity is shut. The lost computer holds the old one, so it can read nothing of what you do from then on. Run it on a computer you keep, as soon as you know a computer is lost: @@ -22,8 +20,6 @@ usage: codeaf identity rotate [--grace 7d] [--yes] [--abandon] ## What a rotation does — the steps, in order -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - 1. It tells you the cost first, and asks. It says how many chats you have, how many are not on this computer, how many megabytes go up and about how long that takes. Answer `n`, or press ctrl+c, and nothing has changed. 2. It fetches the chats this computer does not hold yet. This can take a long time for big chats. You can still cancel. 3. It makes the new identity and writes it down on this computer, before it sends anything. @@ -45,8 +41,6 @@ change these at their providers: OPENROUTER_API_KEY ## The rotation stopped half way — resume it or abandon it -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - If `codeaf identity rotate` stops half way, because the network dropped or the computer went to sleep, run it again. It finds what it had done and finishes. It never makes a second new identity. If saved replacement keys are damaged, keep `rotation-successor.json`; deleting it would lose the replacement already bound by the relay. While a rotation is unfinished, the computer does not sync, and says `a rotation is unfinished on this computer; run codeaf identity rotate to finish it`. To stop a rotation before step 7, run `codeaf identity rotate --abandon`. The old identity opens again. Only after a hosted relay accepts the successor binding does this computer keep the replacement keys privately, outside the active rotation, for another rotation of the same identity on that same relay. Canceling before acceptance, using a relay without the storage capability, or using a self-hosted relay discards the candidate; the next rotation makes fresh keys. After step 7 the only way is forward: it says `already switched to the new identity`. @@ -65,8 +59,6 @@ Remote access is a different door. A machine you let in with `codeaf serve` is n ## Pair your other computers again — each one follows the replacement -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - After a rotation, each computer you keep is still on the old identity. It stops syncing, and says once: `your chats are moving to a new identity; when that is done, pair this computer again (/pair on the computer that moved them)`. When the relay has deleted the old identity, after the grace period, it says: @@ -99,7 +91,9 @@ A way to prove your identity with a written recovery phrase is not built. ## What rotation costs — uploads with storage on, local copies with storage off -With hosted storage off, rotation completes with no store writes. Chats remain on your computers; any already-stored frames are left to the old identity’s grace-period erasure. With storage on, a rotation uploads everything your chats hold, once, because the new identity starts empty. The plan it shows before you answer gives the number. A chat of 200 MB goes up as 200 MB. The transfer takes your network bandwidth and uses relay storage; the old and new copies count towards its limits during the grace period. There is no fixed price or duration promised by the command. +Hosted sync storage is off by default while privacy terms are being finished; request access in Settings → Devices → sync storage → enter to enable uploads. Pairing and live `--at` work while storage is off; your own sync service with `--store` needs no request. + +With hosted storage off, rotation completes with no store writes. Chats remain on your computers; any already-stored frames are left to the old identity’s grace-period erasure. With storage on, a rotation uploads everything your chats hold, once, because the new identity starts empty. The plan it shows before you answer gives the number. A chat of 200 MB goes up as 200 MB. The transfer takes your network bandwidth and uses sync storage; the old and new copies count towards its limits during the grace period. There is no fixed price or duration promised by the command. While the old identity is kept for the grace period, the relay holds both copies. diff --git a/internal/manual/chat/losing-every-device.md b/internal/manual/chat/losing-every-device.md index 1e7e8f727f..bc2fdb07e1 100644 --- a/internal/manual/chat/losing-every-device.md +++ b/internal/manual/chat/losing-every-device.md @@ -13,6 +13,10 @@ Only two things can save you before that day: ## Can I change my identity — identity rotate -If one trusted computer remains, **`codeaf identity rotate` works now**. It makes a replacement identity and reseals saved keys locally. With hosted relay storage off, it finishes with no resealing uploads and chats stay on your computers; with storage on, it reseals and uploads chats and saved keys. It retires the relay's old copy after the grace period. Pair the computers you keep again from that replacement. Read *Locking out a lost computer for good* for the steps and limits. +If one trusted computer remains, **`codeaf identity rotate` works now**. It makes a replacement identity and reseals saved keys locally. With hosted sync storage off, it finishes with no resealing uploads and chats stay on your computers; with storage on, it reseals and uploads chats and saved keys. It retires the relay's old copy after the grace period. Pair the computers you keep again from that replacement. Read *Locking out a lost computer for good* for the steps and limits. Rotation needs the old identity on a surviving computer. It cannot recover an identity when every copy, including any exported identity file and its passphrase, is lost. Revoking or rotating cannot take back chats and keys already downloaded by a lost computer; change exposed provider keys at their providers. + +## Why pairing alone is not a backup — sync storage and completed uploads + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Approval cannot recover turns that never left a lost computer. Keep a surviving computer with the content, or an exported identity and an accessible encrypted copy. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index f2275593a4..3dc1f66f58 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -4335,7 +4335,7 @@ a chain ends after ten requests rather than waiting forever. ## Do my crew settings follow me to my other computer? -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. After pairing, crew seat model pins, allowed-model choices, the crew daily cap, per-task cap and free-route choice follow synced preferences within a few seconds, diff --git a/internal/manual/chat/naming-a-device.md b/internal/manual/chat/naming-a-device.md index 3a122bd76b..cc8e1adbc4 100644 --- a/internal/manual/chat/naming-a-device.md +++ b/internal/manual/chat/naming-a-device.md @@ -2,7 +2,7 @@ ## Why does it show my hostname — the host name is the default until a device is named, and where a name appears -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index bb3043945e..8c9e4e2fd6 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -4,8 +4,6 @@ For an always-on computer on your own Railway account, see [A dev box](a-dev-box ## Two codes, two grants — remote access and your chats -Live `--at` access and pairing work with hosted relay storage off while the other computer is on. Continuing saved chats with that computer off needs storage approval in Settings → Devices → relay storage → enter. - You learn one thing: **a code shown on a screen you own, typed on the other computer.** A code can grant two different things. They stay two things, because they are two different powers. | | remote access (`codeaf serve`, `codeaf chat --at `) | your chats (`/pair`, `codeaf pair`) | @@ -586,3 +584,7 @@ An `--at` window uses the serving computer's chosen device name in its bar, connection errors, reconnecting notices and timeout messages. Its typed rendezvous name still selects the machine to connect to. If an older engine has not supplied a chosen name, the window uses the requested name. + +## Does live --at access need sync storage approval? + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Live access works on the other computer while it stays on; storage approval is needed to copy saved chats so they can open elsewhere when it is off. diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md index 05db73ef6d..e57b9c674c 100644 --- a/internal/manual/chat/relay-hosted-or-your-own.md +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -2,11 +2,13 @@ ## Hosted relay or my own — which one do I use, where does sync go by default, and which relay each channel uses -Pairing and moving chats are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. -A relay is the small service your computers sync through. There are two kinds. Both speak the same wire. Hosted storage requires account approval; your own relay with `--store` does not. +A relay is the small service your computers sync through. There are two kinds. Both speak the same wire. + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. - **A hosted relay** is run for you on a cloud account. You do nothing to run it. You give the chat its address. - **Your own relay** is the program `codeaf relay`. You run it on a computer you control. @@ -15,13 +17,15 @@ Either way the relay sees only ciphertext and the short list on "What can the re ## Where sync goes by default — stable, staging and dev relay addresses -Hosted storage needs approval in Settings → Devices → relay storage; pairing and live `--at` work while storage is off. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Unset, each release channel selects its own hosted relay: stable releases and release candidates use `https://codeaf.agentfield.ai/fabric`, staging builds use `https://caf-fabric-staging.instrument-santosh.workers.dev`, and dev builds and local builds from source use `https://caf-fabric-dev.instrument-santosh.workers.dev`. Channels do not sync with each other: computers must run the same channel (or set the same `CODEAF_SYNC_URL`) to pair and share chats. A build whose `HostedRelayURL` is empty has no default, and then nothing is copied and pairing says `no sync service is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to its address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. ## Point codeaf at a relay — CODEAF_SYNC_URL, url or off -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Set `CODEAF_SYNC_URL` in the environment of every computer that shares the chats. @@ -37,7 +41,7 @@ Your own computers must all name the same relay and hold the same identity, or t ## Run my own relay — codeaf relay --listen and --store -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. ``` codeaf relay --listen :8787 --store /var/lib/codeaf @@ -54,7 +58,7 @@ There is no Docker image for the relay. The page `docs/relay.md` in the reposito ## Who can use my own relay -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. **Any caller with a valid signature may make an identity and store on your relay.** The relay has no list of allowed people: an identity is made from a key, and the relay accepts any key. Keep your relay on a private network, a tailnet or behind a proxy that checks who calls. One identity cannot read another's data. diff --git a/internal/manual/chat/relay-limits-and-refusals.md b/internal/manual/chat/relay-limits-and-refusals.md index 693786df21..ae40a81b07 100644 --- a/internal/manual/chat/relay-limits-and-refusals.md +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -6,7 +6,7 @@ An older service cannot promise that taking a chat displaces only the computer y ## Is there a storage limit on the relay — how much can I sync -Hosted storage starts off and requires approval in Settings → Devices → relay storage → enter while privacy terms are being finished. A self-hosted relay with `--store` needs no request. Limits below apply when storage is on. +Hosted uploads need sync storage approval (Settings → Devices → sync storage); these limits apply with storage on. **A hosted relay has limits.** The hosted relay in this repository sets, for each identity: @@ -22,10 +22,10 @@ With storage on, a chunk already held costs nothing again. Storage off refuses e ## How much sync space have I used -When hosted storage is off, `/status` shows one dim `relay storage off` line instead of sync space. The cached permission is refreshed in the background at launch and after each `/status`. +When hosted storage is off, `/status` shows one dim `sync storage off` line instead of sync space. The cached permission is refreshed in the background at launch and after each `/status`. With storage on, open `/status` in a chat to see a dim `sync space` line, for example -`sync space 2.8 GiB of 5 GiB`. It shows how big your relay storage is and how +`sync space 2.8 GiB of 5 GiB`. It shows how big your sync storage is and how close you are to the relay limit: the amount stored, then the byte limit the hosted relay reports. The same reading appears in `/status --json`. The permission refresher retains its last successful amount in this process. While hosted storage is on it makes no further permission requests, so this amount can trail new uploads. After a restart only the remembered on/off choice remains, and unknown usage draws no line. @@ -47,9 +47,11 @@ The hosted relay lets one network make **200 new identities in a day**. A networ Pairing has limits on both kinds of relay: 10 new codes in an hour for one network, and a relay holds at most 2,000 waiting pairings. The messages are on the pairing page, for example `too many pairings from this network; wait 7 min` and `sync is full right now; try again in a few minutes`. -## Relay storage is off — approval, retries and chats staying here +## Sync storage is off — approval, retries and chats staying here -**Relay storage is off** (`403`, code `storage_off`). The chat says `relay storage is off for this account, so chats stay on this computer; request access in Settings under Devices`, once while it waits. No upload requests are made when this computer knows storage is off. A refresh that sees approval wakes sync automatically. This is separate from every full refusal; reads, pairing and live `--at` still work. Running uploads retry through the local gate on their normal backoff, capped at 60 seconds. One process-wide waiter starts chats waiting for storage within a minute of another process learning approval. Approval discovery runs at launch, after `/status`, and every ten minutes while a relay that reports storage is off (or a hosted address is unknown); serving computers use it too. It stops polling once storage is on. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + +**Sync storage is off** (`403`, code `storage_off`). The chat says `sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices`, once while it waits. No upload requests are made when this computer knows storage is off. A refresh that sees approval wakes sync automatically. This is separate from every full refusal; reads, pairing and live `--at` still work. Running uploads retry through the local gate on their normal backoff, capped at 60 seconds. One process-wide waiter starts chats waiting for storage within a minute of another process learning approval. Approval discovery runs at launch, after `/status`, and every ten minutes while a relay that reports storage is off (or a hosted address is unknown); serving computers use it too. It stops polling once storage is on. ## What do I see when the relay says no diff --git a/internal/manual/chat/relay-what-it-stores-and-sees.md b/internal/manual/chat/relay-what-it-stores-and-sees.md index df4b775f99..b7a7606501 100644 --- a/internal/manual/chat/relay-what-it-stores-and-sees.md +++ b/internal/manual/chat/relay-what-it-stores-and-sees.md @@ -1,32 +1,32 @@ # What the relay stores and what it can see -## Turn on relay storage (request access) — why don't my chats sync to my other computer +## Request access to sync storage — turn on sync storage, why don't my chats sync to my other computer -Hosted relay storage is off by default while its privacy terms are being finished. Pairing does not turn it on. New chats, files and saved keys stay on this computer; the relay refuses even encrypted uploads. Existing stored frames stay readable and nothing is deleted. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing does not turn it on. New chats, files and saved keys stay on this computer; the relay refuses even encrypted uploads. Existing stored frames stay readable and nothing is deleted. Pairing, seeing your devices and `--at` still work while the other computer is on. With storage off, new chat turns cannot be continued on another computer while the first is off, recovered from the relay after losing a computer, or sent as task copies. Teams sharing, synced keys, preferences and shared memories also wait. The relay still keeps device and directory coordination metadata, including existing chat records and leases; storage off does not erase those records. -After pairing, open **Settings → Devices → relay storage**. When it says **off**, press **enter** (`enter request access`). This opens a pre-filled GitHub issue; the panel also shows the request code and link if no browser opens. The request code is your identity id (`id_` followed by 32 hex digits). It is safe to post publicly and grants nothing by itself. +After pairing, open **Settings → Devices → sync storage**. When it says **off**, press **enter** (`enter request access`). This opens a pre-filled GitHub issue; the panel also shows the request code and link if no browser opens. The request code is your identity id (`id_` followed by 32 hex digits). Approval is tied to this identity id. It is safe to post publicly and grants nothing by itself. -Revocation stops new uploads and leaves earlier frames readable. A self-hosted `codeaf relay --store ` keeps storage on and needs no request. Approved hosted accounts still have the limits on *Relay limits and refusals*. +Turning sync storage off stops new uploads and leaves earlier frames readable. A self-hosted `codeaf relay --store ` keeps storage on and needs no request. Approved hosted accounts still have the limits on *Relay limits and refusals*. -## How relay storage permission is discovered — custom addresses, startup and status +## How sync storage permission is discovered — custom addresses, startup and status -The relay's answer decides, whatever its hostname. When it reports storage permission, Settings shows the relay storage row and `/status` shows the dim line `relay storage off` while off. A relay that answers without storage permission, including the self-hosted Go relay and older hosted Workers, keeps storage on and has no row or line. +The relay's answer decides, whatever its hostname. When it reports storage permission, Settings shows the sync storage row and `/status` shows the dim line `sync storage off` while off. A relay that answers without storage permission, including the self-hosted Go relay and older hosted Workers, keeps storage on and has no row or line. Before any answer, the three hosted addresses on *Hosted relay or your own relay* start closed; other addresses start open. A paired process probes an unanswered custom address once at startup. A successful answer is remembered for this account and relay, including when the field is absent. Sync off and unpaired homes make no requests. -Serving processes and chat windows check at launch, after `/status` in chat, and every ten minutes while a relay that reports storage is off (or a hosted address is still unknown). Storage on or an answer without the field stops requests. Once approval is read, waiting chats and uploads start within a minute without a restart. Opening a chat never waits for a relay reading. +Eligible serving processes check after their first home-state round; chat windows check at launch and after `/status` in chat, and every ten minutes while a relay that reports storage is off (or a hosted address is still unknown). Storage on or an answer without the field stops requests. Once approval is read, waiting chats and uploads start within a minute without a restart. Opening a chat never waits for a relay reading. A revoked or removed device stops approval polling. ## Does the relay store my chats — what is on the relay's disk -Hosted relay storage is off by default while its privacy terms are being finished; request access in Settings → Devices → relay storage → enter. Pairing and live `--at` work without storage. With storage enabled, the relay holds two things for each identity, kept apart from every other identity: a **directory** (one record for each chat, one for each of your computers) and a **store** of chat content. +The relay holds two things for each identity, kept apart from every other identity: a **directory** (one record for each chat, one for each of your computers) and a **store** of chat content. New content uploads need sync storage, which is off by default until approval (Settings → Devices → sync storage); directory records can remain while storage is off. The content is in the store as **encrypted chunks**. Each chunk is sealed under a key that only your own computers have. The relay cannot open a chunk. It keeps the chunks you send, and your saved keys too: they travel as one more sealed chunk, like a chat. Two things in the directory are sealed before they leave your computer: **the title of every chat** and **the name of every computer**. The relay stores them as sealed text and cannot read them. -There is no email or password sign-in on the relay. Storage approval is tied to your identity id. Your identity is an id that starts with `id_`, made from your key. +There is no email or password sign-in on the relay. Your identity is an id that starts with `id_`, made from your key. ## What can the relay see — the metadata, my IP address, and what is not hidden from it diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 5a4fc2f323..51a4c2f37b 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -342,7 +342,7 @@ by default on Linux and macOS on every build. Native Windows is supported too; a ## codeaf pair — show a code, or use one, from the terminal -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). **`codeaf pair approve `** compares the four-digit check number and asks `y / n`: only `y` or `yes` approves, and `d`, Esc, Enter or anything else decline. Code pairing keeps its `y / n` question. @@ -402,7 +402,7 @@ version; the map does not keep a second version number. ## The cell verbs — list a cell's turns and rewind it -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. They need a compatible furrow engine. `codeaf cell` is hidden from the +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. They need a compatible furrow engine. `codeaf cell` is hidden from the help text. **`codeaf cell log []`** lists a cell's turns, newest first, one line each (turn, parent, time, trigger, tools, receipt; a setup turn says `Setup` and its calls are marked `[external]`), and **`codeaf cell rewind []`** puts the cell's files and transcript back to that @@ -441,7 +441,7 @@ be read. A wrong passphrase and a damaged blob get the same refusal. computer you keep: a new root, every chat and your saved keys sealed again under it, and the old identity deleted by the relay after the grace period. It is how a lost computer is locked out for good. It asks first and says what it costs; `--abandon` stops a rotation that has not -switched yet. With hosted relay storage off it still completes, without resealing uploads: chats stay on your computers and the old stored frames wait for grace-period erasure. With storage on, successor proof carries approval unless the new identity already has its own setting. Read *Locking out a lost computer for good*. +switched yet. With hosted sync storage off it still completes, without resealing uploads: chats stay on your computers and the old stored frames wait for grace-period erasure. With storage on, successor proof carries approval unless the new identity already has its own setting. Read *Locking out a lost computer for good*. Each machine also has its own device key, made the first time and never exported. Your identity signs a small certificate for it, so two machines share one id but keep separate @@ -452,7 +452,7 @@ key the first time the identity is made on that machine. ## List your chats on every machine — codeaf cell list --all, and what its lines mean -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). **`codeaf cell list --all`** prints every chat you have on any of your machines, one line each: id, title, device, where it runs (`held on `, ` offline`, @@ -472,15 +472,19 @@ To use the new setting at once without touching the other window, run `codeaf ch Only a terminal that sets `CODEAF_SYNC_URL` itself is asking for a different relay. A window with the variable unset — `codeaf chat --host` reaching this computer over ssh, whose shell usually does not carry what you exported, or a terminal you never set it in — joins the running engine and its relay as it is. It restarts the engine only when the relay a pairing saved on this computer has changed since the engine started. -## Sync your chats between machines — CODEAF_SYNC_URL and CODEAF_SYNC_INTERVAL_MS +## Sync your chats between machines — what is CODEAF_SYNC_URL, and CODEAF_SYNC_INTERVAL_MS -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. These environment settings are not rows in `/settings`. `CODEAF_SYNC_URL=off` stops relay traffic; new chats still keep their local cells. +## What is CODEAF_SYNC_URL — the sync service address or off + **`CODEAF_SYNC_URL`** is the relay's address, like `http://host:8787`, or the word `off`. Plain `http` is for trying it out inside an ssh tunnel; later the relay needs TLS. Unset, it is the relay a pairing saved on this computer, and with none saved it is codeaf's hosted @@ -493,7 +497,7 @@ to the relay until pairing; that identity alone does not connect your other comp ## How often chat turns sync — CODEAF_SYNC_INTERVAL_MS -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). After pairing, codeaf sends sealed chat turns to the relay configured by `CODEAF_SYNC_URL`. @@ -536,8 +540,6 @@ this exists and the chat behaves as it always did. ## What syncing a conversation cost — how much data it uploaded, codeaf cell report, and sending the numbers only if you choose -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. - Each time a conversation is synced to another machine, codeaf adds one line of counts to a file on this machine, `v3/sync/stats/.jsonl` in the codeaf home: how many turns, frames and objects that flush carried, how many bytes went up and came down, and how many put, get and has requests it made. Requests that diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index a78eb72cbb..777a76303f 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -2,8 +2,6 @@ ## Can I run it on another computer? -Live `--at` access and pairing work with hosted relay storage off while the other computer is on. Continuing saved chats with that computer off needs storage approval in Settings → Devices → relay storage → enter. - `--host` runs the chat surface on the machine you are sitting at, and runs the conversation on another machine, over ssh. @@ -307,27 +305,28 @@ knowing even where you cannot answer it from this window. A **reading** window — one opened only to watch a task's page — is refused these keys on purpose, and says `this window is reading this conversation, not typing into it`. -## How do I work on the same conversation from two computers — another window, and another machine +## How do I work on the same conversation from two computers — open multiple terminals and have things just transfer + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. -Pairing works on Linux, macOS and native Windows with a compatible engine, even with storage off, as do presence and live `--at`. Hosted chat/file sync needs approval: Settings → Devices → relay storage → enter. Self-hosted `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. **Another window** is a second codeaf on the *same* machine holding a conversation you can see on home. Its row says `another window` in the right margin — and `another window · enter brings it here` while the cursor is on it. `enter` on it never opens a -second copy — one conversation, one writer — but it does something better: it **moves** the -conversation into this terminal. Two presses, the row says `coming here` while it is on its +second copy: it **moves** the conversation into this terminal, keeping one writer. Two presses, the row says `coming here` while it is on its way, and the other window lands on whatever else it was holding. *Continue a conversation -from another terminal*, on the home page, is the whole of it. (The old answer here was "go -to that terminal, or start a new conversation here", which is no longer the only way.) +from another terminal*, on the home page, is the whole of it. + +## Another machine with --host — attach to the conversation without copying it **Another machine** is `--host`. The conversation lives over there and your terminal *attaches* to it: what you type crosses the wire, the work runs on that machine, and the answer comes back. Close the lid and the turn keeps going if the far end is a session host; open a terminal somewhere else, attach to the same session, and you are back in it with the -gap replayed. That is what "it just transfers and works" actually is — attaching, not -copying, and the place a conversation lives never moves. +gap replayed. Attaching does not copy the conversation or change where it lives. **And the places are neither.** They are a listing of one machine's disk, and they follow the machine your session is on. Nothing about them opens a second window or moves a conversation. diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index fc1e814440..aaaa5efe85 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -82,7 +82,7 @@ positive: on shows contextual tips, off hides them. Keyboard instructions and setting explanations stay visible either way. **Completed tool details** offers **collapsed** or **expanded** without changing the meaning of an existing profile. -**Settings → General → share teams** is on by default for paired computers. On the hosted relay it takes effect only with relay storage approval (Settings → Devices → relay storage); a self-hosted relay with `--store` needs no approval. It +**Settings → General → share teams** is on by default for paired computers. On the hosted sync service sharing needs sync storage approval (Settings → Devices → sync storage). It shares teams, their members and settings, and recent Traffic as read-only history. The choice stays on each computer. Turning it off on either computer stops sharing between them: that computer stops sending and receiving teams and Traffic. What @@ -160,7 +160,7 @@ routing, internal role overrides and context limits. **Devices** lists your paired computers in the same words and order as `/devices`, renames this device with `n`, removes another with `r`, and starts `/pair` from -`Add another device`. For a paired hosted account, **relay storage** shows **on** or **off**. With **off**, Enter requests access and shows the public request code and link even if no browser opens. Choosing this row in search clears the search and opens Devices. The row is absent with sync off, before pairing, or on a self-hosted relay. +`Add another device`. **Privacy** separates input history, unfinished drafts, anonymous usage reporting and **shared model recommendations** (formerly Model Pool). Recommendations can @@ -190,3 +190,9 @@ A compilation already running finishes with its original model. The compiler verifies the model reported by the provider and rejects substitutions; it never falls back to a different model. Existing notes stay intact when you change this setting. Knowledge capture must still be enabled from Memory for each project. + +## Devices — sync storage, off or on, and request access + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. + +A paired account whose service reports storage permission has a **sync storage** row showing **on** or **off**. While **off**, press Enter (`enter request access`) to open a pre-filled GitHub issue. The panel also shows the public request code and link if no browser opens. The code is your identity id (`id_` and 32 hex digits), safe to post and granting nothing by itself. Choosing this row in Settings search clears the search and opens Devices. The row is absent with sync off, before pairing, or when the service answers without storage permission. `/status` shows the dim line `sync storage off` while storage is off. diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index b4cf013e83..2815a90cad 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -838,6 +838,6 @@ reaches a conversation when you next open it or switch models; `OPENROUTER_API_KEY` still wins. `your key was not accepted for this model` means the provider refused the key; doctor helps find the source to replace. -## Chats on another computer and hosted relay storage +## Chats on another computer and hosted sync storage -Pairing and live `--at` access work while hosted relay storage is off. Hosted storage is off by default while its privacy terms are being finished, so chats and files stay on this computer until approval. After pairing, request access in Settings → Devices → relay storage → enter. The panel shows the public request code and link as well as opening a browser. Sync starts by itself after a background reading sees approval; `/status` refreshes it without holding up the chat. A self-hosted `codeaf relay --store ` needs no request. +Pairing and live `--at` access work while hosted sync storage is off. Hosted storage is off by default while its privacy terms are being finished, so chats and files stay on this computer until approval. After pairing, request access in Settings → Devices → sync storage → enter. The panel shows the public request code and link as well as opening a browser. Sync starts by itself after a background reading sees approval; `/status` refreshes it without holding up the chat. A self-hosted `codeaf relay --store ` needs no request. diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index 07133f7726..a1b19293cf 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -2,7 +2,7 @@ ## Can a task run on my other computer -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Yes. Pair both computers with the same identity and run `codeaf serve --take-tasks` on the computer that will do the task. Both ends need Linux or macOS; Windows cannot @@ -29,7 +29,7 @@ the conversation and task, once, and also stays on that computer's ledger. ## Run a task on my mac mini -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Pair your Mac mini and this computer, then run `codeaf serve --take-tasks` on the mini. Use its shown name, quoting names that contain spaces: @@ -146,7 +146,7 @@ setting names another computer, without a special placement sentence. ## codeaf serve --take-tasks takes tasks from paired computers -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Plain `codeaf serve` opens only the machine connection and does not take tasks. Run `codeaf serve --take-tasks` on the paired computer that should take tasks. It prints @@ -211,7 +211,7 @@ requires updating both ends before retrying. ## The task copy could not be sent -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. `the task copy changed while it was being sent; try again` keeps the copy here. Finish or pause the edits, then retry the task. diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 2efe3ccb9f..14c321c3b0 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -564,7 +564,7 @@ while it was closed. ## Team sharing and Traffic on another computer -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +Hosted sync storage approval is required for team sharing; storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. The traffic itself is kept in the profile of the machine the conversations run on, in `teams//traffic.jsonl`, one line per message, only ever added to. It holds only @@ -625,7 +625,7 @@ Review manager choices in Teams. ## Shared team spending caps and continued members -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Teams and their settings share automatically between paired computers by default; an unpaired computer is unaffected. A shared team's spending cap counts spending diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 21bd35d4fc..953c23356c 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -2,7 +2,9 @@ ## How do I use this on my laptop — pair a second computer with a code, connect another computer, and log in on a new machine -Pairing and moving chats are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Older chats move through an independent copy. Once paired, codeaf prepares chats active in the last 30 days in the background, one at a time; opening another older chat prepares its copy then. This build shows and writes to the copy. The original files stay untouched and remain usable in older codeaf builds. @@ -10,7 +12,7 @@ There is no account and no password to log in with. You pair: a code on one scre Use a code shown on a screen you own. -The computer that has your chats shows the code. The other computer types it. After that, both computers share the identity; chats copy between them only when relay storage is on. +The computer that has your chats shows the code. The other computer types it. After that, both computers share the identity; chats copy between them only when sync storage is on. This page is the code way. A new computer can also ask to join and be approved from a link, with a four-digit check: the page *Adding a computer to your devices, approving it, and removing it* tells it. @@ -32,26 +34,28 @@ Dashes and spaces in the code do not matter. `42-715-302`, `42 715 302` and `427 The code is good for 10 minutes. It works once. -## Sync my chats — get my chats on my other computer +## Sync my chats — get my chats on my other computer, chat syncing between paired computers + +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. -Pair both computers first with `codeaf pair` or `/pair`, on Linux, macOS or native Windows. New chats save their turns locally and sync through the saved or hosted relay when storage is on. Hosted sync to paired computers needs approval in Settings → Devices → relay storage → enter; self-hosted `--store` needs none. Pairing, presence and live `--at` work while storage is off. `CODEAF_SYNC_URL=off` stops relay traffic. +Pair both computers first with `codeaf pair` or `/pair`, on Linux, macOS or native Windows. New chats save their turns locally and sync through the saved or hosted relay when storage is on. `CODEAF_SYNC_URL=off` stops relay traffic. ## Do older chats sync after pairing — independent copies and untouched originals -Hosted sync needs approval in Settings → Devices → relay storage → enter; self-hosted `--store` needs none. Pairing, presence and live `--at` work while storage is off. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Older chats become movable through independent copies after pairing; their original layout and files stay untouched for older builds. Recent chats are prepared in the background, and opening any other older chat prepares its copy. A never-paired home sends no relay requests. ## What does /laptop do — the laptop command pairs chats with my other computer; /pair, /sync and /link -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. `/pair` also answers to `/sync`, `/link` and `/laptop`. Type `/pair` with nothing after it to show a code. Type `/pair ` to use one. `codeaf pair --code` shows a code in a terminal; `codeaf pair` with nothing after it asks to join by link instead. `codeaf pair ` uses one. ## Terminal pairing usage — codeaf pair -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. ``` usage: codeaf pair [--name ] | codeaf pair approve | codeaf pair [--replace] [--name ] | codeaf pair --code @@ -63,7 +67,7 @@ A computer that has no relay cannot pair. See "The relay" below. A chat that run ## The three words — the check on the computer that has your chats -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Both screens show the same three words. This is how you know the two computers are the ones you hold. @@ -107,7 +111,7 @@ The code is now dead. It cannot pair a third computer. ## The code did not work — one attempt for each code -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. **Each code takes 1 attempt.** A wrong code does not get a second try. The computer that shows the code throws it away and shows a new one at once. You do not press a key. It says: @@ -129,7 +133,7 @@ A code that ran out of time (10 minutes) says the same. Ask for a new code with ## Why did pairing fail — wrong code shape, expired code, another device or network limits -Pairing works while relay storage is off. These are other code-pairing refusals, each in one sentence: +These are other code-pairing refusals, each in one sentence: A code with the wrong shape is refused before anything is sent: @@ -183,9 +187,11 @@ too many pairings from this network; wait 7 min ## What the pairing shares — the other computer holds everything this one holds -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. -**Pairing gives the other computer your whole identity.** Your chats, your saved keys and your sync are all part of it. The other computer receives the identity keys; saved chats and vault content still need enabled relay storage to copy. +**Pairing gives the other computer your whole identity.** Your chats, your saved keys and your sync are all part of it. The other computer receives the identity keys; saved chats and vault content still need enabled sync storage to copy. It is not a view of your chats. It is a copy of you. Pair only a computer you own and trust. @@ -209,7 +215,9 @@ already paired; nothing changed ## I got a new laptop — move my chats to a new machine -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. 1. On the old computer, type `/pair`. 2. On the new computer, run `codeaf pair `, or type `/pair ` in the chat, or type the code on the first screen. @@ -221,7 +229,7 @@ There is no phone app for continuing chats. A phone browser can open the `/p/` p ## Devices and revoking — what you can stop, and what you cannot -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. When moving chats is available, `codeaf devices` lists this computer and every linked device, in up to two groups (in the chat, `/devices` and `/settings` → `Devices` list your paired computers). A group with no entries stays hidden. Before pairing, this computer's local name, platform and `this device` come first, followed by `Run codeaf pair to add another device.` A chat-sync list with no removable peer has that same next step instead of a removal hint. @@ -263,7 +271,7 @@ To make revoking take effect for real, give all your other computers a new ident ## Without a relay — identity export and import -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. You can move your identity by file. This needs no relay. It is the way to use when neither computer can reach a relay. @@ -276,7 +284,7 @@ Keep the file and the passphrase safe. Anybody who has both has your chats. ## Is my code sent anywhere before I pair — quiet first launch -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. On a new home, no device record reaches the relay, and no relay request, connection or chat upload is sent, until this home completes a pairing or starts offering a pairing code. Showing a code needs the relay even if nobody joins. A new device's `codeaf pair` also contacts the relay to offer its link; its chats start syncing only after approval. Opening or closing `+ Add another device` with `alt+d`, reading its instructions, listing `/devices` or `codeaf devices`, and ordinary local chats leave a new home **quiet**. @@ -288,9 +296,9 @@ The first-run line about the hosted relay is said once, when chats are first cop ## Where are my chats stored — where your chats are stored, the hosted relay, turning it off, self-host your own relay, is my code private -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. -Your chats live on your own computer first. When relay storage is enabled they are also copied, sealed, +Your chats live on your own computer first. When sync storage is enabled they are also copied, sealed, to the relay, so your other computers can open them. Which relay is one setting, `CODEAF_SYNC_URL`, and there is no sign-in and no account: @@ -309,7 +317,7 @@ to the relay, so your other computers can open them. Which relay is one setting, ## Is my code private on the relay — encrypted chats, files and visible metadata -Hosted chat/file sync needs approval in Settings → Devices → relay storage → enter. Pairing, presence and live `--at` work while storage is off. Self-hosted `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). **Is my code private?** Everything a chat holds, its turns and the files it saved, is sealed on your computer with a key only your own computers have, before it is sent. The relay keeps @@ -317,9 +325,9 @@ ciphertext: it cannot read your messages, your code, or even the names of your c see that an identity's devices talk to it, when, and how many bytes. Turning it off stops new copies going, and a relay you run is yours to empty. -## The relay — what it sees, your own relay, and turning pairing off +## The relay — what it sees, your own relay, and turning pairing off; sync is off, CODEAF_SYNC_URL=off, pairing needs a sync address -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. A relay is a small service that passes bytes between the two computers. Your chats sync end to end encrypted. The relay only carries ciphertext. It never sees the code, the three words or your chats. @@ -356,7 +364,7 @@ sync is off (CODEAF_SYNC_URL=off); pairing needs the sync service ## The pairing relay does not answer, is full or is too old -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Other things the relay can say while you pair: @@ -391,7 +399,7 @@ can also delay its answer. ## I lost my laptop — what to do -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. 1. On a computer you still have, run `codeaf devices`. 2. Run `codeaf devices revoke ` for the lost computer. @@ -399,7 +407,9 @@ Pairing is available on Linux, macOS and native Windows with a compatible engine ## Which door do I use — pairing, --at or --host -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. - **The same chats on both computers:** pair them with `/pair`. This page. - **Work on one machine from the other, with no ssh:** `codeaf serve` on the machine that has the work, and `codeaf chat --at ` on the other. Read *Reaching a machine with a pairing code*. @@ -409,7 +419,9 @@ Pairing is available on Linux, macOS and native Windows with a compatible engine ## Moving chats on Windows — vault permissions, refused names, symlinks, hard links, WSL and every build -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. `CODEAF_SYNC_URL=off` stops relay traffic; local chats still keep their saved turns. Older chats move through independent copies after pairing. Their original files and layout remain untouched for older builds. Nothing is deleted automatically to save disk. @@ -432,7 +444,7 @@ mode bits are recorded for later moves, but do not provide Windows access contro ## Moving chats needs a newer furrow engine than this build carries -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. Chat movement needs the configured or embedded furrow's exchange verbs, daemon and changed-path sealing. codeaf checks these capabilities once at startup, without using a @@ -443,7 +455,9 @@ and existing remote access still work. Use a build carrying the newer engine, or ## Does a CA certificate bundle stay out as a private key — cacert.pem -Pairing is available on Linux, macOS and native Windows with a compatible engine. Hosted chat and file sync also needs relay storage approval: Settings → Devices → relay storage → enter. Pairing, device presence and live `--at` work while storage is off. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). + +Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first. A PEM certificate bundle containing only `BEGIN CERTIFICATE` blocks is kept in saved history and can move with the workspace. A `.pem` suffix alone does not make it a private key. Files containing PEM private-key headers (including encrypted keys and OpenSSH keys), PuTTY private keys, and PKCS#12 key containers are withheld; a PEM containing both certificates and a private key is withheld too. The same folder exclusions still apply: a certificate inside an install folder left behind for rebuilding is rebuilt with that folder. @@ -482,8 +496,12 @@ Once this computer has its own saved pairing keys, a sent turn, tracked work, a ## What happens to chats made with chat --once before the first pairing? -On the hosted relay, sharing new content between paired computers requires relay storage approval in Settings → Devices → relay storage. A self-hosted relay with `--store` needs no approval. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). If this computer has no saved pairing keys yet — for example it has only ever run `codeaf chat --once` — those chats are kept when it first pairs without `--replace`, and pairing does not discard or hide them. They are not sent at the moment of pairing. The next time codeaf's window opens on this computer, each one is saved for syncing and appears on your other computers' Home, where it can be continued. A computer that already has its own saved pairing keys follows the local-work refusal and explicit `--replace` rules above. + +## Why pairing alone does not copy saved chats — sync storage access + +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md index 765fa5cfde..5b7d767b72 100644 --- a/internal/manual/chat/what-i-remember.md +++ b/internal/manual/chat/what-i-remember.md @@ -647,7 +647,7 @@ one line on a failed row is the whole of what it says. ## Are my memories shared across computers? Does /remember work on my other computer — hand edited memories follow my paired computers, memory missing on the new laptop -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. Person and project memory follow your paired computers through the encrypted vault within one sync round, including hand-edited lines in the Memories place and lines written by older chats. No chat needs to be continued first. Concurrent edits of different lines both survive; for the same line the later edit wins, including forgetting a line. The service receives ciphertext only. @@ -655,6 +655,6 @@ Machine-scoped memories describe one computer and stay there. An unpaired home s ## Memory in older local conversations and new moving chats -Hosted chat/file sync requires storage approval in Settings → Devices → relay storage → enter; pairing alone does not enable it. Pairing, the device list and live `--at` still work while storage is off. A self-hosted relay with `--store` needs no request. +On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). Memories use the local store on each computer. After pairing, an older conversation gets an independent movable copy with its earlier memory history sealed into `.cell/memories.jsonl`; the original is untouched. Person and project memory also sync independently through the encrypted vault. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 9952e03b43..8a22073e43 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4696,9 +4696,9 @@ func TestChatTeamSharingSafetyAnswers(t *testing.T) { func TestStorageOptInManualQuestions(t *testing.T) { for _, question := range []string{ - "how do I turn on relay storage", + "how do I turn on sync storage", "why don't my chats sync to my other computer", - "request access to relay storage", + "request access to sync storage", "does the relay store my chats", } { found := false @@ -4723,7 +4723,7 @@ func TestStorageOptInSettingsAndTeamsQuestions(t *testing.T) { t.Run(probe.question, func(t *testing.T) { for _, section := range Chat().Search(probe.question, DefaultResults) { if section.Page == probe.page && strings.HasPrefix(section.Title, probe.heading) { - if !strings.Contains(section.Body, "relay storage approval") { + if !strings.Contains(section.Body, "sync storage approval") { t.Fatalf("approval missing: %s", section.Body) } return diff --git a/internal/rotate/steps.go b/internal/rotate/steps.go index 9136c410fc..31b1a1d55f 100644 --- a/internal/rotate/steps.go +++ b/internal/rotate/steps.go @@ -118,7 +118,7 @@ func (r *run) seal(ctx context.Context) error { if err := r.registerDevice(ctx); err != nil { return err } - r.say("relay storage is off; chats stay on your computers") + r.say("sync storage is off; chats stay on your computers") return r.advance(Sealed) } if err := r.registerDevice(ctx); err != nil { diff --git a/internal/session/prompts/system.md b/internal/session/prompts/system.md index ebdbbb404e..5ccf4a13ac 100644 --- a/internal/session/prompts/system.md +++ b/internal/session/prompts/system.md @@ -197,4 +197,4 @@ BEFORE RUNNING A COMMAND, CHECK THE TRANSCRIPT. If its answer is already here, u - Don't end the turn while work the person asked for remains; phase boundary/todo flip/sub-step never stops: same turn. - HANDED-OFF WORK IS NOT WORK THAT REMAINS: end your reply once nothing independent of it is left. A task of your own still owes its deliverable whatever it hands out. -Hosted relay chat/file storage is off by default while its privacy terms are being finished. Pairing, devices and live --at work while storage is off; offline chat transfer, relay recovery, teams sharing and synced keys require approval. Explain Settings → Devices → relay storage → enter to request access; a self-hosted relay with --store needs no request. Pairing alone does not make a backup. +Hosted sync storage for chats and files is off by default while its privacy terms are being finished. Pairing, devices and live --at work while storage is off; offline chat transfer, relay recovery, teams sharing and synced keys require approval. Explain Settings → Devices → sync storage → enter to request access; a self-hosted relay with --store needs no request. Pairing alone does not make a backup. diff --git a/internal/syncsetup/state.go b/internal/syncsetup/state.go index 56c5be8958..984c37b25e 100644 --- a/internal/syncsetup/state.go +++ b/internal/syncsetup/state.go @@ -20,8 +20,12 @@ var stateMu sync.Mutex // its lifetime. Quiet, unpaired and removed homes end silently; other failures // retry after the sync interval and are reported once per distinct error. func RunHomeState(ctx context.Context, home string, report func(error)) { - stopStorage := StartStorageRefresh(ctx, home) - defer stopStorage() + var stopStorage func() + defer func() { + if stopStorage != nil { + stopStorage() + } + }() interval, err := flushInterval() if err != nil { interval = DefaultInterval @@ -55,6 +59,11 @@ func RunHomeState(ctx context.Context, home string, report func(error)) { if errors.Is(err, directory.ErrRevoked) || (current != nil && current.isRemoved()) { return } + // The first state round proves the device may still use this home. + // Starting earlier races a revoked device's one final request. + if stopStorage == nil && current != nil { + stopStorage = StartStorageRefresh(ctx, home) + } if err != nil && ctx.Err() == nil && !reported[err.Error()] { reported[err.Error()] = true if report != nil { diff --git a/internal/syncsetup/storage.go b/internal/syncsetup/storage.go index b8f89cfd8d..3a2ce87ba3 100644 --- a/internal/syncsetup/storage.go +++ b/internal/syncsetup/storage.go @@ -133,7 +133,7 @@ func (s *Sync) StorageAllowed() bool { // StorageRequestLink includes only the public account identifier, never a key. func StorageRequestLink(identity string) string { - query := url.Values{"title": {"relay storage access: " + identity}, "body": {"Please turn on relay storage for my account.\n\nRequest code: " + identity}} + query := url.Values{"title": {"sync storage access: " + identity}, "body": {"Please turn on sync storage for my account.\n\nRequest code: " + identity}} return StorageRequestURL + "?" + query.Encode() } diff --git a/internal/syncsetup/storage_optin_test.go b/internal/syncsetup/storage_optin_test.go index 703d62ea92..52ac532f38 100644 --- a/internal/syncsetup/storage_optin_test.go +++ b/internal/syncsetup/storage_optin_test.go @@ -212,3 +212,15 @@ func TestStorageOptInLegacyCustomOnCacheDoesNotInventCapability(t *testing.T) { t.Fatal("explicit on answer must be gating and idle") } } + +func TestStorageOptInRequestLinkUsesPublicWording(t *testing.T) { + const id = "id_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + link, err := url.Parse(StorageRequestLink(id)) + if err != nil { + t.Fatal(err) + } + query := link.Query() + if query.Get("title") != "sync storage access: "+id || query.Get("body") != "Please turn on sync storage for my account.\n\nRequest code: "+id { + t.Fatalf("request wording: %v", query) + } +} diff --git a/internal/syncsetup/storage_refresh.go b/internal/syncsetup/storage_refresh.go index 78cf91d054..b6333ffe40 100644 --- a/internal/syncsetup/storage_refresh.go +++ b/internal/syncsetup/storage_refresh.go @@ -2,11 +2,13 @@ package syncsetup import ( "context" + "errors" "sync" "time" "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/wireauth" ) const StorageRefreshEvery = 10 * time.Minute @@ -30,6 +32,7 @@ type storageProcess struct { lastRefresh time.Time active bool dirty bool + refused *Sync counts blobstore.Counts countsRelay, countsIdentity string probeRelay, probeIdentity string @@ -129,6 +132,10 @@ func (s *Sync) RefreshStorage(ctx context.Context) (blobstore.Counts, error) { if p != nil { p.mu.Lock() p.lastRefresh = p.now() + if errors.Is(err, wireauth.ErrRevoked) || errors.Is(err, wireauth.ErrGone) { + p.refused = s + p.disarm() + } if err == nil { p.counts = counts p.countsRelay, p.countsIdentity = s.Relay, s.Identity.ID() @@ -200,15 +207,35 @@ func (p *storageProcess) check(refresh bool) { p.mu.Unlock() relay, id, state := p.read() p.mu.Lock() - ask := state == "off" || state == "" && (p.probeRelay != normalizeStorageURL(relay) || p.probeIdentity != id) + refused := p.refused + p.mu.Unlock() + removed := false + if refused != nil { + // Only a different pairing can reopen a refused lifetime. This extra + // lookup happens after refusal, keeping ordinary waiter samples small. + current, ok, err := Open(p.home) + removed = err != nil || !ok || (current.Relay == refused.Relay && + current.Identity.ID() == refused.Identity.ID() && current.Device.ID() == refused.Device.ID()) + if !removed { + p.mu.Lock() + if p.refused == refused { + p.refused = nil + p.probeRelay, p.probeIdentity = "", "" + } + p.mu.Unlock() + } + } + p.mu.Lock() + ask := !removed && (state == "off" || state == "" && (p.probeRelay != normalizeStorageURL(relay) || p.probeIdentity != id)) if relay != "" && ask && refresh { p.probeRelay, p.probeIdentity = normalizeStorageURL(relay), id } p.mu.Unlock() if relay != "" && ask && refresh { ctx, cancel := context.WithTimeout(p.ctx, requestTimeout) - _ = p.fetch(ctx) + err := p.fetch(ctx) cancel() + removed = errors.Is(err, wireauth.ErrRevoked) || errors.Is(err, wireauth.ErrGone) p.mu.Lock() p.lastRefresh = p.now() p.mu.Unlock() @@ -216,16 +243,18 @@ func (p *storageProcess) check(refresh bool) { } p.mu.Lock() p.active = false - again := p.dirty + removed = removed || p.refused != nil + again := p.dirty && !removed p.dirty = false var callbacks []func() - if state == "on" { + if !removed && state == "on" { for id, fn := range p.waiting { callbacks = append(callbacks, fn) delete(p.waiting, id) } } - if relay != "" && state == "off" && (p.users > 0 || len(p.waiting) > 0) { + // A removed device cannot regain access by polling for storage approval. + if !removed && relay != "" && state == "off" && (p.users > 0 || len(p.waiting) > 0) { wait := StorageRefreshEvery if p.users > 0 && !p.lastRefresh.IsZero() { wait = max(time.Millisecond, StorageRefreshEvery-p.now().Sub(p.lastRefresh)) diff --git a/internal/syncsetup/storage_refresh_test.go b/internal/syncsetup/storage_refresh_test.go index 9e6a45da49..f73fd4f396 100644 --- a/internal/syncsetup/storage_refresh_test.go +++ b/internal/syncsetup/storage_refresh_test.go @@ -238,3 +238,80 @@ func TestStorageOptInCustomProbeSharedByCallers(t *testing.T) { } } } + +// A terminal stats refusal must end scheduled work, including local waiter sampling. +func TestStorageOptInRefresherStopsAfterDeviceRemoval(t *testing.T) { + celltest.Available(t) + for _, refusal := range []struct { + code string + status int + storage string + }{ + {"revoked", http.StatusUnauthorized, "off"}, + {"gone", http.StatusGone, "off"}, + {"revoked", http.StatusUnauthorized, "unknown"}, + {"gone", http.StatusGone, "unknown"}, + } { + t.Run(refusal.code+"/"+refusal.storage, func(t *testing.T) { + var requests atomic.Int32 + var approved atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/v1/store/stats" || r.Header.Get("Codeaf-Sig") == "" { + t.Errorf("unexpected stats request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + if approved.Load() { + _, _ = io.WriteString(w, `{"storage":"on"}`) + return + } + w.WriteHeader(refusal.status) + _, _ = io.WriteString(w, `{"err":"`+refusal.code+`"}`) + })) + defer srv.Close() + home := machine(t, srv.URL) + id, err := identity.Load(home) + if err != nil { + t.Fatal(err) + } + if refusal.storage == "off" { + if err := SaveStorage(home, srv.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + } + p := storageProcessFor(home) + p.users = 1 + p.waiting[1] = func() { t.Error("removed device woke a waiting chat") } + var next func() + p.after = func(_ time.Duration, fn func()) func() bool { + next = fn + return func() bool { next = nil; return true } + } + defer p.disarm() + p.check(true) + if requests.Load() != 1 || next != nil { + t.Fatalf("removed device: requests=%d timer=%v", requests.Load(), next != nil) + } + // Another waiter or caller cannot restart the refused lifetime. + p.check(false) + p.check(true) + if requests.Load() != 1 || next != nil { + t.Fatalf("refusal restarted: requests=%d timer=%v", requests.Load(), next != nil) + } + // Pairing again supplies a different device key and a new eligible lifetime. + delete(p.waiting, 1) + dev, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + if err := identity.Install(home, id, dev); err != nil { + t.Fatal(err) + } + approved.Store(true) + p.check(true) + if requests.Load() != 2 || StorageState(home, srv.URL, id.ID()) != "on" || next != nil { + t.Fatalf("new pairing did not discover approval: requests=%d timer=%v", requests.Load(), next != nil) + } + }) + } +} diff --git a/internal/tui3/settings_devices.go b/internal/tui3/settings_devices.go index 6f2b2007a7..195315c025 100644 --- a/internal/tui3/settings_devices.go +++ b/internal/tui3/settings_devices.go @@ -28,7 +28,8 @@ import ( const ( // tabDevices is the category title on the settings bar, placed between // Connections and Privacy. - tabDevices = "Devices" + tabDevices = "Devices" + storageLabel = "sync storage" // devicesAddKey is the key legend clause for the row that starts pairing // another computer. devicesAddKey = "enter add" @@ -269,7 +270,7 @@ func (s *sheet) appendDevicesSearch(terms []fuzzy.Term) int { } } if s.storageCode != "" { - if score, ok, at, size := s.matchHits([]string{"relay storage", "request access", tabDevices}, terms); ok { + if score, ok, at, size := s.matchHits([]string{storageLabel, "request access", tabDevices}, terms); ok { best = max(best, score) matched = append(matched, devHit{item: sheetItem{dev: &deviceSheetRow{storage: true}, hitAt: at, hitLen: size}, score: score}) } @@ -301,7 +302,7 @@ func (s *sheet) devRowLines(row *deviceSheetRow, hit []int, selected, hovered bo return overlayLines(s.devicesUnavailable(), "", selected, false, hovered, width, pal) } if row.storage { - return overlayLinesHit("relay storage", s.storageState, hit, selected, false, hovered, width, pal) + return overlayLinesHit(storageLabel, s.storageState, hit, selected, false, hovered, width, pal) } if row.add { return overlayLinesHit(addDeviceWord, "", hit, selected, false, hovered, width, pal) diff --git a/internal/tui3/statusnote.go b/internal/tui3/statusnote.go index c45efc664e..c76903a1bd 100644 --- a/internal/tui3/statusnote.go +++ b/internal/tui3/statusnote.go @@ -118,7 +118,7 @@ func (a *app) statusItems() []deckItem { items = append(items, item) } if a.relayStorageOff() { - items = append(items, deckItem{label: "relay storage", value: "off"}) + items = append(items, deckItem{label: storageLabel, value: "off"}) } else if word := a.syncSpaceWord(); word != "" { items = append(items, deckItem{label: "sync space", value: word}) } diff --git a/internal/tui3/storage_optin_test.go b/internal/tui3/storage_optin_test.go index 9da80ce435..10cfb1cc3c 100644 --- a/internal/tui3/storage_optin_test.go +++ b/internal/tui3/storage_optin_test.go @@ -42,7 +42,7 @@ func TestStorageOptInDevicesRequestAndStatus(t *testing.T) { r.press("enter") r.until("storage request opener", func() bool { return opened != "" }) parsed, err := url.Parse(opened) - if err != nil || !strings.HasPrefix(opened, syncsetup.StorageRequestURL+"?") || !strings.Contains(parsed.Query().Get("title"), code) { + if err != nil || !strings.HasPrefix(opened, syncsetup.StorageRequestURL+"?") || parsed.Query().Get("title") != "sync storage access: "+code || parsed.Query().Get("body") != "Please turn on sync storage for my account.\n\nRequest code: "+code { t.Fatalf("request link: %s %v", opened, err) } details := plain(frame(a)) @@ -51,7 +51,7 @@ func TestStorageOptInDevicesRequestAndStatus(t *testing.T) { } a.syncSpace = blobstore.Counts{Storage: "off"} text := a.statusText() - if !strings.Contains(text, "relay storage") || !strings.Contains(text, "off") || strings.Contains(text, "sync space") { + if !strings.Contains(text, "sync storage") || !strings.Contains(text, "off") || strings.Contains(text, "sync space") { t.Fatalf("off status: %s", text) } for _, fact := range a.statusFacts(text) { @@ -65,7 +65,7 @@ func TestStorageOptInDevicesRequestAndStatus(t *testing.T) { t.Fatal("enabled storage still asks for access") } a.syncSpace = blobstore.Counts{Storage: "on"} - if strings.Contains(a.statusText(), "relay storage") { + if strings.Contains(a.statusText(), "sync storage") { t.Fatal("enabled status has off line") } } @@ -101,7 +101,7 @@ func TestStorageOptInSearchRequestShowsFallbackFrame(t *testing.T) { processOpener = func(string) error { return errors.New("no browser") } t.Cleanup(func() { processOpener = old }) r.press("ctrl+u") - r.typeText("relay storage") + r.typeText("sync storage") for i, item := range a.sheet.items { if item.dev != nil && item.dev.storage { a.sheet.cursor = i @@ -145,12 +145,12 @@ func TestStorageOptInStatusPrefersCurrentPermission(t *testing.T) { a.approvals = door a.syncService = "http://127.0.0.1:18831" a.syncSpace = blobstore.Counts{Storage: "on"} - if !strings.Contains(a.statusText(), "relay storage") { + if !strings.Contains(a.statusText(), "sync storage") { t.Fatal("cached refusal hidden by earlier usage answer") } door.state = "on" a.syncSpace.Storage = "off" - if strings.Contains(a.statusText(), "relay storage") { + if strings.Contains(a.statusText(), "sync storage") { t.Fatal("cached approval hidden by earlier usage answer") } } diff --git a/internal/wireauth/wireauth.go b/internal/wireauth/wireauth.go index 8ab3265493..a4862ab3e6 100644 --- a/internal/wireauth/wireauth.go +++ b/internal/wireauth/wireauth.go @@ -74,7 +74,7 @@ var ErrRateLimited = errors.New("wireauth: rate limited") var ErrTooManyIdentities = errors.New("wireauth: too many new identities") // ErrStorageOff means the relay has not enabled storage for this account. -var ErrStorageOff = errors.New("wireauth: relay storage off") +var ErrStorageOff = errors.New("wireauth: sync storage off") // ErrRelayFull identifies shared storage capacity on both wires. Each decoder // also retains its own general full error so existing callers keep matching it. From ae2f752043dabd7919deb8666e6db435b91ffd44 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sat, 7 Nov 2026 23:49:23 -0500 Subject: [PATCH 11/18] Stop the approval refresher from outliving its caller The last user to stop the refresher now cancels the request in flight and waits for that check to finish, and a check that starts after everyone has left does nothing. Before this a serving loop could return while its startup check was still saving the relay's answer into the home, which a temporary home's cleanup saw as "directory not empty" and the race detector saw as a read after the test ended. Co-Authored-By: Claude Opus 5.5 --- internal/syncsetup/storage_refresh.go | 48 ++++++++++++++++++---- internal/syncsetup/storage_refresh_test.go | 37 +++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/internal/syncsetup/storage_refresh.go b/internal/syncsetup/storage_refresh.go index b6333ffe40..4b43658722 100644 --- a/internal/syncsetup/storage_refresh.go +++ b/internal/syncsetup/storage_refresh.go @@ -21,14 +21,20 @@ var storageProcesses = struct { }{homes: make(map[string]*storageProcess)} type storageProcess struct { - mu sync.Mutex - home string - users int - next uint64 - waiting map[uint64]func() - opened map[uint64]func() - stop func() bool - ctx context.Context + mu sync.Mutex + home string + users int + next uint64 + waiting map[uint64]func() + opened map[uint64]func() + stop func() bool + // ctx belongs to the current lifetime of users; the last user to leave + // cancels it, so a request in flight cannot outlive everyone who asked. + ctx context.Context + cancel context.CancelFunc + // inflight is open while a check runs and closed when it ends, so the + // last user can wait for it instead of returning while it still writes. + inflight chan struct{} lastRefresh time.Time active bool dirty bool @@ -88,6 +94,9 @@ func StartStorageRefresh(ctx context.Context, home string, initial ...bool) func p.mu.Lock() p.users++ first := p.users == 1 + if first && (p.ctx == nil || p.ctx.Err() != nil || p.cancel == nil) { + p.ctx, p.cancel = context.WithCancel(context.Background()) + } p.mu.Unlock() if first { refresh := len(initial) == 0 || initial[0] @@ -98,10 +107,20 @@ func StartStorageRefresh(ctx context.Context, home string, initial ...bool) func once.Do(func() { p.mu.Lock() p.users-- + var running chan struct{} if p.users == 0 && len(p.waiting) == 0 { p.disarm() + if p.cancel != nil { + p.cancel() + } + running = p.inflight } p.mu.Unlock() + // A check that already started may still be saving its answer into + // the home; the caller must not return while that write can land. + if running != nil { + <-running + } }) } done := make(chan struct{}) @@ -197,12 +216,21 @@ func (p *storageProcess) disarm() { func (p *storageProcess) check(refresh bool) { p.mu.Lock() + // Checks start on their own goroutines, so one can begin after the last + // user and waiter left. It then has nobody to answer and must not touch + // the home, or it outlives the lifetime that started it. + if p.users == 0 && len(p.waiting) == 0 { + p.mu.Unlock() + return + } if p.active { p.dirty = true p.mu.Unlock() return } p.active = true + p.inflight = make(chan struct{}) + requests := p.ctx p.disarm() p.mu.Unlock() relay, id, state := p.read() @@ -232,7 +260,7 @@ func (p *storageProcess) check(refresh bool) { } p.mu.Unlock() if relay != "" && ask && refresh { - ctx, cancel := context.WithTimeout(p.ctx, requestTimeout) + ctx, cancel := context.WithTimeout(requests, requestTimeout) err := p.fetch(ctx) cancel() removed = errors.Is(err, wireauth.ErrRevoked) || errors.Is(err, wireauth.ErrGone) @@ -243,6 +271,8 @@ func (p *storageProcess) check(refresh bool) { } p.mu.Lock() p.active = false + close(p.inflight) + p.inflight = nil removed = removed || p.refused != nil again := p.dirty && !removed p.dirty = false diff --git a/internal/syncsetup/storage_refresh_test.go b/internal/syncsetup/storage_refresh_test.go index f73fd4f396..96889327cf 100644 --- a/internal/syncsetup/storage_refresh_test.go +++ b/internal/syncsetup/storage_refresh_test.go @@ -315,3 +315,40 @@ func TestStorageOptInRefresherStopsAfterDeviceRemoval(t *testing.T) { }) } } + +// THE LAST USER TO LEAVE CANCELS THE CHECK IN FLIGHT AND WAITS FOR IT. A +// serving loop that has returned must not leave a request behind that later +// writes its answer into the home, which is what a temporary home's cleanup +// caught as "directory not empty". +func TestStorageOptInLastStopCancelsAndJoinsTheCheckInFlight(t *testing.T) { + home := t.TempDir() + p := storageProcessFor(home) + p.read = func() (string, string, string) { + return "https://relay.example", "id_0123456789abcdef0123456789abcdef", "off" + } + p.after = func(time.Duration, func()) func() bool { return func() bool { return true } } + started, finished := make(chan struct{}), make(chan struct{}) + p.fetch = func(ctx context.Context) error { + close(started) + <-ctx.Done() + time.Sleep(20 * time.Millisecond) // the answer is still being saved after the cancel + close(finished) + return ctx.Err() + } + stop := StartStorageRefresh(context.Background(), home) + <-started + stop() + select { + case <-finished: + default: + t.Fatal("the last stop returned while its check was still running") + } + again := StartStorageRefresh(context.Background(), home, false) + defer again() + p.mu.Lock() + live := p.ctx.Err() == nil + p.mu.Unlock() + if !live { + t.Fatal("a new lifetime kept the cancelled context") + } +} From 038f4dd6de87e1bb4345f639d8564d8d6d593e93 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sun, 8 Nov 2026 00:15:10 -0500 Subject: [PATCH 12/18] Add the change entry for #2509 Co-Authored-By: Claude Opus 5.5 --- .../unreleased/2509-relay-storage-opt-in.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/changes/unreleased/2509-relay-storage-opt-in.md diff --git a/docs/changes/unreleased/2509-relay-storage-opt-in.md b/docs/changes/unreleased/2509-relay-storage-opt-in.md new file mode 100644 index 0000000000..3cf24e47e0 --- /dev/null +++ b/docs/changes/unreleased/2509-relay-storage-opt-in.md @@ -0,0 +1,19 @@ +--- +kind: changed +title: Hosted sync storage is off until an account is approved +pr: 2509 +surface: [chat, engine, remote, docs] +invalidates: + - "Pairing two computers through the hosted relay made it store chats, files, saved keys, memories, teams and task copies automatically; it now stores nothing for an account until that account is approved, while pairing, the device list, presence and live --at keep working." + - "Every hosted relay write was accepted up to the account's 5 GiB limit; a write from an account that is not approved is now refused with 403 storage_off before anything is stored, and frames stored before this change stay readable." + - "There was no way to grant or withdraw hosted storage per account; codeaf-relay-admin approve and revoke send an operator-signed request to POST /v1/admin/storage, checked against CAF_ADMIN_KEYS, and CAF_LIMITS storageOpen opens storage for everyone." + - "Rotating an identity needed nothing from the hosted relay beyond the freeze; the freeze bytes are unchanged, and a relay that reports storage also takes a separate POST /v1/store/handoff from the freezing device so an approved account keeps storage." +--- +The privacy terms for the hosted relay are not finished, so until they are it +keeps nobody's chats or files. A person asks for storage from Settings → +Devices → sync storage, which opens a pre-filled GitHub issue carrying their +request code, and an operator approves the account with `codeaf-relay-admin`. +The app learns of the approval by itself within about a minute of its next check +and starts uploading without a restart. While storage is off, `/status` says +`sync storage off`, chats keep saving on the computer, and the app makes no store +writes. A self-hosted relay is unaffected and needs no request. From d5d234ddd9c5bdab9cd8d4fb08ffac6b86eb9489 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sun, 8 Nov 2026 11:00:08 -0500 Subject: [PATCH 13/18] Keep storage-off chats usable and show sync sentences in the window The macOS hand check found these on the default engine road, where the conversation lives in the engine process and the window is attached to it. Sync refusals (storage off, storage full, removed, replaced, clock skew, displacement) were said on a desk inside the engine process that no window reads. They now go through each conversation's own notice road, which the engine already forwards to every attached window. Registrations retire with their owning session, an idle in-process window is woken at once, and a new window is told only a refusal that is still in force. Continuing a chat while storage is off chooses its road by local work. With nothing local to keep, the unchanged path reads the stored history. Local turns that extend the recorded head continue in place without any upload, and the chat sends at once instead of waiting for a saver that cannot start while off. Local turns that diverged are refused with every turn kept. After a later approval, this computer's own expired hold over an empty head re-acquires and publishes instead of opening a viewer. Closing one chat whose turns are saved here no longer says it could not be saved, and an older peer's positive receipt gets the saved-here wording. The first /status after pairing shows the storage line, Settings search offers request access only while storage is off, an open window notices pairing, and Settings reads storage only where it shows it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/approvals.go | 7 +- cmd/codeaf/chatv3.go | 9 + cmd/codeaf/chatv3_layout.go | 8 +- cmd/codeaf/final_save.go | 14 +- cmd/codeaf/storage_off_notices_test.go | 238 ++++++++++++++ cmd/codeaf/storage_off_watches_test.go | 242 ++++++++++++++ cmd/codeaf/syncdrive.go | 97 +++++- cmd/codeaf/syncdrive_live.go | 23 +- cmd/codeaf/synctake.go | 2 +- internal/cellstore/sealwatch.go | 40 +++ internal/session/agent.go | 6 + internal/syncsetup/drive.go | 5 + internal/syncsetup/storage.go | 9 + .../syncsetup/storage_off_approve_test.go | 183 +++++++++++ .../syncsetup/storage_off_continue_test.go | 218 +++++++++++++ internal/syncsetup/storage_refresh.go | 8 +- internal/syncsetup/take.go | 79 ++++- internal/syncwords/errors.go | 17 + internal/syncwords/words.go | 10 + internal/tui3/app.go | 37 ++- internal/tui3/conversation_close.go | 27 +- internal/tui3/homepanel_continue.go | 10 +- internal/tui3/homepanel_heldelsewhere.go | 3 + internal/tui3/keeper.go | 11 +- internal/tui3/place_settings.go | 46 ++- internal/tui3/sealseam.go | 16 +- internal/tui3/settings.go | 10 +- internal/tui3/settings_devices.go | 3 +- internal/tui3/storage_off_settings_test.go | 297 ++++++++++++++++++ internal/tui3/storage_off_window_test.go | 157 +++++++++ internal/tui3/syncspace.go | 3 +- internal/tui3/takeover.go | 2 +- 32 files changed, 1769 insertions(+), 68 deletions(-) create mode 100644 cmd/codeaf/storage_off_notices_test.go create mode 100644 cmd/codeaf/storage_off_watches_test.go create mode 100644 internal/syncsetup/storage_off_approve_test.go create mode 100644 internal/syncsetup/storage_off_continue_test.go create mode 100644 internal/tui3/storage_off_settings_test.go create mode 100644 internal/tui3/storage_off_window_test.go diff --git a/cmd/codeaf/approvals.go b/cmd/codeaf/approvals.go index d1cb36a5de..7970b9bed6 100644 --- a/cmd/codeaf/approvals.go +++ b/cmd/codeaf/approvals.go @@ -281,7 +281,7 @@ func (d *approvalsDoor) SyncService() string { return syncsetup.Resolve(d.dir).U // RelayStorage reads this computer's account without creating keys or asking the network. func (d *approvalsDoor) RelayStorage() (code, state string) { relay := syncsetup.Resolve(d.dir) - if !relay.On() || !syncsetup.MayTalk(d.dir) { + if !relay.On() || identity.Solo(d.dir) { return "", "" } id, err := identity.Load(d.dir) @@ -289,8 +289,9 @@ func (d *approvalsDoor) RelayStorage() (code, state string) { return "", "" } code = reqsign.IDOf(id.PublicKey()) - if !syncsetup.StorageGated(d.dir, relay.URL, code) { + state = syncsetup.ReportedStorage(d.dir, relay.URL, code) + if state == "" { return "", "" } - return code, syncsetup.StorageState(d.dir, relay.URL, code) + return code, state } diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index fd12a18115..70e2ffc4cf 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1453,6 +1453,14 @@ func openV3Agent(cfg session.Config, workspace string, open func(session.Config) return nil, cfg, "", errors.New(chatArrivingSentence) } cfg = v3Seated(v3Migrated(cfg)) + constructed := false + defer func() { + if !constructed { + if seals, ok := cfg.Seals.(interface{ Retire() }); ok { + seals.Retire() + } + } + }() // Restore the gate before construction, so restored work cannot start behind // the profile default. Return the launch config unchanged: a subsequent new // conversation must not inherit this one's saved override. @@ -1493,6 +1501,7 @@ func openV3Agent(cfg session.Config, workspace string, open func(session.Config) if savedEffort != "" { agent.SetConversationEffort(savedEffort) } + constructed = true return agent, cfg, "", nil } if !errors.Is(err, session.ErrSessionLocked) { diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 04eaddd840..d57f06e638 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -826,12 +826,15 @@ func v3SeatWith(place session.Place, say func(string), drives *driveBook) (execu machine, err := preflight.OpenMachine(c.Root, place.Workspace) watch := &cellstore.SealWatch{Say: say, SyncAddress: chatSyncService()} failed(watch, err) - drive := drives.driveOf(c, cellstore.EngineFor(place.Workspace), watch.SyncReport) + drive := drives.driveOf(c, cellstore.EngineFor(place.Workspace), watch.SyncReport, watch) if drive != nil { watch.OnTurnEnd = drive.Idle // the agent waits for the person: upload what is sealed now } seat, err := cellstore.SeatOver(class, c, place.Workspace, observerOf(machine), watch.Report, driveStore(drive), cellstore.WithNotices(watch.Note), cellstore.WithPipeline(), cellstore.WithWarmCapture()) failed(watch, err) + if err != nil { + watch.Retire() + } watch.OnTail = func() { sealTail(seat, drive) } // A fresh seat has no foreground turn in flight. Background tasks may be // the first input; their journal writes need a tail seal even before chat. @@ -874,6 +877,9 @@ func sealSeamOf(agent any, seals session.SealState) tui3.SealSeam { if seals != nil { seam = tui3.SealSeam{Failing: seals.Failing, Notice: seals.Take} } + if watch, ok := seals.(interface{ Listen(func()) func() }); ok { + seam.Listen = watch.Listen + } if door, ok := agent.(interface{ SealFailing() bool }); ok { seam.Failing = door.SealFailing } diff --git a/cmd/codeaf/final_save.go b/cmd/codeaf/final_save.go index 1ee153ea8e..e50027612f 100644 --- a/cmd/codeaf/final_save.go +++ b/cmd/codeaf/final_save.go @@ -8,7 +8,6 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/syncsetup" "github.com/Agent-Field/codeaf/internal/syncwords" - "github.com/Agent-Field/codeaf/internal/wireauth" "os" "os/signal" "strings" @@ -25,8 +24,8 @@ const quitSavingWord = "saving this chat to your other devices…" const quitStillSavingWord = "still saving — ctrl+c leaves it saved here" const quitStillLocalWord = "still saving on this computer — ctrl+c leaves the written turns here" const quitLegacySavingWord = "codeaf is still saving this chat in the background; unsaved turns stay on this computer." -const quitStorageOffWord = "saved here; sync storage is off for this account, so it stays on this computer" -const quitSavedHereWord = "saved here; it reaches your other devices when the sync service at %s answers" +const quitStorageOffWord = syncwords.SavedStorageOff +const quitSavedHereWord = syncwords.SavedHerePending // finalSaving keeps one failure until cleanup has returned the terminal. var finalSaving struct { @@ -178,7 +177,7 @@ func recordFinalSaveFailure(err error) { } if syncwords.UploadPending(err) { finalSaving.finishing = true - if finalSaving.failure == nil || syncwords.UploadPending(finalSaving.failure) && errors.Is(err, wireauth.ErrStorageOff) { + if finalSaving.failure == nil || syncwords.UploadPending(finalSaving.failure) && syncwords.SavedWithStorageOff(err) { finalSaving.failure = err } return @@ -267,11 +266,8 @@ func finishFinalSaving(ctx context.Context) (ok bool) { // finalSaveWords distinguishes durable local turns from a failed local write. func finalSaveWords(err error, finishing bool, address string) (string, bool) { - if syncwords.UploadPending(err) { - if errors.Is(err, wireauth.ErrStorageOff) { - return quitStorageOffWord, true - } - return fmt.Sprintf(quitSavedHereWord, syncwords.Host(address)), true + if line := syncwords.SavedHereWords(err, address); line != "" { + return line, true } if (err == nil && finishing) || errors.Is(err, session.ErrSavingUnanswered) { return "saving on this computer has not finished; its written turns stay here", false diff --git a/cmd/codeaf/storage_off_notices_test.go b/cmd/codeaf/storage_off_notices_test.go new file mode 100644 index 0000000000..7970c0e8f5 --- /dev/null +++ b/cmd/codeaf/storage_off_notices_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// B4 crosses the engine/window protocol with the real seat watch and remote client. +func TestStorageNoticeReachesEngineWindow(t *testing.T) { + for _, mode := range []string{"refused start", "revoke", "no-host", "storage on", "self-hosted"} { + t.Run(mode, func(t *testing.T) { + cfg, c := spokenDoorChat(t) + t.Cleanup(cellstore.StopSpawned) + oldDesk := surfaceNotices + surfaceNotices = tui3.NewNotices() + defer func() { surfaceNotices = oldDesk }() + service := relayserve.New(relayserve.Config{Store: t.TempDir()}) + defer service.Close() + var refuse atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if refuse.Load() && r.Method == http.MethodPost && r.URL.Path == "/v1/store/frames" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"err":"storage_off"}`)) + return + } + service.Handler.ServeHTTP(w, r) + })) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(syncsetup.IntervalVar, "50") + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + if mode != "self-hosted" { + state := "on" + if mode == "refused start" || mode == "no-host" { + state = "off" + } + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), state); err != nil { + t.Fatal(err) + } + } + cfg = v3Seated(cfg) + // This fixture drives its seals explicitly and must not leave a trailing timer writing during cleanup. + cfg.Seals.(*cellstore.SealWatch).OnTail = nil + drive := syncDrives.renewalsOf(c.ID) + if drive == nil { + t.Fatal("seat has no live drive") + } + startupFinished(drive) + defer func() { _ = syncDrives.closeCell(c.ID) }() + seat := cfg.Seat + cfg.Seat = nil + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + var take func() string + if mode == "no-host" { + defer agent.Close() + if got := storageOffLocalNoticeCount(t, agent, cfg); got != 1 { + t.Fatalf("local window received notice %d times", got) + } + return + } else { + loop, err := remote.Loopback(remote.Hello{}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: agent, SessionFile: cfg.SessionFile}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + take = loop.Client.TakeNotice + } + if mode == "revoke" { + if drive.started() == nil { + t.Fatal("revoke fixture never started") + } + refuse.Store(true) + if err := toolCallOn(t, seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + drive.Idle() + } + count := 0 + deadline := time.After(3 * time.Second) + quiet := time.NewTimer(350 * time.Millisecond) + defer quiet.Stop() + tick := time.NewTicker(10 * time.Millisecond) + defer tick.Stop() + want := mode != "storage on" && mode != "self-hosted" + waiting: + for { + for line := take(); line != ""; line = take() { + if line == chatlist.RelayStorageOff { + count++ + } + } + select { + case <-tick.C: + case <-quiet.C: + if !want || count > 0 { + break waiting + } + case <-deadline: + break waiting + } + } + if want && count != 1 || !want && count != 0 { + t.Fatalf("window received storage-off %d times", count) + } + }) + } +} + +// storageOffLocalNoticeCount gives both in-process notice roads to the real surface. +func storageOffLocalNoticeCount(t *testing.T, agent *session.Agent, cfg session.Config) int { + t.Helper() + input, writer := io.Pipe() + defer input.Close() + defer writer.Close() + screen := &syncOffFrameCapture{} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, SessionFile: cfg.SessionFile, Workspace: cfg.Place.Workspace, Seal: sealSeamOf(agent, cfg.Seals), Notices: surfaceNotices, Input: input, Output: screen, Width: 200, Height: 40, Linear: true}) + }() + defer func() { cancel(); <-done }() + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + ticks := time.NewTicker(10 * time.Millisecond) + defer ticks.Stop() + for { + shown := strings.Join(strings.Fields(screen.Plain()), " ") + if strings.Contains(shown, chatlist.RelayStorageOff) { + return strings.Count(shown, chatlist.RelayStorageOff) + } + select { + case <-ticks.C: + case <-deadline.C: + t.Fatalf("local window missed storage notice: %s", shown) + return 0 + } + } +} + +type storageOffNoticeAgent struct { + quietAgent + watch *cellstore.SealWatch +} + +func (a *storageOffNoticeAgent) TakeSealNotice() string { return a.watch.Take() } + +// B4 gives each seat sharing a drive one notice over its own real window connection. +func TestSharedDriveNoticesEachWindow(t *testing.T) { + cfg, c := spokenDoorChat(t) + service := relayserve.New(relayserve.Config{Store: t.TempDir()}) + defer service.Close() + srv := httptest.NewServer(service.Handler) + defer srv.Close() + defer syncDrives.closeAll() + t.Setenv(syncsetup.URLVar, srv.URL) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + if err := syncsetup.SaveStorage(home.Dir(), syncsetup.Resolve(home.Dir()).URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + cfg = v3Seated(cfg) + d := syncDrives.renewalsOf(c.ID) + startupFinished(d) + _, _, second := v3SeatWith(cfg.Place, nil, syncDrives) + if syncDrives.renewalsOf(c.ID) != d { + t.Fatal("seats did not share a drive") + } + for _, watch := range []*cellstore.SealWatch{cfg.Seals.(*cellstore.SealWatch), second} { + loop, err := remote.Loopback(remote.Hello{}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: &storageOffNoticeAgent{watch: watch}}, nil + }}) + if err != nil { + t.Fatal(err) + } + func() { + defer loop.Close() + count := 0 + ticks := time.NewTicker(10 * time.Millisecond) + defer ticks.Stop() + deadline := time.NewTimer(350 * time.Millisecond) + defer deadline.Stop() + for { + for line := loop.Client.TakeNotice(); line != ""; line = loop.Client.TakeNotice() { + if line == chatlist.RelayStorageOff { + count++ + } + } + select { + case <-ticks.C: + case <-deadline.C: + if count != 1 { + t.Errorf("shared-drive window received %d notices", count) + } + return + } + } + }() + } + + // Approval must not replay the old refusal to a newly opened seat. + if err := syncsetup.SaveStorage(home.Dir(), syncsetup.Resolve(home.Dir()).URL, id.ID(), "on"); err != nil { + t.Fatal(err) + } + startupFinished(d) + _, _, approved := v3SeatWith(cfg.Place, nil, syncDrives) + for line := approved.Take(); line != ""; line = approved.Take() { + if line == chatlist.RelayStorageOff { + t.Fatal("approved seat replayed an obsolete refusal") + } + } +} diff --git a/cmd/codeaf/storage_off_watches_test.go b/cmd/codeaf/storage_off_watches_test.go new file mode 100644 index 0000000000..d92ede04b8 --- /dev/null +++ b/cmd/codeaf/storage_off_watches_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "context" + "errors" + "io" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +func storageOffNoticeConfig(t *testing.T) session.Config { + t.Helper() + cfg, _ := spokenDoorChat(t) + svc := relayserve.New(relayserve.Config{Store: t.TempDir()}) + t.Cleanup(func() { _ = svc.Close() }) + srv := httptest.NewServer(svc.Handler) + t.Cleanup(srv.Close) + t.Setenv(syncsetup.URLVar, srv.URL) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + t.Cleanup(syncDrives.closeAll) + t.Cleanup(cellstore.StopSpawned) + return cfg +} + +// Item 4 exercises session retirement on retained in-process drives and ordinary engine cleanup. +func TestNoticeWatchesRetire(t *testing.T) { + for _, engine := range []bool{false, true} { + t.Run(map[bool]string{false: "in-process reopening", true: "engine cleanup"}[engine], func(t *testing.T) { + base := storageOffNoticeConfig(t) + for i := 0; i < 4; i++ { + cfg := v3Seated(base) + d := syncDrives.renewalsOf(cfg.Place.ID()) + startupFinished(d) + cfg.Seals.(*cellstore.SealWatch).OnTail = nil + if got := storageOffWatchCount(d.notices); got != 1 { + t.Fatalf("reopen %d live watches=%d", i, got) + } + // Closing the real seated agent must release its subscription and pending captures. + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + // Clarification conversations inherit the parent's watch without owning it. + childCfg := cfg + childCfg.Seat, childCfg.Place, childCfg.SessionFile = nil, session.Place{}, "" + childCfg.Errand = true + child, err := session.New(childCfg) + if err != nil { + t.Fatal(err) + } + if err := child.Close(); err != nil { + t.Fatal(err) + } + if got := storageOffWatchCount(d.notices); got != 1 { + t.Fatalf("child retired its parent's watch: %d", got) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + if engine { + if err := syncDrives.closeCell(cfg.Place.ID()); err != nil { + t.Fatal(err) + } + } + if got := storageOffWatchCount(d.notices); got != 0 { + t.Fatalf("closed session retained %d watches", got) + } + if engine && syncDrives.renewalsOf(cfg.Place.ID()) != nil { + t.Fatal("engine kept a closed drive") + } + } + }) + } +} + +func storageOffWatchCount(n *driveNotices) int { + n.mu.Lock() + defer n.mu.Unlock() + return len(n.watches) +} + +// Item 4 also retires a seated watch when construction fails before an agent exists. +func TestFailedConstructionRetiresNoticeWatch(t *testing.T) { + cfg := storageOffNoticeConfig(t) + _, seated, _, err := openV3Agent(cfg, cfg.Place.Workspace, func(session.Config) (*session.Agent, error) { return nil, errors.New("fixture construction failure") }) + // No agent owns this failed fixture's warm capture, so join it before removing its files. + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) + defer cancel() + if err := executor.Drain(ctx, seated.Seat); err != nil { + t.Error(err) + } + }() + if err == nil { + t.Fatal("construction unexpectedly succeeded") + } + d := syncDrives.renewalsOf(cfg.Place.ID()) + startupFinished(d) + if got := storageOffWatchCount(d.notices); got != 0 { + t.Fatalf("failed construction retained %d watches", got) + } +} + +// Item 5 replays only an unresolved startup refusal, never announcements or recovered conditions. +func TestNoticeReplayIsCurrent(t *testing.T) { + for _, mode := range []string{"first run", "storage full recovers", "storage off remains"} { + t.Run(mode, func(t *testing.T) { + _ = storageOffNoticeConfig(t) + n := &driveNotices{} + if mode == "first run" { + n.say("first-run announcement") + } else { + entered := make(chan struct{}) + startErr := wireauth.ErrRelayFull + if mode == "storage off remains" { + startErr = wireauth.ErrStorageOff + } + l := newLiveDrive(func(func()) (*syncsetup.Drive, error) { <-entered; return nil, startErr }, n) + l.after = func(time.Duration, func()) func() bool { return func() bool { return true } } + close(entered) + startupFinished(l) + defer l.Close(t.Context()) + if mode == "storage full recovers" { + l.start = func() (*syncsetup.Drive, error) { return &syncsetup.Drive{}, nil } + l.retryAt = time.Time{} + l.try() + startupFinished(l) + // The empty fixture drive has no process to close. + l.drive = nil + } + } + watch := &cellstore.SealWatch{} + n.add(watch) + got := watch.Take() + want := "" + if mode == "storage off remains" { + want = chatlist.RelayStorageOff + } + if got != want || watch.Take() != "" { + t.Fatalf("replayed %q want %q, exactly once", got, want) + } + }) + } +} + +// Item 6 delivers a later notice to an idle in-process window through its bell, exactly once. +func TestIdleLocalWindowGetsNoticeAtOnce(t *testing.T) { + previousDesk := surfaceNotices + surfaceNotices = tui3.NewNotices() + defer func() { surfaceNotices = previousDesk }() + cfg := storageOffNoticeConfig(t) + cfg = v3Seated(cfg) + d := syncDrives.renewalsOf(cfg.Place.ID()) + startupFinished(d) + watch := cfg.Seals.(*cellstore.SealWatch) + watch.OnTail = nil + for watch.Take() != "" { + } + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + input, writer := io.Pipe() + defer input.Close() + defer writer.Close() + screen := &syncOffFrameCapture{} + seam := sealSeamOf(agent, cfg.Seals) + pushed := make(chan struct{}, 1) + // Observe the bell binding without requiring its new field in the before-fix tree. + listener := reflect.ValueOf(&seam).Elem().FieldByName("Listen") + if listener.IsValid() && !listener.IsNil() { + listen := listener.Interface().(func(func()) func()) + listener.Set(reflect.ValueOf(func(wake func()) func() { + return listen(func() { + select { + case pushed <- struct{}{}: + default: + } + wake() + }) + })) + } + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, SessionFile: cfg.SessionFile, Workspace: cfg.Place.Workspace, Seal: seam, Notices: surfaceNotices, Input: input, Output: screen, Width: 200, Height: 40, Linear: true}) + }() + defer func() { cancel(); <-done }() + deadline := time.Now().Add(3 * time.Second) + for screen.Plain() == "" && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + // The first frame has settled with no pending notice, key or three-second beat. + time.Sleep(50 * time.Millisecond) + select { + case <-pushed: + default: + } + line := "this chat moved to another computer" + d.notices.say(line) + select { + case <-pushed: + case <-time.After(500 * time.Millisecond): + t.Fatal("queued sentence did not ring the idle window's notice bell") + } + deadline = time.Now().Add(500 * time.Millisecond) + for !strings.Contains(screen.Plain(), line) && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + shown := strings.Join(strings.Fields(screen.Plain()), " ") + if !strings.Contains(shown, line) { + t.Fatalf("idle window missed push: %s", shown) + } + // Give an additional paint time to expose accidental duplicate consumers. + time.Sleep(100 * time.Millisecond) + shown = strings.Join(strings.Fields(screen.Plain()), " ") + if strings.Count(shown, line) != 1 || watch.Take() != "" { + t.Fatalf("idle sentence was not consumed exactly once: %s", shown) + } +} diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go index 258b5bf1ee..9d414cb75a 100644 --- a/cmd/codeaf/syncdrive.go +++ b/cmd/codeaf/syncdrive.go @@ -10,6 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncsetup" @@ -38,7 +39,7 @@ type driveBook struct { // A computer with no identity yet gets a drive side that starts by itself the // moment one is made (see liveDrive). A nil book answers nil, which is how a // door that never syncs asks. -func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(error)) *liveDrive { +func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(error), watches ...*cellstore.SealWatch) *liveDrive { if b == nil { return nil } @@ -49,6 +50,12 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er defer b.mu.Unlock() if d := b.drive[c.ID]; d != nil { if !d.superseded() { + if d.notices != nil { + if syncsetup.StorageForHome(home.Dir()) == "on" { + d.notices.clearStorageOff() + } + d.notices.register(watches...) + } return d } // Settle the displaced writer before the replacement reads its branch map @@ -65,7 +72,11 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er // back here) never drives again: the chat opened here needs a drive side of its own, or every // tool call in it would be refused as superseded. delete(b.drive, c.ID) - d := newLiveDrive(func(changed func()) (*syncsetup.Drive, error) { return startDrive(c, engine, report, changed) }) + notices := &driveNotices{} + notices.register(watches...) + d := newLiveDrive(func(changed func()) (*syncsetup.Drive, error) { + return startDriveNotifying(c, engine, report, notices.say, changed) + }, notices) d.transcript = (session.Place{Dir: c.Root}).Transcript() if d != nil { if b.drive == nil { @@ -79,6 +90,11 @@ func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(er // startDrive opens the drive side of c. Local failures are reported here; // liveDrive owns refusal notices and waits across startup attempts. func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), changed ...func()) (*syncsetup.Drive, error) { + return startDriveNotifying(c, engine, report, surfaceNotices.Say, changed...) +} + +// startDriveNotifying keeps every drive sentence on the conversation's own notice road. +func startDriveNotifying(c cell.Cell, engine cellstore.Engine, report func(error), say func(string), changed ...func()) (*syncsetup.Drive, error) { s, ok, err := syncsetup.Open(home.Dir()) if errors.Is(err, syncsetup.ErrNoIdentity) { // Waiting for an identity is not a seal failing: the live drive starts by itself once one @@ -100,7 +116,7 @@ func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), change return nil, wireauth.ErrStorageOff } if line, first := s.FirstRun(); first { - surfaceNotices.Say(line) + say(line) } var onChange func() if len(changed) > 0 { @@ -113,7 +129,7 @@ func startDrive(c cell.Cell, engine cellstore.Engine, report func(error), change // list names the chat by it. Title: func() string { return chatTitle(c) }, // A line is said by the surface the moment it happens (tui3/notices.go). - OnNotice: surfaceNotices.Say, + OnNotice: say, }) if _, refused := cellsync.RefusalOf(err); refused { return nil, err @@ -267,3 +283,76 @@ func (b *driveBook) openDrives() []*liveDrive { } return drives } + +// driveNotices fans out to each seat sharing a writer, including an idle engine seat. +// Only a current startup refusal is replayed while no drive is running. +type driveNotices struct { + mu sync.Mutex + last string + watches map[*cellstore.SealWatch]bool +} + +func (n *driveNotices) register(watches ...*cellstore.SealWatch) { + for _, watch := range watches { + if watch != nil { + watch.OnRetire = n.add(watch) + } + } +} + +func (n *driveNotices) add(watches ...*cellstore.SealWatch) func() { + n.mu.Lock() + defer n.mu.Unlock() + if n.watches == nil { + n.watches = make(map[*cellstore.SealWatch]bool) + } + for _, watch := range watches { + if watch == nil || n.watches[watch] { + continue + } + n.watches[watch] = true + if n.last != "" { + watch.Note(n.last) + } + } + return func() { + n.mu.Lock() + defer n.mu.Unlock() + for _, watch := range watches { + delete(n.watches, watch) + } + } +} + +// refuse caches only startup conditions, never first-run or running-drive announcements. +func (n *driveNotices) refuse(line string) { + n.mu.Lock() + defer n.mu.Unlock() + n.last = line + for watch := range n.watches { + watch.Note(line) + } +} + +func (n *driveNotices) clear() { + n.mu.Lock() + defer n.mu.Unlock() + n.last = "" +} + +func (n *driveNotices) say(line string) { + n.mu.Lock() + defer n.mu.Unlock() + for watch := range n.watches { + watch.Note(line) + } +} + +// clearStorageOff keeps a later seat from replaying a refusal that approval ended. +func (n *driveNotices) clearStorageOff() { + n.mu.Lock() + defer n.mu.Unlock() + if n.last == chatlist.RelayStorageOff { + n.last = "" + } +} diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go index 861ef9ac8a..3e1eee38e0 100644 --- a/cmd/codeaf/syncdrive_live.go +++ b/cmd/codeaf/syncdrive_live.go @@ -24,10 +24,11 @@ import ( // that time or capacity can cure also arms a timer, so pending turns upload even // when the person leaves the chat open without saving another turn. type liveDrive struct { - start func() (*syncsetup.Drive, error) - now func() time.Time - after func(time.Duration, func()) func() bool - say func(string) + start func() (*syncsetup.Drive, error) + now func() time.Time + after func(time.Duration, func()) func() bool + say func(string) + notices *driveNotices // transcript identifies the retained turns if this writer's final publish fails. transcript string @@ -49,11 +50,15 @@ type liveDrive struct { // newLiveDrive starts the drive side behind the first paint. Local startup // screening stays off the surface loop; saving and new tools wait for ownership. -func newLiveDrive(start func(func()) (*syncsetup.Drive, error)) *liveDrive { +func newLiveDrive(start func(func()) (*syncsetup.Drive, error), notices ...*driveNotices) *liveDrive { l := &liveDrive{ changes: make(chan struct{}, 1), now: time.Now, say: surfaceNotices.SayHomeOnce, after: func(d time.Duration, fn func()) func() bool { return time.AfterFunc(d, fn).Stop }, } + if len(notices) > 0 { + l.notices = notices[0] + l.say = l.notices.refuse + } l.start = func() (*syncsetup.Drive, error) { return start(l.renewalChanged) } l.try() return l @@ -129,6 +134,11 @@ func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) } } if d != nil { + if l.notices != nil { + l.notices.clear() + } + l.told = nil + l.refusedSince = time.Time{} d.CatchUp() } l.renewalChanged() @@ -332,6 +342,9 @@ func (l *liveDrive) catchUpAfter(used *syncsetup.Drive) { // storageOpened shortens only a capacity wait; it never replaces an active writer. func (l *liveDrive) storageOpened() { + if l.notices != nil { + l.notices.clearStorageOff() + } if d := l.endStorageWait(); d != nil { d.StorageOpened() } else { diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index 84dd806c0c..e3c689c2c5 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -318,7 +318,7 @@ func (t takeover) OpenBranch(ctx context.Context, id string) (tui3.Taken, error) } func surfaceTaken(got syncsetup.Continued, elapsed time.Duration) tui3.Taken { - return tui3.Taken{Fence: got.Taken.Driving.Fence, Head: got.Taken.Driving.Head, AlreadyHere: got.AlreadyHere, Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} + return tui3.Taken{Fence: got.Taken.Driving.Fence, Head: got.Taken.Driving.Head, AlreadyHere: got.AlreadyHere, LocalOnly: got.LocalOnly, Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} } // takeRootFor is where this machine keeps the chat with an id: the folder it diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index a2e22909a0..ec02ece5c3 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -36,6 +36,11 @@ type SealWatch struct { // Say, when set, receives each sentence as it happens. Say func(string) + // OnRetire releases seat-owned subscriptions after the session stops. + OnRetire func() + retired sync.Once + wake func() + listener uint64 // OnTurnEnd, when set, hears each time the agent finishes a turn. The door // sets it to the sync side's idle call. OnTurnEnd func() @@ -113,6 +118,41 @@ func (w *SealWatch) say(sentence string) { return } w.said = append(w.said, sentence) + if w.wake != nil { + w.wake() + } +} + +// Listen wakes a local surface without giving its sentences a second consuming road. +func (w *SealWatch) Listen(wake func()) func() { + w.mu.Lock() + defer w.mu.Unlock() + w.listener++ + serial := w.listener + w.wake = wake + if len(w.said) > 0 && wake != nil { + wake() + } + return func() { + w.mu.Lock() + defer w.mu.Unlock() + if w.listener == serial { + w.wake = nil + } + } +} + +// Retire stops subscriptions and tail work once this seat has no session left. +func (w *SealWatch) Retire() { + w.retired.Do(func() { + if w.OnRetire != nil { + w.OnRetire() + } + w.mu.Lock() + defer w.mu.Unlock() + w.stopTail() + w.wake = nil + }) } // Waiting arms a newly constructed chat that is waiting for its first input. diff --git a/internal/session/agent.go b/internal/session/agent.go index 65054f11b6..fdf84d661b 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2410,6 +2410,12 @@ func (f sessionCompleter) FallbackModels(model string) []string { // inside a tool must not hold the process open, and past the grace the journal // simply stops accepting writes rather than writing to a closed descriptor. func (a *Agent) Close() (saveErr error) { + // Child conversations may share the parent's watch without owning its subscriptions. + if !a.config.InTask && !a.config.Errand { + if seals, ok := a.config.Seals.(interface{ Retire() }); ok { + defer seals.Retire() + } + } defer func() { saveErr = SavingError(a.config.SessionFile, saveErr) }() // WHAT THE RECORD ALREADY HOLDS IS SETTLED BEFORE THE DOOR SHUTS, so an idle // session that read a landing in its last turn does not re-tell it tomorrow. diff --git a/internal/syncsetup/drive.go b/internal/syncsetup/drive.go index f5fd614211..987fb02e2d 100644 --- a/internal/syncsetup/drive.go +++ b/internal/syncsetup/drive.go @@ -286,6 +286,11 @@ func (s *Sync) driving(ctx context.Context, c cell.Cell) (cellsync.Driving, stri if err != nil { return drv, "", err } + // An empty head has no stored turns to require from this device's own + // expired hold; other devices retain the ordinary ancestry decision. + if view.Cell.Head == "" && view.Cell.Lease.Device == s.Device.ID() { + extends = true + } if !extends { return drv, nonemptyHolder(view.Cell.Lease.Device), nil } diff --git a/internal/syncsetup/storage.go b/internal/syncsetup/storage.go index 3a2ce87ba3..96cd6a2ac1 100644 --- a/internal/syncsetup/storage.go +++ b/internal/syncsetup/storage.go @@ -83,6 +83,15 @@ func StorageGated(home, relay, identity string) bool { return gating } +// ReportedStorage samples permission once for display, omitting services that do not gate storage. +func ReportedStorage(home, relay, identity string) string { + state, gating, _ := storagePolicy(home, relay, identity) + if !gating { + return "" + } + return state +} + // SaveStorage remembers a successful stats answer atomically. An empty state // records a relay without storage permission, so it need not be probed again. func SaveStorage(home, relay, identity, state string) error { diff --git a/internal/syncsetup/storage_off_approve_test.go b/internal/syncsetup/storage_off_approve_test.go new file mode 100644 index 0000000000..1c713bd386 --- /dev/null +++ b/internal/syncsetup/storage_off_approve_test.go @@ -0,0 +1,183 @@ +package syncsetup + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" +) + +// B5 permits stored-history reads when there is no local work to keep. +func TestStorageOffContinueReadsRecordedHistory(t *testing.T) { + for _, behind := range []bool{false, true} { + t.Run(map[bool]string{false: "absent root", true: "clean behind"}[behind], func(t *testing.T) { + h := newTwoHomes(t) + a := h.openA() + a.mustSay("first recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + if behind { + got, err := h.continuerB().Take(t.Context(), h.cell.ID) + mustNil(t, err) + mustNil(t, h.b.Dir.Release(t.Context(), h.cell.ID, got.Taken.Driving.Fence)) + a = h.openA() + a.mustSay("newer recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + } + want, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if behind { + local := cell.Cell{ID: h.cell.ID, Root: filepath.Join(h.rootsB, h.cell.ID)} + view, err := h.b.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + head, _, err := cellstore.Unpublished(local, view.Cell.Head) + mustNil(t, err) + dirty, err := h.continuerB().eng.Dirty(t.Context(), local) + mustNil(t, err) + if head != "" || dirty || headOf(t, local) == view.Cell.Head { + t.Fatal("fixture must be clean and behind the recorded history") + } + } + counted := &storageOffStore{Store: h.b.Store, off: true} + h.b.Store = counted + mustNil(t, SaveStorage(h.homeB, h.b.Relay, h.b.Identity.ID(), "off")) + got, err := h.continuerB().Take(t.Context(), h.cell.ID) + mustNil(t, err) + actual, err := os.ReadFile(transcriptOf(got.Taken.Cell)) + mustNil(t, err) + if !bytes.Equal(actual, want) || counted.writes != 0 { + t.Fatalf("history=%q want=%q store writes=%d", actual, want, counted.writes) + } + // Fetching history while off must not make the window wait for an uploader either. + storageOffRequireLocalReady(t, got) + }) + } +} + +// B5 carries every preserved turn through quit, expiry, approval and publication. +func TestContinueOffThenApprovePublishes(t *testing.T) { + celltest.RequireEngine(t) + for _, empty := range []bool{true, false} { + t.Run(map[bool]string{true: "empty head", false: "extended head"}[empty], func(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + mustNil(t, err) + dev, err := identity.Device(home) + mustNil(t, err) + now := time.Now() + dir := directory.NewMemory(func() time.Time { return now }) + defer dir.Close() + store := &storageOffStore{Store: blobstore.NewMemory()} + s := &Sync{Home: home, Relay: "http://127.0.0.1:1", Identity: id, Device: dev, Dir: dir.For(dev.ID()), Store: store, Ledger: "0123456789abcdef"} + t.Setenv(URLVar, s.Relay) + at, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + mustNil(t, err) + work := t.TempDir() + mustNil(t, session.SaveMeta(at.Root, session.Meta{ID: at.ID, Workspace: work, Created: now})) + eng := cellstore.EngineFor(work) + eng.DataRoot = t.TempDir() + t.Cleanup(cellstore.StopSpawned) + seal := func(words string) string { + appendTo(t, transcriptOf(at), `{"type":"message","role":"user","content":"`+words+`"}`+"\n") + got, err := eng.Seal(t.Context(), at, cellstore.TurnInfo{}) + mustNil(t, err) + return got.Turn.ID + } + base := "" + if !empty { + base = seal("recorded") + } + view, err := s.Dir.Create(t.Context(), at.ID, directory.CellInit{Head: base}) + mustNil(t, err) + mustNil(t, cellstore.NotePublished(at, base)) + seal("preserved one") + seal("preserved two") + mustNil(t, s.Dir.Release(t.Context(), at.ID, view.Cell.Lease.Fence)) + mustNil(t, SaveStorage(home, s.Relay, id.ID(), "off")) + store.off = true + continued, err := s.Continuer(eng, TakeOptions{RootFor: func(string) string { return at.Root }}).Take(t.Context(), at.ID) + mustNil(t, err) + preserved, err := os.ReadFile(transcriptOf(at)) + mustNil(t, err) + again, err := s.Continuer(eng, TakeOptions{RootFor: func(string) string { return at.Root }}).Take(t.Context(), at.ID) + mustNil(t, err) + if !again.AlreadyHere { + t.Fatal("current local hold was not reused") + } + storageOffRequireLocalReady(t, again) + if store.writes != 0 { + t.Fatalf("off writes=%d", store.writes) + } + // No drive runs while off, so quitting leaves the directory hold to expire. + now = now.Add(directory.LeaseTTL + time.Second) + store.off = false + mustNil(t, SaveStorage(home, s.Relay, id.ID(), "on")) + opened, err := cell.OpenAt(continued.Taken.Cell.Root, at.ID) + mustNil(t, err) + // A fresh owner reads the durable continuation result after the old process quits. + reopened := &Sync{Home: home, Relay: s.Relay, Identity: id, Device: dev, Dir: dir.For(dev.ID()), Store: store, Ledger: s.Ledger} + drive, err := reopened.Drive(t.Context(), eng, opened, DriveOptions{}) + mustNil(t, err) + mustNil(t, drive.Close(t.Context())) + view, err = s.Dir.Cell(t.Context(), at.ID) + mustNil(t, err) + if view.Cell.Head != headOf(t, at) || store.writes == 0 { + t.Fatalf("approval left head=%s local=%s writes=%d", view.Cell.Head, headOf(t, at), store.writes) + } + restored := cell.Cell{ID: at.ID, Root: filepath.Join(t.TempDir(), at.ID)} + receiver := eng + receiver.DataRoot = t.TempDir() + synced := receiver.Sync(cellstore.SyncKeys{CellKey: id.CellKey(), Dedup: id.DedupSecret()}, "fedcba9876543210") + fetch := cellsync.Fetcher{Store: store.Store, Engine: synced, Inbox: synced.Inbox} + mustNil(t, fetch.Fetch(t.Context(), restored, view.Cell.Head)) + raw, err := os.ReadFile(transcriptOf(restored)) + mustNil(t, err) + if !bytes.Equal(raw, preserved) { + t.Fatalf("publication changed preserved history: got %s want %s", raw, preserved) + } + for _, words := range []string{"preserved one", "preserved two"} { + if !bytes.Contains(raw, []byte(words)) { + t.Fatalf("published history lacks %q: %s", words, raw) + } + } + }) + } +} + +func storageOffRequireLocalReady(t *testing.T, got Continued) { + t.Helper() + raw, err := json.Marshal(got) + mustNil(t, err) + var readiness struct{ LocalOnly bool } + mustNil(t, json.Unmarshal(raw, &readiness)) + if !readiness.LocalOnly { + t.Fatal("storage-off history waits for an uploading writer") + } +} + +// B5 does not grant another computer's expired empty-head chat to this writer. +func TestAnotherDevicesEmptyHeadStaysViewer(t *testing.T) { + r := newDriveRig(t) + r.spokenFixture() + _, err := r.b.Dir.Create(t.Context(), r.cell.ID, directory.CellInit{}) + mustNil(t, err) + r.clock.advance(directory.LeaseTTL + time.Second) + d, err := r.a.Drive(t.Context(), r.engine, r.cell, DriveOptions{}) + mustNil(t, err) + defer d.Close(t.Context()) + if d.Gate() == nil { + t.Fatal("another device's empty-head chat acquired a writer") + } +} diff --git a/internal/syncsetup/storage_off_continue_test.go b/internal/syncsetup/storage_off_continue_test.go new file mode 100644 index 0000000000..62a4da7ab0 --- /dev/null +++ b/internal/syncsetup/storage_off_continue_test.go @@ -0,0 +1,218 @@ +package syncsetup + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "reflect" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +type storageOffStore struct { + blobstore.Store + writes int + off bool +} + +func (s *storageOffStore) PutFrame(ctx context.Context, frame []byte) (blobstore.FrameID, error) { + s.writes++ + if s.off { + return "", wireauth.ErrStorageOff + } + return s.Store.PutFrame(ctx, frame) +} + +// B5 preserves the entire local root and acquires through the directory without store writes. +func TestStorageOffContinuePreservesLocalTurns(t *testing.T) { + celltest.RequireEngine(t) + for _, mode := range []string{"two turns above head", "empty recorded head", "diverged", "head moves at acquire"} { + t.Run(mode, func(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + mustNil(t, err) + dev, err := identity.Device(home) + mustNil(t, err) + dir := directory.NewMemory(time.Now) + defer dir.Close() + store := &storageOffStore{Store: blobstore.NewMemory(), off: true} + s := &Sync{Home: home, Relay: ProductionRelayURL, Identity: id, Device: dev, Dir: dir.For(dev.ID()), Store: store} + at, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + mustNil(t, err) + work := t.TempDir() + mustNil(t, session.SaveMeta(at.Root, session.Meta{ID: at.ID, Workspace: work, Created: time.Now()})) + eng := cellstore.EngineFor(work) + eng.DataRoot = t.TempDir() + t.Cleanup(cellstore.StopSpawned) + seal := func(words string) string { + appendTo(t, transcriptOf(at), `{"type":"message","role":"user","content":"`+words+`"}`+"\n") + sealed, err := eng.Seal(t.Context(), at, cellstore.TurnInfo{}) + mustNil(t, err) + return sealed.Turn.ID + } + base := seal("recorded turn") + if mode == "empty recorded head" { + base = "" + } + view, err := s.Dir.Create(t.Context(), at.ID, directory.CellInit{Head: base}) + mustNil(t, err) + mustNil(t, cellstore.NotePublished(at, base)) + seal("local one") + seal("local two") + if mode == "diverged" { + view, err = s.Dir.Publish(t.Context(), at.ID, directory.Publish{Fence: view.Cell.Lease.Fence, OldHead: base, Head: "missing-remote-turn"}) + mustNil(t, err) + } + mustNil(t, s.Dir.Release(t.Context(), at.ID, view.Cell.Lease.Fence)) + if mode == "head moves at acquire" { + s.Dir = storageOffMovingDirectory{Client: s.Dir} + } + before, err := os.ReadFile(transcriptOf(at)) + mustNil(t, err) + turns, err := cellstore.Turns(at) + mustNil(t, err) + cont := s.Continuer(eng, TakeOptions{RootFor: func(string) string { return at.Root }}) + got, err := cont.Take(t.Context(), at.ID) + refused := mode == "diverged" || mode == "head moves at acquire" + if refused { + if err == nil || err.Error() != "cannot continue this chat here while sync storage is off because this computer is missing saved turns; your local turns stay here" { + t.Fatalf("refusal: %v", err) + } + } else if err != nil { + t.Fatalf("continue: %v", err) + } + after, readErr := os.ReadFile(transcriptOf(at)) + mustNil(t, readErr) + afterTurns, readErr := cellstore.Turns(at) + mustNil(t, readErr) + if !bytes.Equal(before, after) || !reflect.DeepEqual(turns, afterTurns) || store.writes != 0 { + t.Fatalf("lost local history or wrote storage: turns=%d/%d writes=%d", len(turns), len(afterTurns), store.writes) + } + latest, err := s.Dir.Cell(t.Context(), at.ID) + mustNil(t, err) + if !refused && (got.Taken.Cell.Root != at.Root || latest.Cell.Lease.Device != dev.ID() || latest.Cell.Lease.Fence <= view.Cell.Lease.Fence || got.Taken.Driving.Fence != latest.Cell.Lease.Fence) { + t.Fatalf("directory lease not acquired: %+v", latest.Cell.Lease) + } + if refused && latest.Cell.Lease.Expires != 0 { + t.Fatal("refused continuation retained its new lease") + } + }) + } +} + +type storageOffMovingDirectory struct{ directory.Client } + +func (d storageOffMovingDirectory) Acquire(ctx context.Context, id string, opts directory.AcquireOpts) (directory.CellView, error) { + got, err := d.Client.Acquire(ctx, id, opts) + if err != nil { + return got, err + } + return d.Client.Publish(ctx, id, directory.Publish{Fence: got.Cell.Lease.Fence, OldHead: got.Cell.Head, Head: "new-remote-turn"}) +} + +// B5 keeps the storage-on branch upload and remote-head restore through the existing take path. +func TestStorageOnContinueStillKeepsAndFetches(t *testing.T) { + h := newTwoHomes(t) + a := h.openA() + a.mustSay("recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + base := headOf(t, h.cell) + appendTo(t, transcriptOf(h.cell), `{"type":"message","role":"user","content":"local extra"}`+"\n") + _, err := h.engine.Seal(t.Context(), h.cell, cellstore.TurnInfo{}) + mustNil(t, err) + counted := &storageOffStore{Store: h.a.Store} + h.a.Store = counted + got, err := h.continuerA().Take(t.Context(), h.cell.ID) + mustNil(t, err) + if got.Taken.Kept == "" || got.Taken.Driving.Head != base || counted.writes == 0 { + t.Fatalf("on path changed: kept=%s head=%s writes=%d", got.Taken.Kept, got.Taken.Driving.Head, counted.writes) + } + raw, err := os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + if bytes.Contains(raw, []byte("local extra")) { + t.Fatal("root did not restore the recorded head") + } +} + +// B3 discovers a mid-session pairing once and leaves never-paired homes silent. +func TestStorageRefreshDiscoversMidSessionPairing(t *testing.T) { + celltest.Available(t) + for _, solo := range []bool{false, true} { + for _, initial := range []bool{false, true} { + name := "missing identity/" + if solo { + name = "solo identity/" + } + if initial { + name += "engine" + } else { + name += "window" + } + t.Run(name, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/v1/store/stats" { + t.Errorf("unexpected request %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"storage":"on"}`)) + })) + defer server.Close() + t.Setenv(URLVar, server.URL) + home := t.TempDir() + if solo { + _, err := identity.EnsureSolo(home) + mustNil(t, err) + } + p := storageProcessFor(home) + timers := make(chan func(), 8) + p.after = func(_ time.Duration, fn func()) func() bool { timers <- fn; return func() bool { return true } } + stop := StartStorageRefresh(t.Context(), home, initial) + defer stop() + next := func() func() { + select { + case fn := <-timers: + return fn + case <-time.After(3 * time.Second): + t.Fatal("pairing observation stopped") + return nil + } + } + first := next() + if requests.Load() != 0 { + t.Fatal("never-paired home made a request") + } + first() + second := next() + if requests.Load() != 0 { + t.Fatal("never-paired home made a request on its next check") + } + id, err := identity.Ensure(home) + mustNil(t, err) + if solo { + mustNil(t, identity.EndSolo(home)) + } + second() + p.check(true) + again := StartStorageRefresh(t.Context(), home, false) + defer again() + if requests.Load() != 1 || StorageState(home, server.URL, id.ID()) != "on" { + t.Fatalf("pairing requests=%d state=%s", requests.Load(), StorageState(home, server.URL, id.ID())) + } + }) + } + } +} diff --git a/internal/syncsetup/storage_refresh.go b/internal/syncsetup/storage_refresh.go index 4b43658722..a4a6c43082 100644 --- a/internal/syncsetup/storage_refresh.go +++ b/internal/syncsetup/storage_refresh.go @@ -37,6 +37,7 @@ type storageProcess struct { inflight chan struct{} lastRefresh time.Time active bool + watchSetup bool dirty bool refused *Sync counts blobstore.Counts @@ -93,6 +94,7 @@ func StartStorageRefresh(ctx context.Context, home string, initial ...bool) func p := storageProcessFor(home) p.mu.Lock() p.users++ + p.watchSetup = true first := p.users == 1 if first && (p.ctx == nil || p.ctx.Err() != nil || p.cancel == nil) { p.ctx, p.cancel = context.WithCancel(context.Background()) @@ -284,12 +286,14 @@ func (p *storageProcess) check(refresh bool) { } } // A removed device cannot regain access by polling for storage approval. - if !removed && relay != "" && state == "off" && (p.users > 0 || len(p.waiting) > 0) { + // Before pairing, sample local setup only; the first eligible sample asks once. + watchingPairing := p.watchSetup && p.users > 0 && relay == "" && Resolve(p.home).On() + if !removed && (watchingPairing || relay != "" && state == "off") && (p.users > 0 || len(p.waiting) > 0) { wait := StorageRefreshEvery if p.users > 0 && !p.lastRefresh.IsZero() { wait = max(time.Millisecond, StorageRefreshEvery-p.now().Sub(p.lastRefresh)) } - if len(p.waiting) > 0 { + if watchingPairing || len(p.waiting) > 0 { wait = min(wait, StoragePollEvery) } p.stop = p.after(wait, func() { diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 905f4c2eb1..383e9881e0 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -26,6 +26,7 @@ import ( "github.com/Agent-Field/codeaf/internal/keys" "github.com/Agent-Field/codeaf/internal/preflight" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" "github.com/Agent-Field/codeaf/internal/taskcopy" "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/vaultsync" @@ -59,6 +60,8 @@ type Continued struct { Taken handoff.Taken // AlreadyHere keeps an active local session attached when no move was needed. AlreadyHere bool + // LocalOnly permits immediate local work without waiting for an uploading writer. + LocalOnly bool // KeptTurns is how many turns the kept branch holds, 0 when nothing was kept. KeptTurns uint32 // Device is this machine's name. @@ -214,7 +217,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if err := c.sync.recordSendLease(opened.ID, sendLease{Cell: id, Fence: lease.Fence}); err != nil { return Continued{}, err } - return Continued{AlreadyHere: true, Device: c.opt.DeviceName, Taken: handoff.Taken{ + return Continued{AlreadyHere: true, LocalOnly: !c.sync.StorageAllowed(), Device: c.opt.DeviceName, Taken: handoff.Taken{ Cell: opened, Driving: cellsync.Driving{Cell: opened, Fence: lease.Fence, Head: view.Cell.Head, Frames: view.Cell.Frames}, }}, nil } @@ -233,6 +236,27 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx retired := handoff.Trace(c.opt.OnPhase, "retirement") c.retireHolder(ctx, id) retired() + storageOff := !c.sync.StorageAllowed() + if storageOff { + local := cell.Cell{ID: id, Root: root} + if _, err := os.Stat(root); err == nil { + head, _, err := cellstore.Unpublished(local, view.Cell.Head) + if err != nil { + return Continued{}, err + } + dirty, err := c.eng.Dirty(ctx, local) + if err != nil { + return Continued{}, err + } + // Only work that keepLocal would preserve needs the in-place road; + // an absent or clean older copy can read the recorded history normally. + if head != "" || dirty { + return c.takeLocalStorageOff(ctx, id, root, seen) + } + } else if !errors.Is(err, os.ErrNotExist) { + return Continued{}, err + } + } sc := c.sync.scope(id) defer func() { defer handoff.Trace(c.opt.OnPhase, "filing")() @@ -353,7 +377,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx } } } - out = Continued{Taken: taken, Device: c.opt.DeviceName, TaskCopies: taskCopies, Resume: c.arrive(taken.Cell, from)} + out = Continued{LocalOnly: storageOff, Taken: taken, Device: c.opt.DeviceName, TaskCopies: taskCopies, Resume: c.arrive(taken.Cell, from)} if taken.Kept != "" { out.KeptTurns = c.orphanTurns(ctx, taken.Kept) } @@ -871,3 +895,54 @@ func (c *Continuer) ReleaseUnused(ctx context.Context, id string, fence uint64) } } } + +// takeLocalStorageOff acquires through the directory without uploading or restoring over local turns. +func (c *Continuer) takeLocalStorageOff(ctx context.Context, id, root string, seen *directory.LeaseExpectation) (Continued, error) { + at, err := cell.OpenAt(root, id) + if err != nil { + return Continued{}, syncwords.ErrContinueStorageOff + } + contains := func(head string) error { + if _, _, err := cellstore.Unpublished(at, head); err != nil { + return err + } + // An empty record has no remote turns to require. Unpublished conservatively + // retains divergent work too, so only ancestry can permit an in-place claim. + if head == "" { + return nil + } + extends, err := cellstore.Extends(at, head) + if err != nil { + return err + } + if !extends { + return syncwords.ErrContinueStorageOff + } + return nil + } + view, err := c.sync.Dir.Cell(ctx, id) + if err != nil { + return Continued{}, err + } + if view.Cell.Lease.Device != seen.Device || view.Cell.Lease.Fence != seen.Fence { + return Continued{}, directory.ErrLeaseHeld + } + if err := contains(view.Cell.Head); err != nil { + return Continued{}, err + } + got, err := c.sync.Dir.Acquire(ctx, id, directory.AcquireOpts{RequireConditional: true, Force: view.Cell.Lease.Expires > view.Now, Expect: seen}) + if err != nil { + return Continued{}, err + } + fail := func(err error) (Continued, error) { + return Continued{}, errors.Join(err, c.sync.Dir.Release(ctx, id, got.Cell.Lease.Fence)) + } + // Publishing can race the acquire; validate its actual head before recording ownership. + if err := contains(got.Cell.Head); err != nil { + return fail(err) + } + if err := c.sync.recordSendLease(id, sendLease{Cell: id, Fence: got.Cell.Lease.Fence}); err != nil { + return fail(err) + } + return Continued{LocalOnly: true, Device: c.opt.DeviceName, Taken: handoff.Taken{Cell: at, Driving: cellsync.Driving{Cell: at, Fence: got.Cell.Lease.Fence, Head: got.Cell.Head, Frames: got.Cell.Frames}}}, nil +} diff --git a/internal/syncwords/errors.go b/internal/syncwords/errors.go index b7eb49cb7b..477136ad8a 100644 --- a/internal/syncwords/errors.go +++ b/internal/syncwords/errors.go @@ -3,6 +3,7 @@ package syncwords import ( "context" "errors" + "fmt" "net" "net/url" "strings" @@ -114,3 +115,19 @@ func IsStruggling(err error) bool { var response interface{ HTTPStatus() int } return errors.Is(err, wireauth.ErrUnavailable) || errors.Is(err, clusterctl.ErrUnavailable) || errors.Is(err, relay.ErrStruggling) || errors.As(err, &response) && response.HTTPStatus() >= 500 } + +// SavedHereWords needs positive local durability before describing a pending upload. +func SavedHereWords(err error, address string) string { + if !UploadPending(err) { + return "" + } + if SavedWithStorageOff(err) { + return SavedStorageOff + } + return fmt.Sprintf(SavedHerePending, Host(address)) +} + +// SavedWithStorageOff requires a positive local receipt so a mixed local failure stays a failure. +func SavedWithStorageOff(err error) bool { + return UploadPending(err) && errors.Is(err, wireauth.ErrStorageOff) +} diff --git a/internal/syncwords/words.go b/internal/syncwords/words.go index 4a17fc4553..cc861362f5 100644 --- a/internal/syncwords/words.go +++ b/internal/syncwords/words.go @@ -2,6 +2,7 @@ package syncwords import ( + "errors" "fmt" "net/url" "strings" @@ -96,3 +97,12 @@ func Asking(address string) string { return "asking the sync service at " + Host func IsOutage(text string) bool { return text == UnknownOutage || text == LegacyOutage || strings.HasPrefix(text, outagePrefix) && strings.HasSuffix(text, outageSuffix) } + +// SavedHerePending names the same pending upload at app quit and individual close. +const SavedHerePending = "saved here; it reaches your other devices when the sync service at %s answers" + +// SavedStorageOff is shared by app quit and the close of an individual chat. +const SavedStorageOff = "saved here; sync storage is off for this account, so it stays on this computer" + +// ErrContinueStorageOff preserves local turns when the recorded history is missing here. +var ErrContinueStorageOff = errors.New("cannot continue this chat here while sync storage is off because this computer is missing saved turns; your local turns stay here") diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 282a6d192a..e0cafb3f84 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2156,7 +2156,8 @@ type app struct { link LinkSeam // seal is what the door can tell this surface about the sealing of the // conversation's calls (sealseam.go). Its zero value draws nothing. - seal SealSeam + seal SealSeam + stopSealNotice func() // newsSilenceSaid is whether this window has already said that its engine // sends no status-line news (hostlink.go's [app.sayNewsSilence]). It is said // once per window, because it is a fact about a machine and not about a turn. @@ -2397,7 +2398,8 @@ type app struct { // sheet is the settings panel (settings.go): the FIRST fullscreen thing this // surface drew, and the only overlay that is modal for the pointer as well // as for the keyboard. Closed, it costs the frame nothing. - sheet sheet + sheet sheet + storageRead storageSampling // deck is the phone tier's status sheet (statusdeck.go): the SECOND // fullscreen thing, and the only one that exists at one size class only — // under sixty columns the status row is a two-row deck, and this is where @@ -3491,6 +3493,7 @@ func newApp(ctx context.Context, opts Options) *app { opts.Notices = NewNotices() } a.useNotices(opts.Notices) + a.useSeal(opts.Seal) return a } @@ -3871,12 +3874,8 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case conversationClosedMsg: a.conversationClosing = false - if errors.Is(msg.err, session.ErrSavingUnanswered) { - a.note(closeStillFinishingWord) - } else if msg.err != nil { - a.note(closeSaveFailedWord) - } else if msg.finishing { - a.note(closeStillFinishingWord) + if line := closeSaveWords(msg.err, msg.finishing, msg.address); line != "" { + a.note(line) } a.touch() return a, nil @@ -5946,6 +5945,20 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { a.toldNote(msg.line) return a, nil + case storageAccountMsg: + if msg.serial <= a.storageRead.applied || msg.serial != a.storageRead.pending { + return a, nil + } + a.storageRead.pending = 0 + a.storageRead.applied = msg.serial + a.storageRead.code, a.storageRead.state = msg.code, msg.state + if a.at(pageSettings) && (a.sheet.storageCode != msg.code || a.sheet.storageState != msg.state) { + a.sheet.storageCode, a.sheet.storageState = msg.code, msg.state + a.sheet.build() + a.touch() + } + return a, nil + case syncSpaceMsg: // The reading is the identity's, not one chat's, so it is kept whichever // chat is on screen. It is only ever read by the next /status. @@ -5953,11 +5966,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { msg.counts.Storage = a.syncSpace.Storage } a.syncSpace = msg.counts - if a.at(pageSettings) { - a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() - a.sheet.build() - } - return a, nil + return a, a.sampleStorageAccount(true) case spelledMsg: // The expansion, or nothing at all — a failure, a timeout and a model that @@ -8878,7 +8887,7 @@ func (a *app) takeUp(conv Conversation, whole bool) { a.history = conv.History // AND THE SEALS ARE THIS CONVERSATION'S OWN, cleared by a zero for the same // reason: one chat's failed seal is not another's (sealseam.go). - a.seal = conv.Seal + a.useSeal(conv.Seal) a.saveApproval = conv.SaveApproval a.saveBashApproval = conv.SaveBashApproval a.applyApprovals = conv.ApplyApprovals diff --git a/internal/tui3/conversation_close.go b/internal/tui3/conversation_close.go index 93aeab0dd6..e71c2cf5aa 100644 --- a/internal/tui3/conversation_close.go +++ b/internal/tui3/conversation_close.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // conversationCloseWithin bounds how long the surface waits for engine cleanup. @@ -18,6 +19,7 @@ const closeStillFinishingWord = "this chat is still finishing in the background" type conversationClosedMsg struct { err error finishing bool + address string } // quitAfterClosing restores the terminal even when cleanup is still running. @@ -40,7 +42,7 @@ func quitAfterClosing(work func(), started func(<-chan struct{})) tea.Cmd { // Closing releases the keyboard after a bounded wait. The close method has no // cancellation door, so it can finish later without holding the update loop. -func closeConversationLater(agent Agent, door session.StopDoor, report func(error), started func(<-chan struct{}), lateFailure func()) tea.Cmd { +func closeConversationLater(agent Agent, door session.StopDoor, report func(error), started func(<-chan struct{}), lateFailure func(error), address string) tea.Cmd { done, finished := make(chan error, 1), make(chan struct{}) var mu sync.Mutex late := false @@ -58,7 +60,7 @@ func closeConversationLater(agent Agent, door session.StopDoor, report func(erro notify := late && err != nil && !errors.Is(err, session.ErrSavingUnanswered) && lateFailure != nil mu.Unlock() if notify { - lateFailure() + lateFailure(err) } }() return func() tea.Msg { @@ -66,17 +68,32 @@ func closeConversationLater(agent Agent, door session.StopDoor, report func(erro defer timer.Stop() select { case err := <-done: - return conversationClosedMsg{err: err} + return conversationClosedMsg{err: err, address: address} case <-timer.C: mu.Lock() defer mu.Unlock() select { case err := <-done: - return conversationClosedMsg{err: err} + return conversationClosedMsg{err: err, address: address} default: late = true } - return conversationClosedMsg{finishing: true} + return conversationClosedMsg{finishing: true, address: address} } } } + +// closeSaveWords uses the same local receipt as app quit before claiming turns are saved here. +func closeSaveWords(err error, finishing bool, address string) string { + if line := syncwords.SavedHereWords(err, address); line != "" { + return line + } + switch { + case errors.Is(err, session.ErrSavingUnanswered), err == nil && finishing: + return closeStillFinishingWord + case err != nil: + return closeSaveFailedWord + default: + return "" + } +} diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 94ac86042f..0defce2150 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -46,9 +46,11 @@ type Taken struct { Head string // AlreadyHere preserves the attached session when this device already holds it. AlreadyHere bool - Kept string - KeptTurns uint32 - Device string + // LocalOnly allows local sending while account storage is off. + LocalOnly bool + Kept string + KeptTurns uint32 + Device string // TaskCopies names the tasks whose working copies came along with the chat. TaskCopies []string // Resume is what the chat left behind on the other machine that this one @@ -872,7 +874,7 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { if previous.failed || (previous.fence != 0 && previous.fence != msg.taken.Fence) { msg.taken.AlreadyHere = false } - a.machineRead.sendHolds[msg.row.Cell] = sendHold{fence: msg.taken.Fence, awaitingWriter: !msg.taken.AlreadyHere} + a.machineRead.sendHolds[msg.row.Cell] = sendHold{fence: msg.taken.Fence, awaitingWriter: !msg.taken.AlreadyHere && !msg.taken.LocalOnly} rows := append([]chatlist.Row(nil), a.machineRead.rows...) for i := range rows { if rows[i].Cell == msg.row.Cell { diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 364491e286..16ffe56954 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -16,6 +16,7 @@ import ( "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncwords" ) // ── A WINDOW ON A CHAT ANOTHER MACHINE NOW HOLDS ──────────────────────────── @@ -421,6 +422,8 @@ func (a *app) failedTake(msg homeTakenMsg) string { // safety reading has not started a take and must not cancel one already running. func (a *app) continueFailure(msg homeTakenMsg) string { switch { + case errors.Is(msg.err, syncwords.ErrContinueStorageOff): + return syncwords.ErrContinueStorageOff.Error() case errors.Is(msg.err, directory.ErrLeaseHeld): return lostRaceWord case errors.Is(msg.err, directory.ErrUnreachable): diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index a6d372b04c..2b0aef99f1 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -1272,6 +1272,7 @@ func (a *app) closeFront() (tea.Cmd, bool) { // arrived, and "you closed this conversation" and "another window took it" are // two different sentences to be owed (internal/session's stopcause.go). func (a *app) closeFrontFor(door session.StopDoor) (tea.Cmd, bool) { + address := a.syncService name := a.sessionName() if name == "" { name = a.place @@ -1284,9 +1285,9 @@ func (a *app) closeFrontFor(door session.StopDoor) (tea.Cmd, bool) { a.conversationClosing = true a.retainSavingDurability([]Agent{leaving}) desk := a.outsideDesk - return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure, a.closingStarted, func() { - desk.Say(name + ": " + closeSaveFailedWord) - })), true + return tea.Batch(cmd, closeConversationLater(leaving, door, a.saveFailure, a.closingStarted, func(err error) { + desk.Say(name + ": " + closeSaveWords(err, false, address)) + }, address)), true } // stepBackFront is the same act for a window that is NOT ending anything: another @@ -1397,6 +1398,10 @@ func (a *app) leaveEverything() { // leavingWork captures surface state before any engine work leaves the loop. // The returned work owns only the captured agents and thread-safe callbacks. func (a *app) leavingWork() func() { + if a.stopSealNotice != nil { + a.stopSealNotice() + a.stopSealNotice = nil + } agents := make([]Agent, 0, len(a.behind)+1) if a.agent != nil { agents = append(agents, a.agent) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index b568f7b318..3c4fceda69 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -36,19 +36,49 @@ func (placeSettings) word() string { return "settings" } func (placeSettings) open(a *app) tea.Cmd { a.raiseSettings() - // THE CLOCK IS ARMED FOR THE BAR AND NOT FOR THIS PANEL. Nothing here is - // read from disk on a beat — the rows are this machine's settings and they - // change when somebody changes them — but the tab bar's numbers are - // recomputed on that beat, and a room that armed no clock stopped the whole - // bar counting while it was up ([placeSettings.tick]). - // + // The place clock reads the saved account code and storage permission only + // on Devices or in Settings search, so pairing updates the visible row + // without recurring account discovery on unrelated tabs. The same beat + // keeps the navigation bar's counts current; other settings rows change + // only when their settings change. + // OVER --host THE TEAMS TAB READS THE FAR MACHINE, once, off the loop. A // local launch asks for nothing. return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults(), a.ensureSheetDevices()) } -// tick re-reads nothing and keeps the beat: see the note over [placeSettings.open]. -func (placeSettings) tick(a *app, now time.Time) (bool, tea.Cmd) { return true, nil } +// tick refreshes only the account reading this page currently displays. +func (placeSettings) tick(a *app, now time.Time) (bool, tea.Cmd) { + return true, a.sampleStorageAccount(true) +} + +type storageAccountMsg struct { + code, state string + serial uint64 +} + +// storageSampling survives page changes so a pending read cannot overlap a new visit. +type storageSampling struct { + next, pending, applied uint64 + code, state string +} + +func (a *app) sampleStorageAccount(refresh bool) tea.Cmd { + door, ok := a.approvals.(interface{ RelayStorage() (string, string) }) + if !ok || !a.at(pageSettings) || a.cellsUnavailable != "" || + (!a.sheet.onDevices() && !a.sheet.searching()) || a.storageRead.pending != 0 || + (!refresh && a.sheet.storageSampled) { + return nil + } + a.sheet.storageSampled = true + a.storageRead.next++ + serial := a.storageRead.next + a.storageRead.pending = serial + return func() tea.Msg { + code, state := door.RelayStorage() + return storageAccountMsg{code: code, state: state, serial: serial} + } +} func (placeSettings) close(a *app) { a.dropSettings() } diff --git a/internal/tui3/sealseam.go b/internal/tui3/sealseam.go index 7d08609744..ff5c628b89 100644 --- a/internal/tui3/sealseam.go +++ b/internal/tui3/sealseam.go @@ -26,6 +26,20 @@ type SealSeam struct { // Notice hands over the next sentence worth saying, and "" when there is // none. It drains, so [app.takeSealNotice] is its only caller. Notice func() string + // Listen binds the existing notice bell; only Notice consumes the queue. + Listen func(func()) func() +} + +// useSeal detaches the previous window listener when the front conversation changes. +func (a *app) useSeal(seam SealSeam) { + if a.stopSealNotice != nil { + a.stopSealNotice() + a.stopSealNotice = nil + } + a.seal = seam + if seam.Listen != nil { + a.stopSealNotice = seam.Listen(a.noticeBell.ring) + } } // sealSegment is the status-line segment: a mark and two words while sealing @@ -44,7 +58,7 @@ func (a *app) takeSealNotice() { if a.seal.Notice == nil { return } - if said := a.seal.Notice(); said != "" { + for said := a.seal.Notice(); said != ""; said = a.seal.Notice() { a.note(said) } } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 79313fe171..90513a04ae 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1013,6 +1013,7 @@ type sheet struct { cellsUnavailable string storageCode string storageState string + storageSampled bool storageRequest bool approvals Approvals dev *deviceCard @@ -1339,7 +1340,7 @@ func (a *app) raiseSettings() { routing: a.routing, today: a.todayReading(), } - a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() + a.sheet.storageCode, a.sheet.storageState = a.storageRead.code, a.storageRead.state a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() // WHOSE ROWS, SAID FOR THE TAB ON SHOW. Over --host the note is @@ -2904,7 +2905,7 @@ func (a *app) applySetting(item sheetItem, raw string) bool { } else { a.sheet.msg = "" } - a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() + a.sheet.storageCode, a.sheet.storageState = a.storageRead.code, a.storageRead.state a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() }) @@ -2914,7 +2915,7 @@ func (a *app) applySetting(item sheetItem, raw string) bool { } a.sheet.msg = note a.sheet.savedKey = item.row.Key - a.sheet.storageCode, a.sheet.storageState = a.relayStorageAccount() + a.sheet.storageCode, a.sheet.storageState = a.storageRead.code, a.storageRead.state a.sheet.rows = a.sheet.registry.Rows() a.sheet.build() return true @@ -4078,6 +4079,9 @@ func (s *sheet) keysLine() string { case item.memoryDoor, item.crewDoor: action = "open" case item.dev != nil && item.dev.storage: + if s.storageState != "off" { + return "↑↓ results · esc clear search" + } action = "request access" case item.dev != nil && item.dev.add: action = "add" diff --git a/internal/tui3/settings_devices.go b/internal/tui3/settings_devices.go index 195315c025..a8eecb6c02 100644 --- a/internal/tui3/settings_devices.go +++ b/internal/tui3/settings_devices.go @@ -146,7 +146,8 @@ func (s *sheet) syncDeviceCursor() { // ensureSheetDevices starts the initial directory read and joins the change // watch the first time a visit lands on the Devices tab. Other categories never // pay a network ask they did not look at. -func (a *app) ensureSheetDevices() tea.Cmd { +func (a *app) ensureSheetDevices() (cmd tea.Cmd) { + defer func() { cmd = tea.Batch(cmd, a.sampleStorageAccount(false)) }() if !a.at(pageSettings) || !a.sheet.onDevices() { return nil } diff --git a/internal/tui3/storage_off_settings_test.go b/internal/tui3/storage_off_settings_test.go new file mode 100644 index 0000000000..20e26522d4 --- /dev/null +++ b/internal/tui3/storage_off_settings_test.go @@ -0,0 +1,297 @@ +package tui3 + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +type storageOffTaker struct { + continuer *syncsetup.Continuer + on func(syncsetup.Continued) +} + +func (f storageOffTaker) Take(ctx context.Context, id string) (Taken, error) { + got, err := f.continuer.Take(ctx, id) + if err == nil && f.on != nil { + f.on(got) + } + // Carry outcome flags without requiring new declarations in the before-fix tree. + raw, _ := json.Marshal(got) + var taken Taken + _ = json.Unmarshal(raw, &taken) + taken.Fence, taken.Head = got.Taken.Driving.Fence, got.Taken.Driving.Head + taken.Transcript = filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath) + return taken, err +} + +type storageOffReadOnlyStore struct { + blobstore.Store + writes int +} + +func (s *storageOffReadOnlyStore) PutFrame(context.Context, []byte) (blobstore.FrameID, error) { + s.writes++ + return "", errors.New("unexpected store write") +} + +// B5 uses the real continuation card, reopened agent and Enter road to save the next turn. +func TestContinueCardSendsAndSavesWhileStorageOff(t *testing.T) { + celltest.RequireEngine(t) + lab := newHomeLab(t) + work := lab.workspace("project") + at, err := cell.CreateIn(lab.project("project"), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + file := lab.session("project", at.ID, "Local chat", work, time.Now()) + canonical := session.Place{Dir: at.Root}.Transcript() + if err := os.Rename(file, canonical); err != nil { + t.Fatal(err) + } + file = canonical + appendFile := func(words string) { + f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(`{"type":"message","role":"user","content":"` + words + `"}` + "\n") + if err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + } + eng := cellstore.EngineFor(work) + eng.DataRoot = t.TempDir() + t.Cleanup(cellstore.StopSpawned) + for _, words := range []string{"preserved one", "preserved two"} { + appendFile(words) + if _, err := eng.Seal(t.Context(), at, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + } + if err := cellstore.NotePublished(at, ""); err != nil { + t.Fatal(err) + } + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + dev, err := identity.Device(home) + if err != nil { + t.Fatal(err) + } + dir := directory.NewMemory(time.Now) + defer dir.Close() + view, err := dir.For("other-computer").Create(t.Context(), at.ID, directory.CellInit{}) + if err != nil { + t.Fatal(err) + } + store := &storageOffReadOnlyStore{Store: blobstore.NewMemory()} + s := &syncsetup.Sync{Home: home, Relay: syncsetup.ProductionRelayURL, Identity: id, Device: dev, Dir: dir.For(dev.ID()), Store: store} + a := lab.app(file) + t.Cleanup(a.leaveEverything) + a.width, a.height = 200, 70 + a.open = func(workspace, transcript string) (Conversation, error) { + seat, err := cellstore.SeatOver(executor.HostBound, at, workspace, nil, nil, func(cellstore.Engine) cellstore.Store { return eng }) + if err != nil { + return Conversation{}, err + } + agent, err := session.New(session.Config{Workspace: workspace, Place: session.Place{Dir: at.Root, Workspace: workspace}, SessionFile: transcript, Seat: seat, Model: "stub/chat", APIKey: "stub", BaseURL: "http://127.0.0.1:1"}) + return Conversation{Agent: agent, Workspace: workspace, SessionFile: transcript, Resumed: true}, err + } + drain(t, a, a.openHome()) + src := &continuationListing{row: chatlist.Row{Cell: at.ID, Title: "Local chat", Device: "other-computer", DeviceID: "other-computer", Fence: view.Cell.Lease.Fence, Status: chatlist.Running}} + a.machines = src + a.taker = storageOffTaker{ + continuer: s.Continuer(eng, syncsetup.TakeOptions{RootFor: func(string) string { return at.Root }}), + on: func(got syncsetup.Continued) { + src.change(func(row *chatlist.Row) { + row.Mine, row.Status, row.DeviceID, row.Fence = true, chatlist.Here, dev.ID(), got.Taken.Driving.Fence + }) + }, + } + // A previous displaced writer must not make the local continuation wait again. + a.machineRead.sendHolds = map[string]sendHold{at.ID: {fence: view.Cell.Lease.Fence, failed: true}} + drain(t, a, a.askMachines()) + standOn(t, a, at.ID) + confirm(t, a) + if a.at(pageHome) || a.file != file { + t.Fatalf("continuation did not reopen the conversation: %s", plain(frame(a))) + } + if a.machineRead.sendHolds[at.ID].awaitingWriter { + t.Fatal("local continuation waits for an uploading writer") + } + if strings.Contains(plain(frame(a)), "continue here") || strings.Contains(plain(frame(a)), "held on other-computer") { + t.Fatal("continuation card remained") + } + typeLine(t, a, "! printf next-local-turn") + raw, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + for _, words := range []string{"preserved one", "preserved two", "next-local-turn"} { + if !strings.Contains(string(raw), words) { + t.Fatalf("saved conversation lacks %q: %s", words, raw) + } + } + turns, err := cellstore.Turns(at) + if err != nil || len(turns) < 3 || store.writes != 0 { + t.Fatalf("turns=%d writes=%d err=%v", len(turns), store.writes, err) + } +} + +type storageOffSampleDoor struct { + *storageApprovals + reads atomic.Int32 + entered chan struct{} + release chan struct{} +} + +func (d *storageOffSampleDoor) RelayStorage() (string, string) { + d.reads.Add(1) + if d.entered != nil { + d.entered <- struct{}{} + <-d.release + } + return d.storageApprovals.RelayStorage() +} + +// Item 7 bounds account reads to visible consumers, with one in flight and ordered answers. +func TestSettingsSamplesOnlyVisibleStorage(t *testing.T) { + door := &storageOffSampleDoor{storageApprovals: &storageApprovals{fakeApprovals: devicesFake(), code: "id_account", state: "off"}} + a := newTestApp(&fakeAgent{model: "m"}) + a.approvals = door + a.openSettings() + for i := 0; i < 4; i++ { + _, cmd := (placeSettings{}).tick(a, time.Now()) + if cmd != nil { + a.Update(cmd()) + } + } + if got := door.reads.Load(); got != 0 { + t.Fatalf("unrelated Settings tab read the account %d times", got) + } + for i, tab := range settingTabs { + if tab == tabDevices { + a.sheet.tab = i + } + } + door.entered, door.release = make(chan struct{}, 1), make(chan struct{}) + _, cmd := (placeSettings{}).tick(a, time.Now()) + if cmd == nil { + t.Fatal("Devices did not read storage") + } + answer := make(chan any, 1) + go func() { answer <- cmd() }() + <-door.entered + for i := 0; i < 4; i++ { + if _, cmd := (placeSettings{}).tick(a, time.Now()); cmd != nil { + t.Fatal("overlapping account read") + } + } + close(door.release) + old := <-answer + a.Update(old) + door.entered, door.release = nil, nil + door.state = "on" + _, cmd = (placeSettings{}).tick(a, time.Now()) + if cmd == nil { + t.Fatal("next beat did not refresh") + } + a.Update(cmd()) + a.Update(old) + if a.sheet.storageState != "on" || door.reads.Load() != 2 { + t.Fatalf("state=%s reads=%d", a.sheet.storageState, door.reads.Load()) + } + a.sheet.tab = 0 + a.sheet.query.insert("sync storage") + _, cmd = (placeSettings{}).tick(a, time.Now()) + if cmd == nil { + t.Fatal("Settings search did not refresh") + } + a.Update(cmd()) +} + +// Item 8 crosses a real remote client with an older saving reply that omits StorageOff. +func TestCloseOlderRemoteSavingReceipt(t *testing.T) { + for _, receipt := range []bool{true, false} { + t.Run(map[bool]string{true: "positive local receipt", false: "no durability receipt"}[receipt], func(t *testing.T) { + front, back := remote.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer back.Close() + dec, enc := json.NewDecoder(back), json.NewEncoder(back) + var hello remote.Frame + if dec.Decode(&hello) != nil { + return + } + welcome, _ := json.Marshal(remote.Welcome{Version: remote.Version, LocalSavingReceipts: receipt, SessionFile: "/chat/transcript.jsonl"}) + if enc.Encode(remote.Frame{Kind: "welcome", Payload: welcome}) != nil { + return + } + for { + var call remote.Frame + if dec.Decode(&call) != nil { + return + } + if call.Method != remote.MethodClose { + if enc.Encode(remote.Frame{Kind: "result", ID: call.ID, Payload: json.RawMessage("null")}) != nil { + return + } + continue + } + // This map deliberately has no storageOff key, as on the compatible peer. + _ = enc.Encode(map[string]any{"kind": "result", "id": call.ID, "error": "upload pending", "localSaved": receipt, "syncPending": receipt}) + return + } + }() + client, err := remote.Dial(front, "older-peer", remote.Hello{}) + if err != nil { + t.Fatal(err) + } + defer func() { front.Close(); <-done }() + a := newTestApp(client.Agent()) + t.Cleanup(a.leaveEverything) + a.file = "/chat/transcript.jsonl" + a.syncService = "http://127.0.0.1:8901" + emptyMachine(a) + a.stow(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/remaining/transcript.jsonl"}, &aside{}) + cmd, more := a.closeFront() + if !more { + t.Fatal("remaining chat missing") + } + spend(t, a, cmd) + shown := strings.Join(strings.Fields(plain(frame(a))), " ") + want := closeSaveFailedWord + if receipt { + want = "saved here; it reaches your other devices when the sync service at 127.0.0.1:8901 answers" + } + if !strings.Contains(shown, want) { + t.Fatalf("receipt=%v frame=%s", receipt, shown) + } + }) + } +} diff --git a/internal/tui3/storage_off_window_test.go b/internal/tui3/storage_off_window_test.go new file mode 100644 index 0000000000..2944afd3ea --- /dev/null +++ b/internal/tui3/storage_off_window_test.go @@ -0,0 +1,157 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/syncwords" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// B1 requires the first status frame after pairing to use the cached permission. +func TestFirstStatusAfterPairing(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + door := &storageApprovals{fakeApprovals: devicesFake(), state: "off"} + a.approvals = door + requests := 0 + a.syncStats = func(context.Context) (blobstore.Counts, error) { requests++; return blobstore.Counts{}, nil } + door.code = "id_account" + cmd := a.runStatusCommand("") + shown := plain(frame(a)) + if !strings.Contains(strings.Join(strings.Fields(shown), " "), "sync storage off") || requests != 0 || cmd == nil { + t.Fatalf("first status frame, requests=%d: %s", requests, shown) + } +} + +// B2 offers the request action in search only while storage is off. +func TestStorageSearchActionMatchesPermission(t *testing.T) { + for _, state := range []string{"off", "on"} { + t.Run(state, func(t *testing.T) { + door := &storageApprovals{fakeApprovals: devicesFake(), code: "id_account", state: state} + a, r := openSettingsDevices(t, door, time.Now()) + r.press("ctrl+u") + r.typeText("sync storage") + found := false + for i, item := range a.sheet.items { + if item.dev != nil && item.dev.storage { + a.sheet.cursor = i + found = true + break + } + } + if !found { + t.Fatal("storage search result missing") + } + shown := plain(frame(a)) + if strings.Contains(shown, "enter request access") != (state == "off") { + t.Fatalf("search footer: %s", shown) + } + if state == "on" { + old := processOpener + processOpener = func(string) error { t.Error("approved row opened a request"); return nil } + defer func() { processOpener = old }() + r.press("enter") + if a.sheet.storageRequest { + t.Fatal("approved row requested storage") + } + } + }) + } +} + +type storageOffCloseAgent struct { + *fakeAgent + err error +} + +func (a *storageOffCloseAgent) Close() error { return a.err } + +// B6 distinguishes a durable local save from a failed save when another chat remains open. +func TestCloseStorageOffReceipt(t *testing.T) { + for _, tc := range []struct { + name string + err error + want string + }{ + {"storage off", syncwords.UploadFailure(wireauth.ErrStorageOff), "saved here; sync storage is off for this account, so it stays on this computer"}, + {"local failure", errors.New("disk full"), closeSaveFailedWord}, + {"mixed failure", errors.Join(errors.New("disk full"), syncwords.UploadFailure(wireauth.ErrStorageOff)), closeSaveFailedWord}, + } { + t.Run(tc.name, func(t *testing.T) { + a := newTestApp(&storageOffCloseAgent{fakeAgent: &fakeAgent{model: "m"}, err: tc.err}) + t.Cleanup(a.leaveEverything) + a.file = "/tmp/closing/transcript.jsonl" + emptyMachine(a) + a.stow(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/remaining/transcript.jsonl"}, &aside{}) + if a.openCount() != 2 { + t.Fatal("fixture must have exactly two chats") + } + cmd, more := a.closeFront() + if !more { + t.Fatal("no remaining chat") + } + spend(t, a, cmd) + shown := strings.Join(strings.Fields(plain(frame(a))), " ") + if !strings.Contains(shown, tc.want) || tc.name == "storage off" && strings.Contains(shown, closeSaveFailedWord) { + t.Fatalf("close frame: %s", shown) + } + }) + } +} + +// B3 keeps an already-open Settings frame current after the background answer arrives. +func TestOpenSettingsNoticesPairingAnswer(t *testing.T) { + door := &storageApprovals{fakeApprovals: devicesFake()} + a, _ := openSettingsDevices(t, door, time.Now()) + if strings.Contains(plain(frame(a)), "sync storage") { + t.Fatal("unpaired row shown") + } + door.code, door.state = "id_account", "off" + _, cmd := (placeSettings{}).tick(a, time.Now()) + if cmd == nil { + t.Fatal("Settings never sampled the account answer") + } + a.Update(cmd()) + shown := plain(frame(a)) + if !strings.Contains(shown, "sync storage") || a.sheet.storageState != "off" { + t.Fatalf("paired row missing: %s", shown) + } +} + +type storageOffLateCloseAgent struct{ *blockedCloseAgent } + +func (a *storageOffLateCloseAgent) Close() error { + _ = a.blockedCloseAgent.Close() + return syncwords.UploadFailure(wireauth.ErrStorageOff) +} + +// B6 reports the same saved-here receipt when an individual close finishes after the wait. +func TestLateCloseStorageOffReceipt(t *testing.T) { + agent := &storageOffLateCloseAgent{&blockedCloseAgent{fakeAgent: &fakeAgent{model: "m"}, release: make(chan struct{})}} + a := newTestApp(agent) + t.Cleanup(a.leaveEverything) + a.file, a.title = "/tmp/closing/transcript.jsonl", "saved local chat" + emptyMachine(a) + a.stow(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: "/tmp/remaining/transcript.jsonl"}, &aside{}) + if a.openCount() != 2 { + t.Fatal("fixture must have exactly two chats") + } + finished := make(chan struct{}) + a.closingStarted = func(done <-chan struct{}) { go func() { <-done; close(finished) }() } + cmd, more := a.closeFront() + if !more { + t.Fatal("no remaining chat") + } + spend(t, a, cmd) + close(agent.release) + <-finished + a.Update(noticesMsg{}) + shown := strings.Join(strings.Fields(plain(frame(a))), " ") + if !strings.Contains(shown, "saved here; sync storage is off for this account, so it stays on this computer") || strings.Contains(shown, closeSaveFailedWord) { + t.Fatalf("late receipt: %s", shown) + } +} diff --git a/internal/tui3/syncspace.go b/internal/tui3/syncspace.go index 768c098414..a3e284766c 100644 --- a/internal/tui3/syncspace.go +++ b/internal/tui3/syncspace.go @@ -25,8 +25,9 @@ type syncSpaceMsg struct { // laptop wait on every press; the stored amount changes slowly, so a reading // taken at launch or at the previous /status is close enough. func (a *app) runStatusCommand(rest string) tea.Cmd { + refresh := a.refreshSyncSpace() a.showStatus(rest == "--json") - return a.refreshSyncSpace() + return refresh } // refreshSyncSpace is the one background reading, or nil when sync is off or no diff --git a/internal/tui3/takeover.go b/internal/tui3/takeover.go index 592781042b..f178423f63 100644 --- a/internal/tui3/takeover.go +++ b/internal/tui3/takeover.go @@ -592,7 +592,7 @@ func (a *app) takeOver() tea.Cmd { a.dropParked() a.writeDraftsNow(a.leavingDraft()) a.retainSavingDurability([]Agent{a.agent}) - return closeConversationLater(a.agent, session.StopByTakeover, a.saveFailure, a.closingStarted, nil) + return closeConversationLater(a.agent, session.StopByTakeover, a.saveFailure, a.closingStarted, nil, a.syncService) } a.dropParked() if a.draftFile != "" { From 578e2a43739e4e343094991fce49051ecb59d2a1 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sun, 8 Nov 2026 11:00:13 -0500 Subject: [PATCH 14/18] Say in the manual what storage-off continuing, closing and Settings do The continuing page says that reading stored history needs no approval, that only turns made here which another computer lacks wait for storage, and that coming back after --at works. The close, Settings and starting pages match what the code now says and does. The change entry records that sync refusal sentences used to stay in the engine process on the default road. Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/2509-relay-storage-opt-in.md | 2 ++ .../manual/chat/continuing-a-chat-on-another-computer.md | 8 ++++++++ internal/manual/chat/sessions-and-rewind.md | 8 ++++++++ internal/manual/chat/settings.md | 2 +- internal/manual/chat/starting-codeaf.md | 4 ++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/changes/unreleased/2509-relay-storage-opt-in.md b/docs/changes/unreleased/2509-relay-storage-opt-in.md index 3cf24e47e0..77bb2d8fff 100644 --- a/docs/changes/unreleased/2509-relay-storage-opt-in.md +++ b/docs/changes/unreleased/2509-relay-storage-opt-in.md @@ -8,6 +8,8 @@ invalidates: - "Every hosted relay write was accepted up to the account's 5 GiB limit; a write from an account that is not approved is now refused with 403 storage_off before anything is stored, and frames stored before this change stay readable." - "There was no way to grant or withdraw hosted storage per account; codeaf-relay-admin approve and revoke send an operator-signed request to POST /v1/admin/storage, checked against CAF_ADMIN_KEYS, and CAF_LIMITS storageOpen opens storage for everyone." - "Rotating an identity needed nothing from the hosted relay beyond the freeze; the freeze bytes are unchanged, and a relay that reports storage also takes a separate POST /v1/store/handoff from the freezing device so an approved account keeps storage." + - "On the default engine road, sync refusal sentences for storage full, removed, replaced, clock skew and displacement stayed in the engine process and never reached the window; they now reach every attached window of that chat." + - "Reopening local turns under this device's expired hold with an empty recorded head opened a viewer; it now reacquires the chat and publishes those turns when storage is on, including after approval." --- The privacy terms for the hosted relay are not finished, so until they are it keeps nobody's chats or files. A person asks for storage from Settings → diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index c4dd2c203c..a7d199dfe2 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -69,6 +69,14 @@ at five seconds without a chat-list answer, Home says `waiting for ; still these are Home's listing sentences. a message left unsent by a failed send check gets its own explanation: ` has not answered; your message is still in the box, press enter to send it`. +## Continue a chat on its own computer while sync storage is off + +Reading history already stored for your account needs no storage approval. With no local work to keep, `1 continue here` brings that recorded history here, even if this computer has no copy yet or its clean copy is older. + +After a live `--at` session, come back to the original computer and choose `1 continue here`. If its local history contains the last shared turn and any later local turns, or no turn was shared yet, the chat continues in place. Every local turn stays, the continue card goes away, and you can send messages and use tools immediately. Nothing is uploaded while storage is off. Quit and reopen normally; after approval, opening the chat on this same computer publishes the preserved turns even if its earlier hold expired. + +Keeping turns made here that another computer lacks waits for storage access only when they must be saved separately before replacing this copy. If those turns diverge from newer stored history, codeaf preserves them and says `cannot continue this chat here while sync storage is off because this computer is missing saved turns; your local turns stay here`. Your typed message stays in the box. Request access in Settings → Devices → sync storage before continuing this divergent copy. + ## Why does continue here say this chat was last held here — my only computer asks to continue A chat this computer held last, whose hold expired with no other computer holding it, opens without a question: there is nothing to continue from. Enter sends, and turns this computer saved while it could not reach your other devices are sent to them when the chat opens. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 045710f1c6..2dbf5a1302 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -1005,3 +1005,11 @@ does not clear this chat's saving failure. If a local write fails, the error names the local transcript that keeps the unsaved turns. A sync outage alone leaves the turns saved here and does not produce a local-write failure. Keep that file: after storage becomes reachable again, the turns can be saved and published, or kept separately when you continue the chat. + +## Closing one open chat while sync storage is off + +Closing a chat with `/quit` or its close action can leave your other chats open. Only when an upload is refused because sync storage is off after a successful local save, codeaf says `saved here; sync storage is off for this account, so it stays on this computer`. The same words appear when quitting the app with that saving result. + +When local saving succeeds but another upload is still pending, the sentence is `saved here; it reaches your other devices when the sync service at
answers`. It names the service used by the chat you closed. + +If saving here fails, closing one chat still says `this chat could not be saved; its unsaved turns stay on this computer for the next start`. An unanswered save remains finishing in the background until its result arrives. Storage being off alone does not mean the local save failed. diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index aaaa5efe85..e1bf33124c 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -195,4 +195,4 @@ setting. Knowledge capture must still be enabled from Memory for each project. Hosted sync storage is off by default while its privacy terms are being finished. Pairing alone does not enable it: new chats, files, saved keys, preferences, shared memories, teams and task copies wait. Pairing, device presence and live `--at` still work. After pairing, press enter on **Settings → Devices → sync storage** to ask for approval. A self-hosted sync service with `--store` needs no request. -A paired account whose service reports storage permission has a **sync storage** row showing **on** or **off**. While **off**, press Enter (`enter request access`) to open a pre-filled GitHub issue. The panel also shows the public request code and link if no browser opens. The code is your identity id (`id_` and 32 hex digits), safe to post and granting nothing by itself. Choosing this row in Settings search clears the search and opens Devices. The row is absent with sync off, before pairing, or when the service answers without storage permission. `/status` shows the dim line `sync storage off` while storage is off. +A paired account whose service reports storage permission has a **sync storage** row showing **on** or **off**. While **off**, press Enter (`enter request access`) to open a pre-filled GitHub issue. The panel also shows the public request code and link if no browser opens. The code is your identity id (`id_` and 32 hex digits), safe to post and granting nothing by itself. Choosing this row in Settings search clears the search and opens Devices. While **off**, Enter also requests access. While **on**, it opens Devices without offering or making a request. The row is absent with sync off, before pairing, or when the service answers without storage permission. A window already open when you pair checks for the answer within about a minute; an open Settings page then updates its row automatically. The first `/status` after pairing shows the cached dim line `sync storage off` while storage is off. diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 2815a90cad..4f1245c4ba 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -841,3 +841,7 @@ the provider refused the key; doctor helps find the source to replace. ## Chats on another computer and hosted sync storage Pairing and live `--at` access work while hosted sync storage is off. Hosted storage is off by default while its privacy terms are being finished, so chats and files stay on this computer until approval. After pairing, request access in Settings → Devices → sync storage → enter. The panel shows the public request code and link as well as opening a browser. Sync starts by itself after a background reading sees approval; `/status` refreshes it without holding up the chat. A self-hosted `codeaf relay --store ` needs no request. + +## Sync storage is off — a notice in an open chat + +A paired account with storage off sees `sync storage is off for this account, so chats stay on this computer; request access in Settings under Devices` once when that chat first tries to sync. If access is withdrawn after syncing starts, the chat shows it once again. This appears in the window showing the chat, including an already-open window. Your chats keep saving on this computer. From 0e96a096494bae064ecf7ad52961fa8c8c41012c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sun, 8 Nov 2026 11:28:36 -0500 Subject: [PATCH 15/18] Leave hosted sync storage out of the system prompt The prompt on dev says nothing about sync, so the paragraph this branch added retracted no promise. It only cost about 400 bytes on every request and pushed the lean prefix over its budget. The manual already answers what sync storage does and how to ask for it. Co-Authored-By: Claude Opus 5.5 --- internal/session/prompts/system.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/session/prompts/system.md b/internal/session/prompts/system.md index 5ccf4a13ac..8c400767e9 100644 --- a/internal/session/prompts/system.md +++ b/internal/session/prompts/system.md @@ -196,5 +196,3 @@ BEFORE RUNNING A COMMAND, CHECK THE TRANSCRIPT. If its answer is already here, u # Critical - Don't end the turn while work the person asked for remains; phase boundary/todo flip/sub-step never stops: same turn. - HANDED-OFF WORK IS NOT WORK THAT REMAINS: end your reply once nothing independent of it is left. A task of your own still owes its deliverable whatever it hands out. - -Hosted sync storage for chats and files is off by default while its privacy terms are being finished. Pairing, devices and live --at work while storage is off; offline chat transfer, relay recovery, teams sharing and synced keys require approval. Explain Settings → Devices → sync storage → enter to request access; a self-hosted relay with --store needs no request. Pairing alone does not make a backup. From 37a05998659a1a85da46ff3443899ed9597d65fd Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sun, 8 Nov 2026 11:43:11 -0500 Subject: [PATCH 16/18] Finish storage-off continuation and keep shared watches until their owners close The storage-off road selector now uses the same local-work checks as the ordinary continue path, so bookkeeping-only seals let newer history download and an edit made here stays here without any upload. Sending again after this computer's own hold over an empty head has lapsed re-acquires the chat quietly instead of asking to continue it. A seal watch counts the conversations that use it, Home errands included, and retires only after the last of them has closed locally. A retired watch arms no more timers, and the window detaches its listener on every exit. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/storage_off_lifetime_test.go | 319 ++++++++++++++++++ internal/cellstore/sealwatch.go | 55 ++- internal/cellstore/sealwatch_test.go | 19 ++ .../continuing-a-chat-on-another-computer.md | 2 +- internal/session/agent.go | 11 +- internal/session/conversation_delete.go | 6 + internal/session/sealnotice.go | 12 + internal/session/sealwatch_close_test.go | 48 +++ internal/session/session.go | 2 + .../syncsetup/storage_off_approve_test.go | 79 +++++ internal/syncsetup/take.go | 6 +- internal/tui3/homepanel_heldelsewhere.go | 5 +- internal/tui3/place_settings.go | 11 +- internal/tui3/tui3.go | 1 + 14 files changed, 552 insertions(+), 24 deletions(-) create mode 100644 cmd/codeaf/storage_off_lifetime_test.go create mode 100644 internal/session/sealwatch_close_test.go diff --git a/cmd/codeaf/storage_off_lifetime_test.go b/cmd/codeaf/storage_off_lifetime_test.go new file mode 100644 index 0000000000..db422cc2c8 --- /dev/null +++ b/cmd/codeaf/storage_off_lifetime_test.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +func TestSharedSealWatchLivesUntilBothConversationsClose(t *testing.T) { + for _, parentFirst := range []bool{true, false} { + t.Run(map[bool]string{true: "parent closes first", false: "child closes first"}[parentFirst], func(t *testing.T) { + cfg := v3Seated(storageOffNoticeConfig(t)) + d := syncDrives.renewalsOf(cfg.Place.ID()) + startupFinished(d) + parent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer parent.Close() + child, err := v3Errand(cfg, cfg.Workspace, "", true)(tui3.ErrandOrders{Dir: t.TempDir(), Workspace: cfg.Workspace}) + if err != nil { + t.Fatal(err) + } + defer child.Close() + first, last := tui3.Agent(parent), child + if !parentFirst { + first, last = child, parent + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + if got := storageOffWatchCount(d.notices); got != 1 { + t.Fatalf("first close retired a shared watch: %d", got) + } + d.notices.say("remaining conversation still receives notices") + found := false + watch := cfg.Seals.(*cellstore.SealWatch) + for line := watch.Take(); line != ""; line = watch.Take() { + found = found || line == "remaining conversation still receives notices" + } + if !found { + t.Fatal("remaining owner lost its subscription") + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + if got := storageOffWatchCount(d.notices); got != 1 { + t.Fatalf("repeated close released another owner: %d", got) + } + if err := last.Close(); err != nil { + t.Fatal(err) + } + if got := storageOffWatchCount(d.notices); got != 0 { + t.Fatalf("last close retained %d watches", got) + } + }) + } +} + +func TestCancelledSurfaceDetachesItsSealListener(t *testing.T) { + cfg := v3Seated(storageOffNoticeConfig(t)) + startupFinished(syncDrives.renewalsOf(cfg.Place.ID())) + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + watch := cfg.Seals.(*cellstore.SealWatch) + seam := sealSeamOf(agent, cfg.Seals) + var active, wakes atomic.Int32 + listen := seam.Listen + seam.Listen = func(wake func()) func() { + active.Add(1) + stop := listen(func() { wakes.Add(1); wake() }) + return func() { stop(); active.Add(-1) } + } + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + screen := &syncOffFrameCapture{} + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, SessionFile: cfg.SessionFile, Workspace: cfg.Workspace, Seal: seam, Input: input, Output: screen, Width: 120, Height: 35, Linear: true}) + }() + waitStorageOffBoundary(t, "surface listener", func() bool { return active.Load() == 1 && screen.Plain() != "" }) + cancel() + <-done + if active.Load() != 0 { + t.Fatal("cancelled surface retained its seal listener") + } + before := wakes.Load() + watch.Note("the watch outlives the window") + if wakes.Load() != before { + t.Fatal("retained watch rang a closed window") + } + found := false + for line := watch.Take(); line != ""; line = watch.Take() { + found = found || line == "the watch outlives the window" + } + if !found { + t.Fatal("surface cancellation retired the still-owned watch") + } +} + +type storageOffCountedFrames struct { + blobstore.Store + writes atomic.Int32 +} + +func (s *storageOffCountedFrames) PutFrame(ctx context.Context, b []byte) (blobstore.FrameID, error) { + s.writes.Add(1) + return s.Store.PutFrame(ctx, b) +} + +func waitStorageOffBoundary(t *testing.T, what string, ready func() bool) { + t.Helper() + until := time.Now().Add(5 * time.Second) + for !ready() { + if time.Now().After(until) { + t.Fatal("waiting for " + what) + } + time.Sleep(5 * time.Millisecond) + } +} + +func TestStorageOffContinuedChatSendsAfterItsOwnEmptyHeadHoldExpires(t *testing.T) { + cfg, c := spokenDoorChat(t) + // A real saved-chat location lets the ordinary Home reader find the reopened workspace. + root := filepath.Join(session.PlacesRoot(), "project", c.ID) + if err := os.MkdirAll(filepath.Dir(root), 0700); err != nil { + t.Fatal(err) + } + if err := os.Rename(c.Root, root); err != nil { + t.Fatal(err) + } + var err error + c, err = cell.OpenAt(root, c.ID) + if err != nil { + t.Fatal(err) + } + cfg.Place.Dir = root + cfg.SessionFile = cfg.Place.Transcript() + t.Cleanup(cellstore.StopSpawned) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + dir := directory.NewMemory(func() time.Time { return time.UnixMilli(clock.Load()) }) + defer dir.Close() + store := &storageOffCountedFrames{Store: blobstore.NewMemory()} + auth := reqsign.AuthenticateAt(time.Now) + mux := http.NewServeMux() + mux.Handle("/v1/dir/", directory.Handler(auth, func(string) (directory.Directory, error) { return dir, nil })) + mux.Handle("/", blobstore.Handler(auth, func(string) (blobstore.Store, error) { return store, nil })) + srv := httptest.NewServer(mux) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), "off"); err != nil { + t.Fatal(err) + } + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + t.Fatalf("open sync: %v", err) + } + if _, err := dir.For("other-computer").Create(t.Context(), c.ID, directory.CellInit{}); err != nil { + t.Fatal(err) + } + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: cfg.Workspace}); err != nil { + t.Fatal(err) + } + eng := cellstore.EngineFor(cfg.Workspace) + if _, err := eng.Seal(t.Context(), c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + if err := cellstore.NotePublished(c, ""); err != nil { + t.Fatal(err) + } + taker := takeover{s.Continuer(eng, syncsetup.TakeOptions{RootFor: func(string) string { return c.Root }})} + continued, err := taker.Take(t.Context(), c.ID) + if err != nil || !continued.LocalOnly { + t.Fatalf("continue: %+v %v", continued, err) + } + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var request struct { + Messages []struct { + Role string + Content json.RawMessage + } + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + marker := "before-expiry" + for _, message := range request.Messages { + if message.Role == "user" && strings.Contains(string(message.Content), "after-expiry") { + marker = "after-expiry" + } + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"answer-%s\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", marker) + })) + defer model.Close() + cfg.BaseURL = model.URL + cfg = v3Seated(cfg) + drive := syncDrives.renewalsOf(c.ID) + startupFinished(drive) + if drive.started() != nil { + t.Fatal("storage-off seat started an uploading writer") + } + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + screen := &syncOffFrameCapture{} + t.Cleanup(func() { + if t.Failed() { + raw, _ := os.ReadFile(cfg.SessionFile) + t.Logf("surface: %s\ntranscript: %s", screen.Plain(), raw) + } + }) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + open := func(workspace, transcript string) (tui3.Conversation, error) { + reopened, err := v3Reopen(cfg, transcript, workspace) + if err != nil { + return tui3.Conversation{}, err + } + reopened = v3Seated(reopened) + next, err := session.New(reopened) + if err == nil { + t.Cleanup(func() { _ = next.Close() }) + } + return tui3.Conversation{Agent: next, Workspace: reopened.Workspace, SessionFile: transcript, Resumed: true}, err + } + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, Workspace: cfg.Workspace, SessionFile: cfg.SessionFile, Machines: surfaceMachines{s}, Takeover: taker, Open: open, Input: input, Output: screen, Width: 160, Height: 40, Linear: true}) + }() + defer func() { cancel(); <-done }() + waitStorageOffBoundary(t, "first frame", func() bool { return screen.Plain() != "" }) + send := func(marker string) { + t.Helper() + if _, err := io.WriteString(typed, marker+"\r"); err != nil { + t.Fatal(err) + } + waitStorageOffBoundary(t, "locally saved "+marker, func() bool { + if strings.Contains(screen.Plain(), "Continue this chat here?") { + t.Fatal("own empty-head chat asked to continue again") + } + raw, _ := os.ReadFile(cfg.SessionFile) + return strings.Contains(string(raw), `"role":"assistant","content":"answer-`+marker+`"`) + }) + } + send("before-expiry") + previous, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil { + t.Fatal(err) + } + clock.Add((directory.LeaseTTL + time.Second).Milliseconds()) + expired, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || expired.Cell.Head != "" || expired.Cell.Lease.Device != s.Device.ID() || expired.Cell.Lease.Expires > expired.Now { + t.Fatalf("not this computer's free empty-head chat: %+v %v", expired, err) + } + send("after-expiry") + waitStorageOffBoundary(t, "saved local turns", func() bool { + raw, _ := os.ReadFile(cfg.SessionFile) + head, _ := cellstore.Head(c) + return head != nil && head.Receipt.Transcript.End >= int64(len(raw)) + }) + raw, err := os.ReadFile(cfg.SessionFile) + if err != nil { + t.Fatal(err) + } + for _, words := range []string{"printf fixture", "answer-before-expiry", "answer-after-expiry"} { + if !strings.Contains(string(raw), words) { + t.Fatalf("local history lost %q", words) + } + } + latest, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || latest.Cell.Lease.Fence <= previous.Cell.Lease.Fence || latest.Cell.Lease.Device != s.Device.ID() { + t.Fatalf("send did not reacquire: %+v %v", latest, err) + } + if strings.Contains(screen.Plain(), "Continue this chat here?") { + t.Fatal("own expired chat asked to continue") + } + if store.writes.Load() != 0 { + t.Fatalf("store writes=%d", store.writes.Load()) + } +} diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index ec02ece5c3..48653b3cf5 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -39,6 +39,8 @@ type SealWatch struct { // OnRetire releases seat-owned subscriptions after the session stops. OnRetire func() retired sync.Once + stopped bool + owners int wake func() listener uint64 // OnTurnEnd, when set, hears each time the agent finishes a turn. The door @@ -142,16 +144,48 @@ func (w *SealWatch) Listen(wake func()) func() { } } +// Adopt keeps subscriptions alive while a conversation still uses this watch. +func (w *SealWatch) Adopt() func() { + w.mu.Lock() + defer w.mu.Unlock() + if w.stopped { + return func() {} + } + w.owners++ + var released sync.Once + return func() { + released.Do(func() { + last := func() bool { + w.mu.Lock() + defer w.mu.Unlock() + w.owners-- + if w.owners != 0 { + return false + } + // Mark the last release before unlocking so a retired watch cannot be adopted again. + w.stopped = true + return true + }() + if last { + w.Retire() + } + }) + } +} + // Retire stops subscriptions and tail work once this seat has no session left. func (w *SealWatch) Retire() { w.retired.Do(func() { + func() { + w.mu.Lock() + defer w.mu.Unlock() + w.stopped = true + w.stopTail() + w.wake = nil + }() if w.OnRetire != nil { w.OnRetire() } - w.mu.Lock() - defer w.mu.Unlock() - w.stopTail() - w.wake = nil }) } @@ -184,7 +218,7 @@ func (w *SealWatch) TurnStarted() { func (w *SealWatch) Wrote() { w.mu.Lock() defer w.mu.Unlock() - if !w.idle || w.OnTail == nil { + if w.stopped || !w.idle || w.OnTail == nil { return } w.stopTail() @@ -206,10 +240,13 @@ func (w *SealWatch) stopTail() { } func (w *SealWatch) sealTail() { - w.mu.Lock() - owed := w.idle && w.tail != nil - w.tail = nil - w.mu.Unlock() + owed := func() bool { + w.mu.Lock() + defer w.mu.Unlock() + owed := !w.stopped && w.idle && w.tail != nil + w.tail = nil + return owed + }() if owed { w.OnTail() } diff --git a/internal/cellstore/sealwatch_test.go b/internal/cellstore/sealwatch_test.go index 3c731449d8..917dda0522 100644 --- a/internal/cellstore/sealwatch_test.go +++ b/internal/cellstore/sealwatch_test.go @@ -267,3 +267,22 @@ func TestSealWatchStartupTurnStillWaitsForPublish(t *testing.T) { t.Fatal("published startup turn did not recover") } } + +func TestRetiredSealWatchNeverArmsAnotherTail(t *testing.T) { + w := &SealWatch{Quiet: time.Hour, OnTail: func() { t.Error("retired watch sealed a tail") }} + w.Waiting() + w.Wrote() + w.Retire() + defer w.Retire() + w.Wrote() + w.TurnStarted() + w.TurnEnded() + w.Waiting() + w.Wrote() + w.mu.Lock() + defer w.mu.Unlock() + if w.tail != nil { + w.tail.Stop() + t.Fatal("retired watch armed a new tail timer") + } +} diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index a7d199dfe2..0845f5601c 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -79,7 +79,7 @@ Keeping turns made here that another computer lacks waits for storage access onl ## Why does continue here say this chat was last held here — my only computer asks to continue -A chat this computer held last, whose hold expired with no other computer holding it, opens without a question: there is nothing to continue from. Enter sends, and turns this computer saved while it could not reach your other devices are sent to them when the chat opens. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. +A chat this computer held last, whose hold expired with no other computer holding it, opens without a question, even if nothing has been shared yet. Enter sends. While sync storage is off, new turns stay on this computer; after approval, opening the chat here sends its preserved turns to your other computers. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. The card still says `this chat was last held here; last saved turn 2h ago` when this window's own earlier move failed. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. diff --git a/internal/session/agent.go b/internal/session/agent.go index fdf84d661b..5bc83d9956 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -414,6 +414,10 @@ func newAgent(config Config, client Completer) (*Agent, error) { // AND THIS PROCESS SAYS IT HOLDS THIS CONVERSATION. It is how a lane knows // somebody is waiting on it, and how the memory pass finds the one room to // say its line in (livesessions.go). Close erases it. + // Every conversation using a watch owns its lifetime, including independently running errands. + if watch, ok := config.Seals.(interface{ Adopt() func() }); ok { + agent.releaseSeals = watch.Adopt() + } registerLiveSession(agent) // AND WHAT WAS ANSWERED WHILE NOBODY WAS HOME IS ANSWERED NOW. An answer left // on this conversation's doorstep rides the presence heartbeat @@ -2410,12 +2414,7 @@ func (f sessionCompleter) FallbackModels(model string) []string { // inside a tool must not hold the process open, and past the grace the journal // simply stops accepting writes rather than writing to a closed descriptor. func (a *Agent) Close() (saveErr error) { - // Child conversations may share the parent's watch without owning its subscriptions. - if !a.config.InTask && !a.config.Errand { - if seals, ok := a.config.Seals.(interface{ Retire() }); ok { - defer seals.Retire() - } - } + defer a.releaseSealsAfterClose() defer func() { saveErr = SavingError(a.config.SessionFile, saveErr) }() // WHAT THE RECORD ALREADY HOLDS IS SETTLED BEFORE THE DOOR SHUTS, so an idle // session that read a landing in its last turn does not re-tell it tomorrow. diff --git a/internal/session/conversation_delete.go b/internal/session/conversation_delete.go index 64e5b6f3dc..e47d598f8a 100644 --- a/internal/session/conversation_delete.go +++ b/internal/session/conversation_delete.go @@ -76,11 +76,17 @@ func (a *Agent) finishCloseWhenStopped() { if file != nil { _ = file.Close() } + if a.releaseSeals != nil { + a.releaseSeals() + } return default: a.beltMu.Unlock() } guard.Go("finishing a conversation deletion stop", func() { + if a.releaseSeals != nil { + defer a.releaseSeals() + } <-done if file != nil { _ = file.Close() diff --git a/internal/session/sealnotice.go b/internal/session/sealnotice.go index 7ccf4d3173..632f36889e 100644 --- a/internal/session/sealnotice.go +++ b/internal/session/sealnotice.go @@ -51,3 +51,15 @@ func (a *Agent) noticeSealed() { hub.send(Event{Kind: EventNotice, Text: said, Told: true}) } } + +// Deferred journal completion still owns the watch after Close returns to the surface. +func (a *Agent) releaseSealsAfterClose() { + pending := func() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.closeFinalizer + }() + if !pending && a.releaseSeals != nil { + a.releaseSeals() + } +} diff --git a/internal/session/sealwatch_close_test.go b/internal/session/sealwatch_close_test.go new file mode 100644 index 0000000000..346055080a --- /dev/null +++ b/internal/session/sealwatch_close_test.go @@ -0,0 +1,48 @@ +package session + +import ( + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" +) + +func TestSealWatchOutlivesDeferredConversationClose(t *testing.T) { + retired := make(chan struct{}) + watch := &cellstore.SealWatch{OnRetire: func() { close(retired) }} + file := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(cfg *Config) { cfg.Seals = watch; cfg.SessionFile = file }) + done := make(chan struct{}) + agent.beltRun = &beltRun{over: done} + defer func() { + select { + case <-done: + default: + close(done) + } + agent.beltMu.Lock() + defer agent.beltMu.Unlock() + agent.beltRun = nil + }() + if err := agent.Close(); err != nil { + t.Fatal(err) + } + select { + case <-retired: + t.Fatal("watch retired before its workers and journal finished") + default: + } + if !InUse(file) { + t.Fatal("fixture did not retain its unfinished journal") + } + close(done) + select { + case <-retired: + case <-time.After(time.Second): + t.Fatal("completed local close retained its watch") + } + if InUse(file) { + t.Fatal("watch retired before the journal closed") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index fd0bd59e6a..b572c375d7 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3206,6 +3206,8 @@ type Agent struct { closingBeltDone <-chan struct{} deletionGrace time.Duration closeFinalizer bool + // releaseSeals gives back this conversation's share after its last local save. + releaseSeals func() // closeDone is closed by [Agent.Close] as its LAST act, and it is what makes // the close complete for everybody rather than only for whoever got there // first. diff --git a/internal/syncsetup/storage_off_approve_test.go b/internal/syncsetup/storage_off_approve_test.go index 1c713bd386..02712c1678 100644 --- a/internal/syncsetup/storage_off_approve_test.go +++ b/internal/syncsetup/storage_off_approve_test.go @@ -66,6 +66,85 @@ func TestStorageOffContinueReadsRecordedHistory(t *testing.T) { } } +// Continuation must preserve precisely the work the ordinary taker would keep. +func TestStorageOffContinuationUsesTheSameLocalWorkChecks(t *testing.T) { + for _, mode := range []string{"bookkeeping behind", "same length transcript edit", "workspace files only"} { + t.Run(mode, func(t *testing.T) { + h := newTwoHomes(t) + a := h.openA() + a.mustSay("first recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + got, err := h.continuerB().Take(t.Context(), h.cell.ID) + mustNil(t, err) + local := got.Taken.Cell + mustNil(t, h.b.Dir.Release(t.Context(), h.cell.ID, got.Taken.Driving.Fence)) + cont := h.continuerB() + before, err := os.ReadFile(transcriptOf(local)) + mustNil(t, err) + workspaceFile := filepath.Join(workspaceOf(local.Root), "kept.txt") + switch mode { + case "bookkeeping behind": + _, err = cont.eng.Seal(t.Context(), local, cellstore.TurnInfo{}) + mustNil(t, err) + a = h.openA() + a.mustSay("newer recorded turn") + h.durable(h.cell.ID, h.cell) + mustNil(t, a.drive.Close(t.Context())) + before, err = os.ReadFile(transcriptOf(h.cell)) + mustNil(t, err) + cont = h.continuerB() + case "same length transcript edit": + edited := bytes.Replace(before, []byte("first recorded turn"), []byte("local edited record"), 1) + if bytes.Equal(edited, before) || len(edited) != len(before) { + t.Fatal("fixture must edit a message without changing its length") + } + before = edited + mustNil(t, os.WriteFile(transcriptOf(local), before, 0600)) + case "workspace files only": + mustNil(t, os.WriteFile(workspaceFile, []byte("keep this work"), 0600)) + } + view, err := h.b.Dir.Cell(t.Context(), h.cell.ID) + mustNil(t, err) + rawHead, _, err := cellstore.Unpublished(local, view.Cell.Head) + mustNil(t, err) + rawDirty, err := cont.eng.Dirty(t.Context(), local) + mustNil(t, err) + if mode == "bookkeeping behind" && rawHead == "" { + t.Fatal("fixture needs an unpublished bookkeeping seal") + } + if mode == "same length transcript edit" && (rawHead != "" || rawDirty) { + t.Fatal("fixture must evade the raw checks") + } + counted := &storageOffStore{Store: h.b.Store, off: true} + h.b.Store = counted + mustNil(t, SaveStorage(h.homeB, h.b.Relay, h.b.Identity.ID(), "off")) + got, takeErr := cont.Take(t.Context(), h.cell.ID) + actual, err := os.ReadFile(transcriptOf(local)) + mustNil(t, err) + if !bytes.Equal(actual, before) || counted.writes != 0 || got.Taken.Kept != "" { + t.Fatalf("history=%q want=%q writes=%d kept=%q", actual, before, counted.writes, got.Taken.Kept) + } + mustNil(t, takeErr) + if mode == "bookkeeping behind" { + if got.Taken.Driving.Head != view.Cell.Head || headOf(t, local) != view.Cell.Head { + t.Fatal("bookkeeping prevented the read-only fetch") + } + } else if got.Taken.Cell.Root != local.Root || got.Taken.Driving.Head != view.Cell.Head { + t.Fatal("local work did not continue in place") + } + if mode == "workspace files only" { + raw, err := os.ReadFile(workspaceFile) + mustNil(t, err) + if string(raw) != "keep this work" { + t.Fatalf("workspace work changed: %q", raw) + } + } + storageOffRequireLocalReady(t, got) + }) + } +} + // B5 carries every preserved turn through quit, expiry, approval and publication. func TestContinueOffThenApprovePublishes(t *testing.T) { celltest.RequireEngine(t) diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 383e9881e0..71b78b8d87 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -237,14 +237,15 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx c.retireHolder(ctx, id) retired() storageOff := !c.sync.StorageAllowed() + localWork := engineLocal{eng: c.eng, ctx: ctx} if storageOff { local := cell.Cell{ID: id, Root: root} if _, err := os.Stat(root); err == nil { - head, _, err := cellstore.Unpublished(local, view.Cell.Head) + head, _, err := localWork.Unpublished(local, view.Cell.Head) if err != nil { return Continued{}, err } - dirty, err := c.eng.Dirty(ctx, local) + dirty, err := localWork.Dirty(ctx, local) if err != nil { return Continued{}, err } @@ -267,6 +268,7 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx intendedPrimary = project } taker := c.taker(ctx, sc) + taker.Local = localWork preflight := func(_ context.Context, staged cell.Cell) error { place := session.Place{Dir: staged.Root, Workspace: intendedPrimary} plan, err := session.TaskWorkspaceTransferPlan(place) diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index 16ffe56954..d0726c64a1 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -308,7 +308,10 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { } return msg.resume() } - if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && row.Head != "" && row.Head == msg.head { + // An empty head still belongs here when this device was the last holder; + // conditional acquisition checks that nobody took it after this reading. + localHead := row.Head != "" && row.Head == msg.head || row.Head == "" && row.Mine + if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && localHead { if _, ok := a.taker.(conditionalTaker); ok { draft := a.mainComposer() return a.takeRemoteState(homeTakenMsg{row: row, draft: &draft, send: msg.resume}) diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 3c4fceda69..107e01d090 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -36,11 +36,12 @@ func (placeSettings) word() string { return "settings" } func (placeSettings) open(a *app) tea.Cmd { a.raiseSettings() - // The place clock reads the saved account code and storage permission only - // on Devices or in Settings search, so pairing updates the visible row - // without recurring account discovery on unrelated tabs. The same beat - // keeps the navigation bar's counts current; other settings rows change - // only when their settings change. + // The place clock samples local sync setup, the pairing identity, the solo + // marker and the saved storage permission only on Devices or in Settings + // search. It derives the public account code from that identity, so pairing + // updates the visible row without recurring account discovery on unrelated + // tabs. The same beat keeps the navigation bar's counts current; other + // settings rows change only when their settings change. // OVER --host THE TEAMS TAB READS THE FAR MACHINE, once, off the loop. A // local launch asks for nothing. diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 02c8bbb604..106510e1c2 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -1345,6 +1345,7 @@ func Run(ctx context.Context, opts Options) error { defer listenForNews(surface.news)() defer surface.news.close() defer surface.noticeBell.close() + defer func() { surface.useSeal(SealSeam{}) }() defer surface.leaving.close() // AND THE DOOR LINE ENDS WITH THE WINDOW, after what is already in it has // been asked (offloop.go): a person's last keystroke before they close a From e91f332896901f7da37f9208b5981ebdb428b351 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sun, 8 Nov 2026 11:52:26 -0500 Subject: [PATCH 17/18] Let a retired seal watch keep sealing what is written Retiring a watch was meant to end the delivery of drive sentences once no conversation uses it. Stopping the trailing seal as well went further than dev ever did: a conversation still finishing its journal after a deletion timeout, or a Home errand opened later on the same seat, lost the seal of what it wrote. Retirement now ends only the subscriptions and the window listener, and the tail behaves exactly as it does on dev. The change entry also says that the next Enter, not only a reopen, quietly reacquires a chat under this device's own expired empty-head hold. Co-Authored-By: Claude Opus 5.5 --- .../unreleased/2509-relay-storage-opt-in.md | 2 +- internal/cellstore/sealwatch.go | 12 ++++++---- internal/cellstore/sealwatch_test.go | 22 ++++++++----------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/changes/unreleased/2509-relay-storage-opt-in.md b/docs/changes/unreleased/2509-relay-storage-opt-in.md index 77bb2d8fff..191a31f7fa 100644 --- a/docs/changes/unreleased/2509-relay-storage-opt-in.md +++ b/docs/changes/unreleased/2509-relay-storage-opt-in.md @@ -9,7 +9,7 @@ invalidates: - "There was no way to grant or withdraw hosted storage per account; codeaf-relay-admin approve and revoke send an operator-signed request to POST /v1/admin/storage, checked against CAF_ADMIN_KEYS, and CAF_LIMITS storageOpen opens storage for everyone." - "Rotating an identity needed nothing from the hosted relay beyond the freeze; the freeze bytes are unchanged, and a relay that reports storage also takes a separate POST /v1/store/handoff from the freezing device so an approved account keeps storage." - "On the default engine road, sync refusal sentences for storage full, removed, replaced, clock skew and displacement stayed in the engine process and never reached the window; they now reach every attached window of that chat." - - "Reopening local turns under this device's expired hold with an empty recorded head opened a viewer; it now reacquires the chat and publishes those turns when storage is on, including after approval." + - "Local turns under this device's own expired hold with an empty recorded head opened a viewer on reopening and asked to continue on the next Enter; both now quietly reacquire the chat, whether storage is on or off, and publish those turns once storage is on." --- The privacy terms for the hosted relay are not finished, so until they are it keeps nobody's chats or files. A person asks for storage from Settings → diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go index 48653b3cf5..8b82d52860 100644 --- a/internal/cellstore/sealwatch.go +++ b/internal/cellstore/sealwatch.go @@ -173,14 +173,18 @@ func (w *SealWatch) Adopt() func() { } } -// Retire stops subscriptions and tail work once this seat has no session left. +// Retire ends this seat's sentence subscriptions once no conversation uses it. +// +// RETIRING NEVER TOUCHES THE TRAILING SEAL. The tail belongs to the writes, not +// to the subscriptions: a conversation still finishing its journal, or a Home +// errand opened later on the same seat, must seal what it wrote exactly as it +// did before retirement existed. Only the delivery of sentences ends here. func (w *SealWatch) Retire() { w.retired.Do(func() { func() { w.mu.Lock() defer w.mu.Unlock() w.stopped = true - w.stopTail() w.wake = nil }() if w.OnRetire != nil { @@ -218,7 +222,7 @@ func (w *SealWatch) TurnStarted() { func (w *SealWatch) Wrote() { w.mu.Lock() defer w.mu.Unlock() - if w.stopped || !w.idle || w.OnTail == nil { + if !w.idle || w.OnTail == nil { return } w.stopTail() @@ -243,7 +247,7 @@ func (w *SealWatch) sealTail() { owed := func() bool { w.mu.Lock() defer w.mu.Unlock() - owed := !w.stopped && w.idle && w.tail != nil + owed := w.idle && w.tail != nil w.tail = nil return owed }() diff --git a/internal/cellstore/sealwatch_test.go b/internal/cellstore/sealwatch_test.go index 917dda0522..53bdb28e39 100644 --- a/internal/cellstore/sealwatch_test.go +++ b/internal/cellstore/sealwatch_test.go @@ -268,21 +268,17 @@ func TestSealWatchStartupTurnStillWaitsForPublish(t *testing.T) { } } -func TestRetiredSealWatchNeverArmsAnotherTail(t *testing.T) { - w := &SealWatch{Quiet: time.Hour, OnTail: func() { t.Error("retired watch sealed a tail") }} +// A retired watch still seals what is written while the agent waits: retiring +// ends the delivery of sentences, never the trailing seal a later write is owed. +func TestARetiredSealWatchStillSealsALaterWrite(t *testing.T) { + sealed := make(chan struct{}, 1) + w := &SealWatch{Quiet: time.Millisecond, OnTail: func() { sealed <- struct{}{} }} w.Waiting() - w.Wrote() w.Retire() - defer w.Retire() w.Wrote() - w.TurnStarted() - w.TurnEnded() - w.Waiting() - w.Wrote() - w.mu.Lock() - defer w.mu.Unlock() - if w.tail != nil { - w.tail.Stop() - t.Fatal("retired watch armed a new tail timer") + select { + case <-sealed: + case <-time.After(5 * time.Second): + t.Fatal("a write after retirement was never sealed") } } From 6e031be207178775ae5b6354ab16ee218afb5e77 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Sun, 8 Nov 2026 13:02:54 -0500 Subject: [PATCH 18/18] Let a storage-off chat return quietly after its hold lapses With storage off no writer renews the directory hold, so it lapses about ninety seconds after each continue. The send gate then re-acquired quietly only from the same saved turn or an empty head, and a chat with local turns past the last stored one asked "Continue this chat here?" again every minute or two, showing the storage-off sentence each time it reopened. While storage is off the send gate now treats the chat as local, and a free chat this computer held last re-acquires quietly when every stored turn is here. The conversation stays open rather than reopening, so nothing is said twice. A chat missing turns another computer stored still asks, and nothing changes while storage is on. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/storage_off_lifetime_test.go | 312 +++++++++++++++++- cmd/codeaf/synctake.go | 3 +- .../continuing-a-chat-on-another-computer.md | 8 +- internal/syncsetup/take.go | 22 +- internal/tui3/homepanel_continue.go | 6 + internal/tui3/homepanel_heldelsewhere.go | 15 +- 6 files changed, 349 insertions(+), 17 deletions(-) diff --git a/cmd/codeaf/storage_off_lifetime_test.go b/cmd/codeaf/storage_off_lifetime_test.go index db422cc2c8..27e111d1f4 100644 --- a/cmd/codeaf/storage_off_lifetime_test.go +++ b/cmd/codeaf/storage_off_lifetime_test.go @@ -17,6 +17,8 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" @@ -144,7 +146,8 @@ func waitStorageOffBoundary(t *testing.T, what string, ready func() bool) { } } -func TestStorageOffContinuedChatSendsAfterItsOwnEmptyHeadHoldExpires(t *testing.T) { +func storageOffSavedChat(t *testing.T) (session.Config, cell.Cell) { + t.Helper() cfg, c := spokenDoorChat(t) // A real saved-chat location lets the ordinary Home reader find the reopened workspace. root := filepath.Join(session.PlacesRoot(), "project", c.ID) @@ -161,6 +164,11 @@ func TestStorageOffContinuedChatSendsAfterItsOwnEmptyHeadHoldExpires(t *testing. } cfg.Place.Dir = root cfg.SessionFile = cfg.Place.Transcript() + return cfg, c +} + +func TestStorageOffContinuedChatSendsAfterItsOwnEmptyHeadHoldExpires(t *testing.T) { + cfg, c := storageOffSavedChat(t) t.Cleanup(cellstore.StopSpawned) id, err := identity.Ensure(home.Dir()) if err != nil { @@ -317,3 +325,305 @@ func TestStorageOffContinuedChatSendsAfterItsOwnEmptyHeadHoldExpires(t *testing. t.Fatalf("store writes=%d", store.writes.Load()) } } + +func TestStorageOffChatKeepsSendingAfterItsOwnRecordedHeadHoldExpires(t *testing.T) { + testRecordedHeadReturn(t, "local turns ahead") +} + +func TestStorageOffChatWithMissingSavedTurnsDoesNotReturnQuietly(t *testing.T) { + testRecordedHeadReturn(t, "missing saved turns") +} + +func TestStorageOnChatWithDifferentHeadsStillAsksToContinue(t *testing.T) { + testRecordedHeadReturn(t, "storage on") +} + +func testRecordedHeadReturn(t *testing.T, mode string) { + t.Helper() + cfg, c := storageOffSavedChat(t) + t.Cleanup(cellstore.StopSpawned) + id, err := identity.Ensure(home.Dir()) + if err != nil { + t.Fatal(err) + } + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + dir := directory.NewMemory(func() time.Time { return time.UnixMilli(clock.Load()) }) + defer dir.Close() + store := &storageOffCountedFrames{Store: blobstore.NewMemory()} + auth := reqsign.AuthenticateAt(time.Now) + mux := http.NewServeMux() + mux.Handle("/v1/dir/", directory.Handler(auth, func(string) (directory.Directory, error) { return dir, nil })) + mux.Handle("/", blobstore.Handler(auth, func(string) (blobstore.Store, error) { return store, nil })) + srv := httptest.NewServer(mux) + defer srv.Close() + t.Setenv(syncsetup.URLVar, srv.URL) + permission := func(state string) { + t.Helper() + if err := syncsetup.SaveStorage(home.Dir(), srv.URL, id.ID(), state); err != nil { + t.Fatal(err) + } + } + permission("on") + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + t.Fatalf("open sync: %v", err) + } + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: cfg.Workspace}); err != nil { + t.Fatal(err) + } + eng := cellstore.EngineFor(cfg.Workspace) + if _, err := eng.Seal(t.Context(), c, cellstore.TurnInfo{}); err != nil { + t.Fatal(err) + } + approved := v3Seated(cfg) + startupFinished(syncDrives.renewalsOf(c.ID)) + syncDrives.renewalsOf(c.ID).Idle() + syncDrives.closeAll() + approved.Seals.(*cellstore.SealWatch).Retire() + published, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || published.Cell.Head == "" || store.writes.Load() == 0 { + t.Fatalf("approved chat never published: %+v, writes=%d, %v", published, store.writes.Load(), err) + } + permission("off") + writesAtRevoke := store.writes.Load() + taker := takeover{s.Continuer(eng, syncsetup.TakeOptions{RootFor: func(string) string { return c.Root }})} + continued, err := taker.Take(t.Context(), c.ID) + if err != nil || !continued.LocalOnly { + t.Fatalf("continue after revoke: %+v %v", continued, err) + } + var turns atomic.Int32 + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var request struct { + Messages []struct { + Role string + Content json.RawMessage + } + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + marker := "" + for _, message := range request.Messages { + if message.Role == "user" { + for _, candidate := range []string{"local-one", "local-two", "before-expiry", "after-first-expiry", "after-second-expiry"} { + if strings.Contains(string(message.Content), candidate) { + marker = candidate + } + } + } + } + turns.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"answer-%s\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", marker) + })) + defer model.Close() + cfg.BaseURL = model.URL + localCfg := v3Seated(cfg) + startupFinished(syncDrives.renewalsOf(c.ID)) + local, err := session.New(localCfg) + if err != nil { + t.Fatal(err) + } + defer local.Close() + for _, words := range []string{"local-one", "local-two"} { + events, err := local.Submit(t.Context(), words) + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventError { + t.Fatal(event.Text) + } + } + } + if err := local.Close(); err != nil { + t.Fatal(err) + } + syncDrives.closeAll() + reopened, err := v3Reopen(cfg, cfg.SessionFile, cfg.Workspace) + if err != nil { + t.Fatal(err) + } + cfg = v3Seated(reopened) + startupFinished(syncDrives.renewalsOf(c.ID)) + if syncDrives.renewalsOf(c.ID).started() != nil { + t.Fatal("storage-off chat started an uploading writer") + } + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + input, typed := io.Pipe() + defer input.Close() + defer typed.Close() + screen := &syncOffFrameCapture{} + var opens atomic.Int32 + open := func(workspace, transcript string) (tui3.Conversation, error) { + opens.Add(1) + reopened, err := v3Reopen(cfg, transcript, workspace) + if err != nil { + return tui3.Conversation{}, err + } + reopened = v3Seated(reopened) + next, err := session.New(reopened) + if err == nil { + t.Cleanup(func() { _ = next.Close() }) + } + return tui3.Conversation{Agent: next, Workspace: reopened.Workspace, SessionFile: transcript, Resumed: true}, err + } + t.Cleanup(func() { + if t.Failed() { + raw, _ := os.ReadFile(cfg.SessionFile) + t.Logf("surface: %s\ntranscript: %s", screen.Plain(), raw) + } + }) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + seam := sealSeamOf(agent, cfg.Seals) + var storageNotices atomic.Int32 + notice := seam.Notice + seam.Notice = func() string { + line := notice() + if line == chatlist.RelayStorageOff { + storageNotices.Add(1) + } + return line + } + go func() { + done <- tui3.Run(ctx, tui3.Options{Agent: agent, Workspace: cfg.Workspace, SessionFile: cfg.SessionFile, Seal: seam, Machines: surfaceMachines{s}, Takeover: taker, Open: open, Input: input, Output: screen, Width: 200, Height: 40, Linear: true}) + }() + defer func() { cancel(); <-done }() + shown := func() string { return strings.Join(strings.Fields(screen.Plain()), " ") } + waitStorageOffBoundary(t, "storage-off sentence", func() bool { return strings.Contains(shown(), chatlist.RelayStorageOff) }) + send := func(marker string) { + t.Helper() + if _, err := io.WriteString(typed, marker+"\r"); err != nil { + t.Fatal(err) + } + waitStorageOffBoundary(t, "saved "+marker, func() bool { + if strings.Contains(shown(), "Continue this chat here?") { + t.Fatal("own chat asked to continue again") + } + raw, _ := os.ReadFile(cfg.SessionFile) + head, _ := cellstore.Head(c) + return strings.Contains(string(raw), `"role":"assistant","content":"answer-`+marker+`"`) && head != nil && head.Receipt.Transcript.End >= int64(len(raw)) + }) + } + send("before-expiry") + before, err := os.ReadFile(cfg.SessionFile) + if err != nil { + t.Fatal(err) + } + for _, marker := range []string{"after-first-expiry", "after-second-expiry"} { + clock.Add((directory.LeaseTTL + time.Second).Milliseconds()) + if mode == "missing saved turns" { + // Another computer publishes a real child of the shared turn, without receiving our local turns. + other := cell.Cell{ID: c.ID, Root: filepath.Join(t.TempDir(), c.ID)} + otherEngine := eng + otherEngine.DataRoot = t.TempDir() + synced := otherEngine.Sync(cellstore.SyncKeys{CellKey: id.CellKey(), Dedup: id.DedupSecret()}, "fedcba9876543210") + fetch := cellsync.Fetcher{Engine: synced, Store: store.Store, Inbox: synced.Inbox} + if err := fetch.Fetch(t.Context(), other, published.Cell.Head); err != nil { + t.Fatal(err) + } + other, err = cell.OpenAt(other.Root, other.ID) + if err != nil { + t.Fatal(err) + } + file, err := os.OpenFile(filepath.Join(other.Root, cell.TranscriptPath), os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, writeErr := io.WriteString(file, "{\"type\":\"message\",\"role\":\"user\",\"content\":\"another computer's turn\"}\n") + closeErr := file.Close() + if writeErr != nil || closeErr != nil { + t.Fatalf("other turn: %v %v", writeErr, closeErr) + } + sealed, err := otherEngine.Seal(t.Context(), other, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + otherDir := dir.For("other-computer") + got, err := otherDir.Acquire(t.Context(), c.ID, directory.AcquireOpts{}) + if err != nil { + t.Fatal(err) + } + driving := cellsync.Driving{Cell: other, Fence: got.Cell.Lease.Fence, Head: got.Cell.Head, Frames: got.Cell.Frames} + publisher := cellsync.Publisher{Engine: synced, Store: store.Store, Dir: otherDir} + if err := publisher.Publish(t.Context(), &driving, sealed.Turn.ID, cellsync.PublishInfo{}); err != nil { + t.Fatal(err) + } + if err := otherDir.Release(t.Context(), c.ID, driving.Fence); err != nil { + t.Fatal(err) + } + // Returning the recorded hold here without fetching makes this an own free chat with missing history. + got, err = s.Dir.Acquire(t.Context(), c.ID, directory.AcquireOpts{}) + if err != nil { + t.Fatal(err) + } + if err := s.Dir.Release(t.Context(), c.ID, got.Cell.Lease.Fence); err != nil { + t.Fatal(err) + } + } + if mode == "storage on" { + // Stop background retries so this send, rather than an approval upload, observes the differing heads. + syncDrives.closeAll() + permission("on") + } + free, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || free.Cell.Head == "" || free.Cell.Lease.Device != s.Device.ID() || free.Cell.Lease.Expires > free.Now { + t.Fatalf("not this computer's free recorded chat: %+v %v", free, err) + } + extends, err := cellstore.Extends(c, free.Cell.Head) + if err != nil || extends != (mode != "missing saved turns") { + t.Fatalf("fixture ancestry=%v: %v", extends, err) + } + if mode != "local turns ahead" { + calls := turns.Load() + if _, err := io.WriteString(typed, marker+"\r"); err != nil { + t.Fatal(err) + } + waitStorageOffBoundary(t, "continue question or missing-turns refusal", func() bool { + return strings.Contains(shown(), "Continue this chat here?") || mode == "missing saved turns" && strings.Contains(shown(), "this computer is missing saved turns; your local turns stay here") + }) + latest, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || latest.Cell.Lease.Fence != free.Cell.Lease.Fence || turns.Load() != calls { + t.Fatalf("control acquired or sent: %+v calls=%d/%d %v", latest, turns.Load(), calls, err) + } + after, err := os.ReadFile(cfg.SessionFile) + if err != nil || string(after) != string(before) { + t.Fatalf("control changed local turns: %v", err) + } + break + } + send(marker) + latest, err := s.Dir.Cell(t.Context(), c.ID) + if err != nil || latest.Cell.Lease.Fence <= free.Cell.Lease.Fence || latest.Cell.Lease.Device != s.Device.ID() || latest.Cell.Head != published.Cell.Head { + t.Fatalf("send did not return without publishing: %+v %v", latest, err) + } + } + if opens.Load() != 0 { + t.Fatalf("chat reopened %d times", opens.Load()) + } + if got := storageNotices.Load(); got != 1 { + t.Fatalf("storage-off sentence shown %d times", got) + } + if store.writes.Load() != writesAtRevoke { + t.Fatalf("store writes after revoke=%d", store.writes.Load()-writesAtRevoke) + } + raw, err := os.ReadFile(cfg.SessionFile) + if err != nil { + t.Fatal(err) + } + for _, words := range []string{"printf fixture", "answer-local-one", "answer-local-two", "answer-before-expiry"} { + if !strings.Contains(string(raw), words) { + t.Fatalf("local history lost %q", words) + } + } +} diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go index e3c689c2c5..5049a65dc8 100644 --- a/cmd/codeaf/synctake.go +++ b/cmd/codeaf/synctake.go @@ -184,7 +184,8 @@ func (s surfaceMachines) RowsForChatSend(ctx context.Context, file string, offli return nil, 0, false, directory.ErrUnreachable } rows, version, err := s.RowsForSend(ctx) - return rows, version, false, err + // Storage-off chats save locally without an uploading writer, even after their hold expires. + return rows, version, !s.StorageAllowed(), err } func (s surfaceMachines) RowsForSend(ctx context.Context) ([]chatlist.Row, uint64, error) { diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md index 0845f5601c..208654afde 100644 --- a/internal/manual/chat/continuing-a-chat-on-another-computer.md +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -79,7 +79,9 @@ Keeping turns made here that another computer lacks waits for storage access onl ## Why does continue here say this chat was last held here — my only computer asks to continue -A chat this computer held last, whose hold expired with no other computer holding it, opens without a question, even if nothing has been shared yet. Enter sends. While sync storage is off, new turns stay on this computer; after approval, opening the chat here sends its preserved turns to your other computers. Earlier builds asked `Continue this chat here?` on the only computer that used the chat. +A chat this computer held last can resume sending after its hold expires when nobody else holds it. While sync storage is off, Enter sends without a question if the local history contains every shared turn, including when later turns stayed only here or nothing was shared yet. The conversation stays open and the storage-off sentence is not repeated after idle time. After approval, opening the chat here sends its preserved turns to your other computers. + +With storage on, differing saved turns still raise the question. Missing shared turns also require a choice or show the existing refusal while storage is off; local turns stay here. The card still says `this chat was last held here; last saved turn 2h ago` when this window's own earlier move failed. Choose `continue here` to resume it. The landing says `Moved here` and never names this computer as somewhere the chat moved from. @@ -472,8 +474,6 @@ Yes. Directory requests from older builds keep the existing 1 MiB request limit, ## Why did Enter show Continue here instead of sending my message -On the hosted sync service this needs sync storage approval (Settings → Devices → sync storage). - Before sending, codeaf checks that this window still holds the chat. If another computer holds it, its latest saved turn is newer, or this window's earlier move failed, Enter opens **Continue this chat here?** or leaves the window showing the chat only. The words stay in the box. Choose **continue here** to bring the saved chat here, then press Enter to send. Once this window says another computer continued the chat, its first Enter @@ -481,7 +481,7 @@ raises that question without running a turn or making a branch. A saved hold from an earlier process cannot bypass the question. A window whose writer still holds the chat sends with one Enter without asking the service first. -A new chat can send its first message normally. A sleeping computer can also resume sending when nobody else has taken the chat and its local saved turn is still current. That safe return happens without another question. If the ownership check cannot reach your other computers, a chat this window already holds keeps working and its turns wait to be shared; any other message stays unsent. +A new chat can send its first message normally. A sleeping computer can also resume sending when nobody else has taken the chat and its local saved turn is still current. While storage is off, a free chat last held here also resumes quietly if its local history contains every shared turn, even with newer turns saved only here. These returns keep the conversation open without another question. If the ownership check cannot reach your other computers, a chat this window already holds keeps working and its turns wait to be shared; any other message stays unsent. ## A move that failed partway — are my typed words safe, can the old copy still send diff --git a/internal/syncsetup/take.go b/internal/syncsetup/take.go index 71b78b8d87..0628af1cfc 100644 --- a/internal/syncsetup/take.go +++ b/internal/syncsetup/take.go @@ -233,10 +233,16 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if lease.Device == c.sync.Device.ID() { from = "" } - retired := handoff.Trace(c.opt.OnPhase, "retirement") - c.retireHolder(ctx, id) - retired() storageOff := !c.sync.StorageAllowed() + // An own free chat can return without replacing its open local conversation. + keepOpen := storageOff && lease.Device == c.sync.Device.ID() && lease.Expires <= view.Now + retire := func() { + defer handoff.Trace(c.opt.OnPhase, "retirement")() + c.retireHolder(ctx, id) + } + if !keepOpen { + retire() + } localWork := engineLocal{eng: c.eng, ctx: ctx} if storageOff { local := cell.Cell{ID: id, Root: root} @@ -249,15 +255,19 @@ func (c *Continuer) take(ctx context.Context, id string, seen *directory.LeaseEx if err != nil { return Continued{}, err } - // Only work that keepLocal would preserve needs the in-place road; - // an absent or clean older copy can read the recorded history normally. - if head != "" || dirty { + // An own free chat whose shared turn is here can keep its live reader; + // an absent or clean older copy still reads the recorded history normally. + if head != "" || dirty || keepOpen && known { return c.takeLocalStorageOff(ctx, id, root, seen) } } else if !errors.Is(err, os.ErrNotExist) { return Continued{}, err } } + if keepOpen { + // A read-only fetch still replaces files, so it must retire their live reader. + retire() + } sc := c.sync.scope(id) defer func() { defer handoff.Trace(c.opt.OnPhase, "filing")() diff --git a/internal/tui3/homepanel_continue.go b/internal/tui3/homepanel_continue.go index 0defce2150..a0a7830135 100644 --- a/internal/tui3/homepanel_continue.go +++ b/internal/tui3/homepanel_continue.go @@ -884,6 +884,12 @@ func (a *app) openTaken(msg homeTakenMsg) tea.Cmd { } } a.machineRead.rows = rows + if msg.send != nil && msg.row.Mine && msg.taken.LocalOnly && msg.fromFile == a.file && msg.taken.Transcript == a.file { + // The local conversation was kept in place, so reopening would repeat its startup notices. + a.restoreTakeDraft(msg) + a.finishHomeTake(false) + return msg.send() + } a.machinesRetaken = a.machinesAsking a.considerResume() a.home.others = a.othersNow() diff --git a/internal/tui3/homepanel_heldelsewhere.go b/internal/tui3/homepanel_heldelsewhere.go index d0726c64a1..582a491dd2 100644 --- a/internal/tui3/homepanel_heldelsewhere.go +++ b/internal/tui3/homepanel_heldelsewhere.go @@ -62,6 +62,7 @@ type ownershipReadMsg struct { fence uint64 proof bool local bool + extends bool // notHeld prevents a saved receipt from reviving a displaced writer offline. notHeld bool writerStopped bool @@ -189,9 +190,13 @@ func (a *app) checkBeforeSendWith(resume func() tea.Cmd) tea.Cmd { } for _, row := range rows { if row.Cell == remote && (row.Status == chatlist.Idle || row.Status == chatlist.Off) { - if head, _ := cellstore.Head(cell.Cell{ID: id, Root: session.DirOf(file)}); head != nil { + at := cell.Cell{ID: id, Root: session.DirOf(file)} + if head, _ := cellstore.Head(at); head != nil { msg.head = head.Turn.ID } + if msg.local && row.Mine && row.Head != "" { + msg.extends, _ = cellstore.Extends(at, row.Head) + } break } } @@ -255,7 +260,7 @@ func (a *app) afterOwnershipRead(msg ownershipReadMsg) tea.Cmd { } // resumeOwnedSend spends only the submitted gesture after its fresh ownership -// reading. A free chat can resume silently only from the same saved turn. +// reading. A storage-off return can include later local turns when every saved turn is here. func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { if msg.writerDisplaced { return nil @@ -308,9 +313,9 @@ func (a *app) resumeOwnedSend(msg ownershipReadMsg) tea.Cmd { } return msg.resume() } - // An empty head still belongs here when this device was the last holder; - // conditional acquisition checks that nobody took it after this reading. - localHead := row.Head != "" && row.Head == msg.head || row.Head == "" && row.Mine + // Local-only work can be ahead of the last shared turn. Missing turns + // still need a choice, and the conditional take checks ancestry again. + localHead := row.Head != "" && row.Head == msg.head || row.Mine && (row.Head == "" || msg.local && msg.extends) if !held.blocked && (row.Status == chatlist.Idle || row.Status == chatlist.Off) && localHead { if _, ok := a.taker.(conditionalTaker); ok { draft := a.mainComposer()