td reports its build; outdated agents are told to update in their guidance
Every td request carries x-td-build (the SHA-256 of the binary, 'dev' from a checkout). When it is not one of the published builds, or missing (td from before this change), the guidance in work responses says to restart the AI app or run td update, and which tools the old td lacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNwtXPYqFu3oKaaXwYwNWW
This commit is contained in:
parent
064c55f394
commit
eff085bcbb
5 changed files with 71 additions and 4 deletions
|
|
@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { ownBuild } from './update.js';
|
||||||
|
|
||||||
export class Api {
|
export class Api {
|
||||||
constructor(server, token) {
|
constructor(server, token) {
|
||||||
|
|
@ -14,7 +15,7 @@ export class Api {
|
||||||
async request(method, path, body) {
|
async request(method, path, body) {
|
||||||
const res = await fetch(this.server + path, {
|
const res = await fetch(this.server + path, {
|
||||||
method,
|
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),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
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 }. */
|
/** GET that sends the ETag of a cached copy: null when unchanged (304), else { data, etag }. */
|
||||||
async getIfChanged(p, etag) {
|
async getIfChanged(p, etag) {
|
||||||
const res = await fetch(this.server + p, {
|
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.status === 304) return null;
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,25 @@ async function ownHash(exe, state) {
|
||||||
return hash;
|
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
|
* 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.
|
* effect on the next start), otherwise null. Never throws: an update must not break a command.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Verifier } from './verify.js';
|
||||||
import { Merger } from './merge.js';
|
import { Merger } from './merge.js';
|
||||||
import { PeerReviews } from './peer.js';
|
import { PeerReviews } from './peer.js';
|
||||||
import { Originals } from './original.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 { ServerGhidra } from './ghidra.js';
|
||||||
import { functionRegions } from './split.js';
|
import { functionRegions } from './split.js';
|
||||||
import { CheckQueue, WorkerUnavailable } from './checks.js';
|
import { CheckQueue, WorkerUnavailable } from './checks.js';
|
||||||
|
|
@ -788,7 +788,7 @@ export function createApp(ctx) {
|
||||||
const m = router.match(req.method, url.pathname);
|
const m = router.match(req.method, url.pathname);
|
||||||
if (!m) throw new HttpError(404, 'not found');
|
if (!m) throw new HttpError(404, 'not found');
|
||||||
const result = await m.handler({ req, res, params: m.params, query: url.searchParams });
|
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) {
|
} catch (e) {
|
||||||
if (res.headersSent) return res.end();
|
if (res.headersSent) return res.end();
|
||||||
if (e instanceof HttpError) sendJson(res, e.status, { error: e.message, details: e.details });
|
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 <dataDir>/downloads).
|
// Public downloads of the td binaries (tools/build-td output copied to <dataDir>/downloads).
|
||||||
const DOWNLOADS = new Set(['td-windows-x64.exe', 'td-linux-x64', 'SHA256SUMS']);
|
const DOWNLOADS = new Set(['td-windows-x64.exe', 'td-linux-x64', 'SHA256SUMS']);
|
||||||
let downloadsRoot = null;
|
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) {
|
function serveDownload(name, req, res) {
|
||||||
const file = DOWNLOADS.has(name) && downloadsRoot ? path.join(downloadsRoot, name) : null;
|
const file = DOWNLOADS.has(name) && downloadsRoot ? path.join(downloadsRoot, name) : null;
|
||||||
if (!file || !fs.existsSync(file)) {
|
if (!file || !fs.existsSync(file)) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ export function downloadsDir(config) {
|
||||||
return path.join(config.dataDir, 'downloads');
|
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) {
|
function checksums(config) {
|
||||||
const file = path.join(downloadsDir(config), 'SHA256SUMS');
|
const file = path.join(downloadsDir(config), 'SHA256SUMS');
|
||||||
const out = {};
|
const out = {};
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
test('the crew view is public and shows no account details', async () => {
|
||||||
const token = await activeUser('crewview');
|
const token = await activeUser('crewview');
|
||||||
await call('GET', '/api/tasks', null, token); // the agent checks in
|
await call('GET', '/api/tasks', null, token); // the agent checks in
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue