confluence_web/agent/artifacts.ts
Artur Mukhamadiev 38a8ca67f7 agent: pi runtime track handoff (contract revision 1)
Pinned pi SDK 0.85.1 bridge, Python supervisor, artifact exporter,
scripted backend peer, image checks and boundary checks under agent/**.
Review findings F1-F3 are recorded in docs/implementation/PI_AGENT_REVIEW.md.
2026-09-14 21:57:54 +03:00

114 lines
5.8 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { LIMITS, type Warning } from './types.js';
import { WarningCollector } from './warnings.js';
export interface ArtifactFileCandidate { relPath: string; size: number; fd: number; stat: fs.Stats; }
export interface ArtifactScanResult { candidates: ArtifactFileCandidate[]; warnings: Warning[]; }
export function validateRelativeArtifactPath(name: string): { valid: boolean; reason?: string } {
const valid = typeof name === 'string' && !!name && !name.startsWith('/') &&
!/[\\\x00-\x1f\x7f-\x9f]/.test(name) && Buffer.byteLength(name) <= LIMITS.MAX_PATH_BYTES &&
name.split('/').every(c => !!c && c !== '.' && c !== '..');
return valid ? { valid } : { valid: false, reason: 'Invalid relative artifact name' };
}
const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW;
// Linux descriptor-relative traversal. /proc/self/fd is kernel-owned: following
// that magic link uses an already-open directory, never an agent symlink.
function at(fd: number, name: string): string { return `/proc/self/fd/${fd}/${name}`; }
function openRoot(root: string): number {
let fd = fs.openSync('/', directoryFlags);
try {
for (const component of path.resolve(root).split('/').filter(Boolean)) {
const next = fs.openSync(at(fd, component), directoryFlags);
fs.closeSync(fd); fd = next;
}
return fd;
} catch (err) { fs.closeSync(fd); throw err; }
}
export function closeArtifacts(candidates: ArtifactFileCandidate[]): void {
for (const c of candidates) { try { fs.closeSync(c.fd); } catch {} }
}
export function scanArtifactsDirectory(root: string): ArtifactScanResult {
const candidates: ArtifactFileCandidate[] = [];
const warnings = new WarningCollector();
const warn = (code: string) => {
warnings.add({ code, message: 'An artifact was omitted during filesystem validation.' });
};
const seen = new Set<string>();
let total = 0, visited = 0;
function walk(fd: number, prefix: string): void {
const dir = fs.opendirSync(`/proc/self/fd/${fd}`);
try {
let item: fs.Dirent | null;
while ((item = dir.readSync())) {
if (++visited > 10000) { warn('artifact_scan_limit'); return; }
const name = prefix + item.name;
if (!validateRelativeArtifactPath(name).valid) { warn('artifact_invalid_path'); continue; }
const location = at(fd, item.name);
const stat = fs.lstatSync(location);
if (stat.isDirectory()) {
let next: number;
try { next = fs.openSync(location, directoryFlags); } catch { warn('artifact_open_failed'); continue; }
try { walk(next, name + '/'); } finally { fs.closeSync(next); }
if (visited > 10000) return;
continue;
}
if (!stat.isFile() || stat.nlink !== 1) { warn('artifact_not_regular'); continue; }
const normalized = name.normalize('NFC');
if (seen.has(normalized)) { warn('artifact_duplicate_name'); continue; }
seen.add(normalized);
if (stat.size > LIMITS.ARTIFACT_MAX_FILE_BYTES) { warn('artifact_oversized'); continue; }
if (candidates.length >= LIMITS.ARTIFACT_MAX_FILES) { warn('artifact_count_exceeded'); continue; }
if (total + stat.size > LIMITS.ARTIFACT_MAX_TOTAL_BYTES) { warn('artifact_total_limit_exceeded'); continue; }
let file: number;
// O_NONBLOCK prevents a raced FIFO open from blocking the exporter.
try { file = fs.openSync(location, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); }
catch { warn('artifact_open_failed'); continue; }
const opened = fs.fstatSync(file);
if (!opened.isFile() || opened.nlink !== 1 || opened.ino !== stat.ino || opened.dev !== stat.dev || opened.size !== stat.size) {
fs.closeSync(file); warn('artifact_descriptor_mismatch'); continue;
}
total += opened.size;
// Preserve the verified descriptor across backend acknowledgements.
candidates.push({ relPath: name, fd: file, size: opened.size, stat: opened });
}
} finally { dir.closeSync(); }
}
let fd: number;
try { fd = openRoot(root); } catch { warn('artifact_root_rejected'); return { candidates, warnings: warnings.snapshot() }; }
try { walk(fd, ''); } catch { warn('artifact_scan_failed'); } finally { fs.closeSync(fd); }
return { candidates, warnings: warnings.snapshot() };
}
export interface ArtifactTransferCallbacks {
sendBegin: (id: string, name: string, size: number) => Promise<'accept' | 'skip'>;
sendChunk: (id: string, index: number, data: string) => Promise<void>;
sendEnd: (id: string, size: number, chunks: number) => Promise<void>;
}
function unchanged(c: ArtifactFileCandidate): void {
const s = fs.fstatSync(c.fd);
if (!s.isFile() || s.nlink !== 1 || s.ino !== c.stat.ino || s.dev !== c.stat.dev || s.size !== c.size ||
s.mtimeMs !== c.stat.mtimeMs || s.ctimeMs !== c.stat.ctimeMs) throw new Error('Artifact changed after validation');
}
export async function exportArtifacts(candidates: ArtifactFileCandidate[], callbacks: ArtifactTransferCallbacks): Promise<number> {
let stored = 0, seq = 0;
const buffer = Buffer.alloc(LIMITS.ARTIFACT_CHUNK_MAX_DECODED_BYTES);
for (const c of candidates) {
unchanged(c);
const id = `a_t_${++seq}`;
if (await callbacks.sendBegin(id, c.relPath, c.size) === 'skip') continue;
unchanged(c);
let offset = 0, chunks = 0;
while (offset < c.size) {
const n = fs.readSync(c.fd, buffer, 0, Math.min(buffer.length, c.size - offset), offset);
if (!n) throw new Error('Artifact truncated');
offset += n;
await callbacks.sendChunk(id, chunks++, buffer.subarray(0, n).toString('base64'));
}
if (fs.readSync(c.fd, buffer, 0, 1, offset) !== 0) throw new Error('Artifact grew');
unchanged(c);
await callbacks.sendEnd(id, offset, chunks);
stored++;
}
return stored;
}