diff --git a/src/client/api.js b/src/client/api.js index 8778d19..ec9efee 100644 --- a/src/client/api.js +++ b/src/client/api.js @@ -4,6 +4,7 @@ import fs from 'node:fs'; import crypto from 'node:crypto'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; +import { ownBuild } from './update.js'; export class Api { constructor(server, token) { @@ -14,7 +15,7 @@ export class Api { async request(method, path, body) { const res = await fetch(this.server + path, { method, - headers: { 'content-type': 'application/json', ...(this.token ? { authorization: `Bearer ${this.token}` } : {}) }, + headers: { 'content-type': 'application/json', 'x-td-build': await ownBuild(), ...(this.token ? { authorization: `Bearer ${this.token}` } : {}) }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await res.text(); @@ -40,7 +41,7 @@ export class Api { /** GET that sends the ETag of a cached copy: null when unchanged (304), else { data, etag }. */ async getIfChanged(p, etag) { const res = await fetch(this.server + p, { - headers: { ...(this.token ? { authorization: `Bearer ${this.token}` } : {}), ...(etag ? { 'if-none-match': etag } : {}) }, + headers: { 'x-td-build': await ownBuild(), ...(this.token ? { authorization: `Bearer ${this.token}` } : {}), ...(etag ? { 'if-none-match': etag } : {}) }, }); if (res.status === 304) return null; if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); diff --git a/src/client/update.js b/src/client/update.js index 428642d..1b4b93a 100644 --- a/src/client/update.js +++ b/src/client/update.js @@ -44,6 +44,25 @@ async function ownHash(exe, state) { return hash; } +/** + * This td's build: the SHA-256 of the standalone executable (cached by size and mtime), or 'dev' + * when it runs from a checkout. Sent with every request so the server can tell an outdated td. + */ +let buildCache = null; +export async function ownBuild() { + if (buildCache) return buildCache; + try { + const sea = await import('node:sea').catch(() => null); + if (!sea?.isSea?.()) return (buildCache = 'dev'); + const state = readState(); + buildCache = await ownHash(process.execPath, state); + writeState(state); + } catch { + buildCache = 'unknown'; + } + return buildCache; +} + /** * Checks for and installs a newer td. Returns a message when an update was installed (it takes * effect on the next start), otherwise null. Never throws: an update must not break a command. diff --git a/src/server/app.js b/src/server/app.js index 35eb510..3594b05 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -13,7 +13,7 @@ import { Verifier } from './verify.js'; import { Merger } from './merge.js'; import { PeerReviews } from './peer.js'; import { Originals } from './original.js'; -import { downloadsDir, installPs1, installSh } from './install.js'; +import { downloadsDir, installPs1, installSh, publishedBuilds } from './install.js'; import { ServerGhidra } from './ghidra.js'; import { functionRegions } from './split.js'; import { CheckQueue, WorkerUnavailable } from './checks.js'; @@ -788,7 +788,7 @@ export function createApp(ctx) { const m = router.match(req.method, url.pathname); if (!m) throw new HttpError(404, 'not found'); const result = await m.handler({ req, res, params: m.params, query: url.searchParams }); - if (result !== undefined) sendJson(res, 200, result); + if (result !== undefined) sendJson(res, 200, withUpdateNote(req, result)); } catch (e) { if (res.headersSent) return res.end(); if (e instanceof HttpError) sendJson(res, e.status, { error: e.message, details: e.details }); @@ -804,6 +804,30 @@ export function createApp(ctx) { // Public downloads of the td binaries (tools/build-td output copied to /downloads). const DOWNLOADS = new Set(['td-windows-x64.exe', 'td-linux-x64', 'SHA256SUMS']); let downloadsRoot = null; +// Agents read the guidance in work responses; one whose td is not the current build is told to +// update there (td from before builds were reported sends none). Read per request, so a new +// publish takes effect without a restart. +function withUpdateNote(req, result) { + if (!result || typeof result !== 'object' || typeof result.guidance !== 'string' || !downloadsRoot) return result; + const build = String(req.headers['x-td-build'] ?? ''); + if (build === 'dev') return result; + let current; + try { + current = Object.values(publishedBuilds({ dataDir: path.dirname(downloadsRoot) })); + } catch { + return result; + } + if (!current.length || current.includes(build)) return result; + return { + ...result, + guidance: + result.guidance + + ` UPDATE NEEDED: your td is not the current build (${build ? build.slice(0, 12) : 'it does not report one'}; current ${current.map((h) => h.slice(0, 12)).join(' / ')}).` + + ' Tell the user to restart the AI app (td updates itself when it starts) or to run "td update" and reconnect.' + + ' Older td lacks td_mark_no_decompile, td_place and bulk verdicts in td_submit_peer_review.', + }; +} + function serveDownload(name, req, res) { const file = DOWNLOADS.has(name) && downloadsRoot ? path.join(downloadsRoot, name) : null; if (!file || !fs.existsSync(file)) { diff --git a/src/server/install.js b/src/server/install.js index f8b3956..0d2bd78 100644 --- a/src/server/install.js +++ b/src/server/install.js @@ -11,6 +11,11 @@ export function downloadsDir(config) { return path.join(config.dataDir, 'downloads'); } +/** The SHA-256 of every published td binary (download name -> hash). */ +export function publishedBuilds(config) { + return checksums(config); +} + function checksums(config) { const file = path.join(downloadsDir(config), 'SHA256SUMS'); const out = {}; diff --git a/test/server.test.js b/test/server.test.js index b01d0d0..87a0fd7 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -748,6 +748,24 @@ test('verification: unwritten functions stay claimed, failing ones go back alone } }); +test('agents with an outdated td are told to update in their guidance', async () => { + const token = await activeUser('oldtd'); + const dir = path.join(ctx.config.dataDir, 'downloads'); + fs.mkdirSync(dir, { recursive: true }); + const current = 'a'.repeat(64); + fs.writeFileSync(path.join(dir, 'SHA256SUMS'), `${current} td-windows-x64.exe\n${'b'.repeat(64)} td-linux-x64\n`); + const task = ctx.store.get('SELECT id FROM tasks LIMIT 1').id; + const ask = async (build) => { + const res = await fetch(`${base}/api/tasks/${task}`, { headers: { authorization: `Bearer ${token}`, ...(build ? { 'x-td-build': build } : {}) } }); + return (await res.json()).guidance ?? ''; + }; + assert.match(await ask(null), /UPDATE NEEDED/, 'td from before builds were reported'); + assert.match(await ask('c'.repeat(64)), /UPDATE NEEDED/, 'an older build'); + assert.doesNotMatch(await ask(current), /UPDATE NEEDED/, 'the current build'); + assert.doesNotMatch(await ask('dev'), /UPDATE NEEDED/, 'a checkout'); + fs.rmSync(path.join(dir, 'SHA256SUMS')); +}); + test('the crew view is public and shows no account details', async () => { const token = await activeUser('crewview'); await call('GET', '/api/tasks', null, token); // the agent checks in