Spaces:
Running
Running
File size: 10,210 Bytes
eafb694 d06c4aa 1f5aa99 eafb694 1f5aa99 eafb694 1f5aa99 eafb694 1f5aa99 eafb694 1f5aa99 eafb694 1f5aa99 eafb694 1f5aa99 eafb694 1f5aa99 eafb694 1f5aa99 eafb694 d06c4aa eafb694 d06c4aa eafb694 d06c4aa eafb694 d06c4aa 6fd7f11 eafb694 d06c4aa eafb694 6fd7f11 eafb694 1f5aa99 eafb694 1f5aa99 6fd7f11 eafb694 6fd7f11 eafb694 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { DATA_DIR } from './config.js';
export const OPERATIONS_FILE = path.join(DATA_DIR, 'operations.jsonl');
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
// Reads worth auditing. A GET is normally none of this log's business β it
// changes nothing β but `wait` is the one read that IS an event between two
// agents: A blocked on B until B stopped working. Without it the log records
// work being handed out and nothing ever coming back, which is exactly half of
// "who called whom". Deliberately NOT here: `tail`, which every open pane polls
// constantly and which says nothing a resolved wait does not already say.
const LOGGED_READS = [/^\/api\/agents\/[^/]+\/wait$/];
const shouldLog = (req) => MUTATING.has(req.method)
|| (req.method === 'GET' && LOGGED_READS.some((re) => re.test(req.path)));
// The body is stored WHOLE, on the operator's instruction: "just store all the
// full api calls. why this arbitrary compression." So there is no allowlist of
// routes, no size cap, and nothing is replaced by a summary of itself. The one
// thing still withheld is a credential β that is not compression, it is not
// writing secrets into a file that lives on the bucket.
//
// Above this length a string is stored as {present, chars, sha256, text} rather
// than as a bare string. Nothing is lost either way: this only decides whether
// the checksum travels beside the value, and `chars` is what the log's compact
// list column reads.
const MAX_TEXT = 500;
// A backstop against a cyclic object, not a limit on how much is kept: a request
// body is the output of a JSON or text parser and cannot contain a cycle, but
// JSON.stringify throwing here would lose the whole entry.
const MAX_DEPTH = 20;
// How far back a read will go looking for complete records. One enormous entry
// must not hide the log, and reading a whole year of it must not exhaust memory.
const MAX_TAIL = 256 * 1024 * 1024;
const SENSITIVE_KEY = /(authorization|credential|password|secret|subscription|token|endpoint|private.?key)/i;
const CONTENT_KEY = /(body|content|data|prompt|text)/i;
const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
// The value, plus the two things worth having beside it: sha256, because equal
// checksums are how a repeated prompt or a scheduled job shows up, and chars,
// because that is what the list column reads without touching the text.
function textSummary(value) {
return { present: value.length > 0, chars: value.length, sha256: digest(value), text: value };
}
/**
* The call as it was made, whole, with a checksum attached to anything long
* enough to want one. Credentials are the single exception and are replaced by
* `[redacted]` wherever they appear.
*/
export function summarizePayload(value, key = '', depth = 0) {
if (value == null || typeof value === 'boolean' || typeof value === 'number') return value;
// No route here parses a raw body today, but if one ever does, keep the bytes
// rather than a description of them.
if (Buffer.isBuffer(value)) {
return { bytes: value.length, sha256: digest(value), base64: value.toString('base64') };
}
if (typeof value === 'string') {
if (SENSITIVE_KEY.test(key)) return '[redacted]';
if (CONTENT_KEY.test(key) || value.length > MAX_TEXT) return textSummary(value);
return value;
}
if (depth >= MAX_DEPTH) return '[max-depth]';
if (Array.isArray(value)) return value.map((v) => summarizePayload(v, key, depth + 1));
if (typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) {
out[k] = SENSITIVE_KEY.test(k) ? '[redacted]' : summarizePayload(v, k, depth + 1);
}
return out;
}
return String(value);
}
function append(record) {
try {
fs.mkdirSync(path.dirname(OPERATIONS_FILE), { recursive: true });
fs.appendFileSync(OPERATIONS_FILE, `${JSON.stringify(record)}\n`, { mode: 0o600 });
} catch (e) {
// Auditing must never turn a successfully completed user operation into an
// HTTP failure. Make storage trouble loud in the server log instead.
console.error('[operations.append]', e && e.message);
}
}
const requestOrigin = (req) => String(
req.query?.from
|| req.headers?.['x-am-origin']
|| (req.body && !Array.isArray(req.body) && typeof req.body === 'object' ? req.body.from : '')
|| '',
).trim();
const cleanQuery = (query) => {
const out = { ...(query || {}) };
delete out.from;
return summarizePayload(out, 'query');
};
/**
* Require an attributable origin for every state-changing API request and
* append its outcome to a durable JSONL log.
*
* resolveOrigin(raw, req) returns {id,type,name?,cli?}, or null when the id is
* unknown. It may derive an identity from a protocol route (remote agents do
* this for backwards compatibility with already-running polling loops).
*/
export function operationMiddleware({ resolveOrigin, resolveTarget, allowMissing = false } = {}) {
return (req, res, next) => {
if (!req.path.startsWith('/api/') || !shouldLog(req)) return next();
const raw = requestOrigin(req);
let origin = resolveOrigin ? resolveOrigin(raw, req) : (raw ? { id: raw, type: 'unknown' } : null);
if (!origin && allowMissing) origin = { id: 'test', type: 'test' };
// A logged READ is never refused for want of an origin. `wait` is documented
// as read-only and every watch loop running right now calls it without
// `?from=`; rejecting those would break them the moment this ships. An
// unattributed wait still records that someone finished waiting on B.
if (!origin && MUTATING.has(req.method)) {
return res.status(400).json({
error: raw
? `unknown origin '${raw}'`
: 'from required β mutating calls must pass ?from=<origin id> (agents use $AM_ID)',
});
}
if (origin) req.operationOrigin = origin;
// BEFORE next(), not at response time: the handler for a delete removes the
// session from the store and only then answers, so resolving this later
// recorded `{id}` for a session whose name and cli had just been thrown
// away β the one operation where the roster can never fill them back in.
// A request-time snapshot also gives a rename the name it had when the call
// arrived, which is the state the entry is describing.
const target = resolveTarget ? resolveTarget(req) : null;
const started = Date.now();
const operationId = crypto.randomUUID();
let responseBody;
let recorded = false;
const originalJson = res.json.bind(res);
res.json = (body) => {
responseBody = body;
return originalJson(body);
};
const record = () => {
if (recorded) return;
recorded = true;
// A wait is a polling loop: only the call that RESOLVED is an event. The
// ones that timed out say "still working", which the log already implies,
// and logging them would multiply the entries by however long the job ran.
// Same for a wait the caller abandoned (no body) or one whose target had
// already gone: nothing came back, so there is nothing to draw.
if (!MUTATING.has(req.method) && !(responseBody && responseBody.matched === true)) return;
append({
version: 1,
id: operationId,
at: new Date(started).toISOString(),
origin,
// Who it was done TO, snapshotted above. The id is in the path already,
// but a name read back later is the name the session has NOW β renamed
// or deleted, and the audit trail stops making sense.
...(target ? { target } : {}),
method: req.method,
path: req.path,
query: cleanQuery(req.query),
request: summarizePayload(req.body, 'body'),
status: res.statusCode,
ok: res.statusCode < 400,
durationMs: Date.now() - started,
result: summarizePayload(responseBody, 'result'),
});
};
res.once('finish', record);
res.once('close', record);
next();
};
}
export function readOperations(limit = 200, before = null) {
const take = Math.max(1, Math.min(1000, Number(limit) || 200));
let fd;
try {
fd = fs.openSync(OPERATIONS_FILE, 'r');
const size = fs.fstatSync(fd).size;
// A bounded tail keeps this endpoint cheap after years of operations. But an
// entry is now as big as the call it records β a file write can be eight
// megabytes on one line β so a fixed window is not enough: it could contain
// no COMPLETE line at all and the log would read as empty. Grow it until
// there are enough whole records, or until the ceiling says stop.
let rows = [];
for (let window = 4 * 1024 * 1024; ; window *= 4) {
const start = Math.max(0, size - window);
const buf = Buffer.alloc(size - start);
fs.readSync(fd, buf, 0, buf.length, start);
let text = buf.toString('utf8');
// the first line is almost certainly cut in half by the window
if (start > 0) text = text.slice(Math.max(0, text.indexOf('\n') + 1));
rows = text.split('\n').filter(Boolean).flatMap((line) => {
try { return [JSON.parse(line)]; } catch { return []; }
});
if (rows.length >= take || start === 0 || window >= MAX_TAIL) break;
}
// Sort, do not merely reverse. A record is appended when its response
// finishes, but `at` is when the request STARTED β and a `wait` can block
// for five minutes, so it lands in the file after calls that began later and
// finished sooner. Reversing append order therefore returned rows out of
// chronological order, which put an old wait above newer calls in the log
// and, because the map derives its x from rank, could run its time axis
// backwards. Ties keep newest-appended first, which is what reversing did.
return rows
.filter((row) => !before || row.at < before)
.reverse()
.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
.slice(0, take);
} catch {
return [];
} finally {
if (fd !== undefined) try { fs.closeSync(fd); } catch {}
}
}
|