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.
139 lines
6.5 KiB
TypeScript
139 lines
6.5 KiB
TypeScript
import { Transform, type TransformCallback } from 'node:stream';
|
|
import { TextDecoder } from 'node:util';
|
|
import { jsonPieces } from './json.js';
|
|
import { LIMITS, type ErrorShape, type BridgeBaseMessage } from './types.js';
|
|
|
|
export function isValidId(id: unknown): id is string {
|
|
return typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
|
}
|
|
const messages: Record<string, string> = {
|
|
invalid_input: 'Invalid runtime protocol or input.',
|
|
connectivity_failed: 'The backend connection was lost.',
|
|
query_timeout: 'The runtime deadline expired.',
|
|
model_context_exceeded: 'The configured model context limit was exceeded.',
|
|
model_output_limit: 'The model output is incomplete or exceeds its configured limit.',
|
|
upstream_failed: 'The model request failed.',
|
|
upstream_response_too_large: 'The upstream response exceeds the supported limit.',
|
|
tls_failed: 'The upstream TLS connection failed.',
|
|
confluence_auth_failed: 'Confluence authentication failed.',
|
|
destination_denied: 'The requested Confluence destination is denied.',
|
|
cleanup_failed: 'Runtime cleanup failed.',
|
|
execution_failed: 'The runtime could not complete execution.',
|
|
};
|
|
// Never reflect arbitrary exceptions or peer content into terminal diagnostics.
|
|
export function sanitizeError(code: string, _raw?: unknown): ErrorShape {
|
|
const safeCode = Object.hasOwn(messages, code) ? code : 'execution_failed';
|
|
return { code: safeCode, message: messages[safeCode] };
|
|
}
|
|
|
|
export class NDJsonFrameParser extends Transform {
|
|
private parts: Buffer[] = [];
|
|
private bytes = 0;
|
|
private slab = Buffer.alloc(65536);
|
|
private used = 0;
|
|
constructor(private maxFrameBytes: number = LIMITS.ORDINARY_FRAME_MAX_BYTES) {
|
|
super({ readableObjectMode: true });
|
|
}
|
|
setMaxFrameBytes(max: number): void { this.maxFrameBytes = max; }
|
|
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
|
|
try {
|
|
let offset = 0;
|
|
while (offset < chunk.length) {
|
|
const end = chunk.indexOf(10, offset);
|
|
const stop = end < 0 ? chunk.length : end;
|
|
const part = chunk.subarray(offset, stop);
|
|
if (this.bytes + part.length > this.maxFrameBytes) throw new Error('Frame limit exceeded');
|
|
// Copy slices so a small unfinished line cannot retain a large coalesced chunk.
|
|
for (let at = 0; at < part.length;) {
|
|
const n = Math.min(this.slab.length - this.used, part.length - at);
|
|
part.copy(this.slab, this.used, at, at + n); this.used += n; at += n; this.bytes += n;
|
|
if (this.used === this.slab.length) { this.parts.push(this.slab); this.slab = Buffer.alloc(65536); this.used = 0; }
|
|
}
|
|
if (end < 0) break;
|
|
if (this.used) this.parts.push(this.slab.subarray(0, this.used));
|
|
const line = Buffer.concat(this.parts, this.bytes);
|
|
this.slab = Buffer.alloc(65536); this.used = 0;
|
|
this.parts = []; this.bytes = 0;
|
|
if (!line.length) throw new Error('Empty frame');
|
|
const parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(line));
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid frame');
|
|
if (line.length > (parsed.type === 'start' ? LIMITS.START_FRAME_MAX_BYTES : LIMITS.ORDINARY_FRAME_MAX_BYTES)) throw new Error('Frame limit exceeded');
|
|
this.push(parsed);
|
|
offset = end + 1;
|
|
}
|
|
callback();
|
|
} catch { callback(new Error('Invalid protocol frame')); }
|
|
}
|
|
_flush(callback: TransformCallback): void {
|
|
callback(this.bytes ? new Error('Truncated protocol frame') : undefined);
|
|
}
|
|
}
|
|
|
|
type QueuedWrite = { message: BridgeBaseMessage; onStart?: () => void; resolve: () => void; reject: (error: Error) => void };
|
|
export class StreamMessageWriter {
|
|
private queue: QueuedWrite[] = [];
|
|
private active?: QueuedWrite;
|
|
private closed = false;
|
|
private failure?: Error;
|
|
private idle: Array<() => void> = [];
|
|
constructor(private stream: NodeJS.WritableStream) {
|
|
stream.on('error', () => { this.failure = new Error('Output stream failed'); });
|
|
}
|
|
write(message: BridgeBaseMessage, onStart?: () => void): Promise<void> {
|
|
if (this.closed) return Promise.reject(new Error('Writer closed'));
|
|
return this.enqueue(message, onStart);
|
|
}
|
|
/** Drop frames not yet started; never splice a terminal error into an active line. */
|
|
writeTerminal(message: BridgeBaseMessage): Promise<void> {
|
|
if (this.closed) return Promise.reject(new Error('Writer closed'));
|
|
this.closed = true;
|
|
for (const queued of this.queue.splice(0)) queued.reject(new Error('Write cancelled by terminal failure'));
|
|
// An already-started complete/error is itself terminal. A failed/truncated
|
|
// write must be reported by stream closure, not a second terminal frame.
|
|
if (this.active && ['complete', 'error'].includes(this.active.message.type)) {
|
|
return Promise.reject(new Error('Terminal frame already started'));
|
|
}
|
|
return this.enqueue(message);
|
|
}
|
|
private enqueue(message: BridgeBaseMessage, onStart?: () => void): Promise<void> {
|
|
const result = new Promise<void>((resolve, reject) => this.queue.push({ message, onStart, resolve, reject }));
|
|
if (!this.active) void this.pump();
|
|
return result;
|
|
}
|
|
private async pump(): Promise<void> {
|
|
while (this.queue.length) {
|
|
const current = this.queue.shift()!;
|
|
this.active = current;
|
|
try {
|
|
if (this.failure) throw this.failure;
|
|
current.onStart?.();
|
|
let batch = '';
|
|
const flush = async () => {
|
|
const data = batch; batch = '';
|
|
await new Promise<void>((resolve, reject) => {
|
|
this.stream.write(data, 'utf8', (error?: Error | null) => error ? reject(new Error('Output stream failed')) : resolve());
|
|
});
|
|
};
|
|
for (const part of jsonPieces(current.message)) { batch += part; if (batch.length >= 65536) await flush(); }
|
|
batch += '\n'; await flush();
|
|
current.resolve();
|
|
} catch {
|
|
this.failure = new Error('Output stream failed');
|
|
current.reject(this.failure);
|
|
for (const queued of this.queue.splice(0)) queued.reject(this.failure);
|
|
this.closed = true;
|
|
}
|
|
this.active = undefined;
|
|
}
|
|
for (const resolve of this.idle.splice(0)) resolve();
|
|
}
|
|
async close(): Promise<void> {
|
|
this.closed = true;
|
|
if (this.active || this.queue.length) await new Promise<void>(resolve => this.idle.push(resolve));
|
|
if (this.failure) throw this.failure;
|
|
}
|
|
}
|
|
export function logDiagnostic(message: string): void {
|
|
process.stderr.write(message.slice(0, 1024) + '\n');
|
|
}
|