feat: initial commit

This commit is contained in:
Jeffrey Wu
2026-09-07 09:57:33 +08:00
commit f39f6ac881
66 changed files with 9859 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@agent-studio/harness",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": { "types": "./src/index.ts", "development": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" } },
"scripts": { "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.json" },
"dependencies": {
"@agent-studio/shared": "workspace:*",
"@earendil-works/pi-ai": "0.84.3",
"@earendil-works/pi-coding-agent": "0.84.3",
"sharp": "^0.34.5",
"typebox": "1.3.7",
"zod": "^4.1.13"
},
"devDependencies": { "@types/node": "^24.10.1", "typescript": "^5.9.3" }
}
+74
View File
@@ -0,0 +1,74 @@
import { randomUUID } from "node:crypto";
import type { ApprovalRequest } from "@agent-studio/shared";
import { HarnessError, redactValue } from "./errors.js";
export type ApprovalStatus = "approved" | "denied" | "expired" | "cancelled";
export interface ApprovalDecision { status: ApprovalStatus; resolvedAt: string }
export interface ApprovalRequestInput { sessionId: string; runId: string; toolCallId: string; toolName: string; arguments: unknown; riskLevel: "low" | "medium" | "high" }
export interface ApprovalResolution { request: ApprovalRequest; decision: ApprovalDecision }
export interface ApprovalBroker {
request(input: ApprovalRequestInput, signal: AbortSignal): Promise<ApprovalDecision>;
approve(approvalId: string): ApprovalResolution;
deny(approvalId: string): ApprovalResolution;
cancelForSession(sessionId: string): void;
get(approvalId: string): ApprovalRequest | undefined;
listPending(sessionId: string): ApprovalRequest[];
}
interface Pending { request: ApprovalRequest; resolve: (value: ApprovalDecision) => void; timer: ReturnType<typeof setTimeout>; signal: AbortSignal; abort: () => void; decision?: ApprovalDecision }
export class InMemoryApprovalBroker implements ApprovalBroker {
private readonly approvals = new Map<string, Pending>();
constructor(
private readonly timeoutMs = 60_000,
private readonly onResolution?: (value: ApprovalResolution) => void,
private readonly onRequest?: (value: ApprovalRequest) => void,
) {}
request(input: ApprovalRequestInput, signal: AbortSignal): Promise<ApprovalDecision> {
const now = Date.now();
const approvalId = `apr_${randomUUID()}`;
const request: ApprovalRequest = {
...input,
approvalId,
arguments: redactValue(input.arguments),
createdAt: new Date(now).toISOString(),
expiresAt: new Date(now + this.timeoutMs).toISOString(),
status: "pending",
};
return new Promise((resolve) => {
const entry: Pending = { request, resolve, timer: setTimeout(() => this.finish(approvalId, "expired"), this.timeoutMs), signal, abort: () => this.finish(approvalId, "cancelled") };
this.approvals.set(approvalId, entry);
this.onRequest?.(request);
if (signal.aborted) entry.abort();
else signal.addEventListener("abort", entry.abort, { once: true });
});
}
private finish(approvalId: string, status: ApprovalStatus): ApprovalResolution {
const entry = this.approvals.get(approvalId);
if (!entry) throw new HarnessError("APPROVAL_NOT_FOUND", "Approval not found", 404);
if (entry.decision) {
if (entry.decision.status !== status) throw new HarnessError("APPROVAL_ALREADY_RESOLVED", "Approval already resolved", 409);
return { request: entry.request, decision: entry.decision };
}
clearTimeout(entry.timer);
entry.signal.removeEventListener("abort", entry.abort);
const decision = { status, resolvedAt: new Date().toISOString() };
entry.decision = decision;
entry.request = { ...entry.request, status };
entry.resolve(decision);
const resolution = { request: entry.request, decision };
this.onResolution?.(resolution);
return resolution;
}
approve(id: string): ApprovalResolution { return this.finish(id, "approved"); }
deny(id: string): ApprovalResolution { return this.finish(id, "denied"); }
cancelForSession(sessionId: string): void {
for (const [id, entry] of this.approvals) if (entry.request.sessionId === sessionId && !entry.decision) this.finish(id, "cancelled");
}
get(id: string): ApprovalRequest | undefined { return this.approvals.get(id)?.request; }
listPending(sessionId: string): ApprovalRequest[] { return [...this.approvals.values()].filter((item) => item.request.sessionId === sessionId && !item.decision).map((item) => item.request); }
}
+68
View File
@@ -0,0 +1,68 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import type { ImageAttachment } from "@agent-studio/shared";
import sharp from "sharp";
import { HarnessError } from "./errors.js";
export interface UploadStream { filename: string; mimetype: string; stream: AsyncIterable<Uint8Array> }
export interface StoredAttachment extends ImageAttachment { filePath: string }
export interface AttachmentStore {
add(sessionId: string, upload: UploadStream): Promise<ImageAttachment>;
get(sessionId: string, attachmentId: string): ImageAttachment | undefined;
getStored(sessionId: string, attachmentId: string): StoredAttachment | undefined;
remove(sessionId: string, attachmentId: string): Promise<void>;
removeAll(sessionId: string): Promise<void>;
}
const mediaExtensions = new Map([["image/jpeg", new Set([".jpg", ".jpeg"])], ["image/png", new Set([".png"])], ["image/webp", new Set([".webp"])]]);
function magicMedia(buffer: Buffer): "image/jpeg" | "image/png" | "image/webp" | undefined {
if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg";
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
return undefined;
}
export class FileAttachmentStore implements AttachmentStore {
private readonly items = new Map<string, StoredAttachment>();
constructor(private readonly sessionsRoot: string, private readonly maxBytes = 5 * 1024 * 1024, private readonly maxPixels = 40_000_000) {}
private key(sessionId: string, attachmentId: string): string { return `${sessionId}:${attachmentId}`; }
async add(sessionId: string, upload: UploadStream): Promise<ImageAttachment> {
const declared = upload.mimetype.toLowerCase();
if (!mediaExtensions.has(declared)) throw new HarnessError("ATTACHMENT_TYPE_UNSUPPORTED", "Only JPEG, PNG, and WebP images are accepted", 415);
const extension = path.extname(upload.filename).toLowerCase();
if (!mediaExtensions.get(declared)?.has(extension)) throw new HarnessError("ATTACHMENT_EXTENSION_MISMATCH", "File extension does not match its MIME type", 415);
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of upload.stream) {
size += chunk.byteLength;
if (size > this.maxBytes) throw new HarnessError("ATTACHMENT_TOO_LARGE", "Image exceeds the configured size limit", 413);
chunks.push(Buffer.from(chunk));
}
const source = Buffer.concat(chunks);
const detected = magicMedia(source);
if (!detected || detected !== declared) throw new HarnessError("ATTACHMENT_MAGIC_MISMATCH", "Image content does not match its declared type", 415);
const metadata = await sharp(source, { limitInputPixels: this.maxPixels, failOn: "error" }).metadata().catch(() => { throw new HarnessError("ATTACHMENT_INVALID_IMAGE", "Image cannot be decoded safely", 422); });
if (!metadata.width || !metadata.height || metadata.width * metadata.height > this.maxPixels) throw new HarnessError("ATTACHMENT_DIMENSIONS_EXCEEDED", "Image dimensions exceed the configured limit", 413);
const pipeline = sharp(source, { limitInputPixels: this.maxPixels, failOn: "error" }).rotate();
const normalized = detected === "image/png" ? await pipeline.png({ compressionLevel: 9 }).toBuffer() : detected === "image/webp" ? await pipeline.webp({ quality: 88 }).toBuffer() : await pipeline.jpeg({ quality: 90, mozjpeg: true }).toBuffer();
const outputMetadata = await sharp(normalized).metadata();
const attachmentId = `att_${randomUUID()}`;
const directory = path.join(this.sessionsRoot, sessionId, "attachments");
await mkdir(directory, { recursive: true, mode: 0o700 });
const finalPath = path.join(directory, `${attachmentId}${extension}`);
const temporary = `${finalPath}.tmp`;
await writeFile(temporary, normalized, { mode: 0o600, flag: "wx" });
await rename(temporary, finalPath);
const item: StoredAttachment = { attachmentId, sessionId, name: path.basename(upload.filename).slice(0, 200), mediaType: detected, byteSize: normalized.byteLength, width: outputMetadata.width ?? metadata.width, height: outputMetadata.height ?? metadata.height, status: "ready", filePath: finalPath };
this.items.set(this.key(sessionId, attachmentId), item);
return this.public(item);
}
get(sessionId: string, id: string): ImageAttachment | undefined { const item = this.items.get(this.key(sessionId, id)); return item ? this.public(item) : undefined; }
getStored(sessionId: string, id: string): StoredAttachment | undefined { return this.items.get(this.key(sessionId, id)); }
async readBase64(sessionId: string, id: string): Promise<string> { const item = this.getStored(sessionId, id); if (!item) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found", 404); return (await readFile(item.filePath)).toString("base64"); }
async remove(sessionId: string, id: string): Promise<void> { const key = this.key(sessionId, id); const item = this.items.get(key); if (!item) return; await unlink(item.filePath).catch(() => undefined); this.items.delete(key); }
async removeAll(sessionId: string): Promise<void> { for (const [key] of this.items) if (key.startsWith(`${sessionId}:`)) this.items.delete(key); await rm(path.join(this.sessionsRoot, sessionId, "attachments"), { recursive: true, force: true }); }
private public(item: StoredAttachment): ImageAttachment { return { attachmentId: item.attachmentId, sessionId: item.sessionId, name: item.name, mediaType: item.mediaType, byteSize: item.byteSize, width: item.width, height: item.height, status: item.status }; }
}
+127
View File
@@ -0,0 +1,127 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import type { ModelConnectionInput } from "@agent-studio/shared";
import { HarnessError } from "./errors.js";
const encryptedValueSchema = z.object({
iv: z.string().min(1),
tag: z.string().min(1),
ciphertext: z.string().min(1),
}).strict();
const vaultDocumentSchema = z.object({
version: z.literal(1),
entries: z.record(z.string(), encryptedValueSchema),
}).strict();
type VaultDocument = z.infer<typeof vaultDocumentSchema>;
export interface CredentialVault {
get(identity: string): Promise<string | undefined>;
set(identity: string, apiKey: string): Promise<void>;
delete(identity: string): Promise<void>;
}
export function modelCredentialIdentity(input: ModelConnectionInput): string {
const stable = JSON.stringify({
providerId: input.providerId,
api: input.api,
baseUrl: input.baseUrl ?? null,
modelId: input.modelId,
});
return createHash("sha256").update(stable).digest("base64url");
}
export class EncryptedFileCredentialVault implements CredentialVault {
private readonly key: Buffer;
private document: VaultDocument = { version: 1, entries: {} };
private loaded: Promise<void> | undefined;
private mutation: Promise<void> = Promise.resolve();
constructor(private readonly filePath: string, encodedMasterKey: string) {
this.key = Buffer.from(encodedMasterKey.trim(), "base64");
if (this.key.byteLength !== 32) {
throw new HarnessError("INVALID_CREDENTIAL_ENCRYPTION_KEY", "MODEL_CREDENTIAL_ENCRYPTION_KEY must be a base64-encoded 32-byte key", 500);
}
}
async get(identity: string): Promise<string | undefined> {
await this.mutation;
await this.ensureLoaded();
const value = this.document.entries[identity];
if (!value) return undefined;
try {
const decipher = createDecipheriv("aes-256-gcm", this.key, Buffer.from(value.iv, "base64"));
decipher.setAAD(this.aad(identity));
decipher.setAuthTag(Buffer.from(value.tag, "base64"));
return Buffer.concat([decipher.update(Buffer.from(value.ciphertext, "base64")), decipher.final()]).toString("utf8");
} catch {
throw new HarnessError("CREDENTIAL_DECRYPTION_FAILED", "Stored model credential could not be decrypted with the configured master key", 500);
}
}
set(identity: string, apiKey: string): Promise<void> {
return this.enqueue(async () => {
await this.ensureLoaded();
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
cipher.setAAD(this.aad(identity));
const ciphertext = Buffer.concat([cipher.update(apiKey, "utf8"), cipher.final()]);
this.document.entries[identity] = {
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ciphertext: ciphertext.toString("base64"),
};
await this.persist();
});
}
delete(identity: string): Promise<void> {
return this.enqueue(async () => {
await this.ensureLoaded();
if (!(identity in this.document.entries)) return;
delete this.document.entries[identity];
await this.persist();
});
}
private ensureLoaded(): Promise<void> {
if (!this.loaded) this.loaded = this.load();
return this.loaded;
}
private async load(): Promise<void> {
try {
const raw = await readFile(this.filePath, "utf8");
this.document = vaultDocumentSchema.parse(JSON.parse(raw));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
if (error instanceof HarnessError) throw error;
throw new HarnessError("CREDENTIAL_STORE_CORRUPT", "Encrypted model credential store is invalid", 500);
}
}
private enqueue(operation: () => Promise<void>): Promise<void> {
const next = this.mutation.then(operation, operation);
this.mutation = next.catch(() => undefined);
return next;
}
private async persist(): Promise<void> {
const directory = path.dirname(this.filePath);
await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, `${JSON.stringify(this.document)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
await rename(temporary, this.filePath);
} finally {
await rm(temporary, { force: true }).catch(() => undefined);
}
}
private aad(identity: string): Buffer {
return Buffer.from(`agent-studio-mini:model-credential:v1:${identity}`, "utf8");
}
}
+31
View File
@@ -0,0 +1,31 @@
export class HarnessError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly statusCode = 400,
public readonly details: Record<string, unknown> = {},
) {
super(message);
this.name = "HarnessError";
}
}
export function safeErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "Unexpected error";
return error.message
.replace(/(?:sk|pk|key)-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
.replace(/[A-Za-z0-9+/]{200,}={0,2}/g, "[REDACTED_DATA]");
}
const sensitive = /api[-_]?key|authorization|token|secret|password|credential/i;
export function redactValue(value: unknown, depth = 0): unknown {
if (depth > 6) return "[TRUNCATED]";
if (typeof value === "string") return value.length > 2_000 ? `${value.slice(0, 2_000)}` : safeErrorMessage(new Error(value));
if (Array.isArray(value)) return value.slice(0, 50).map((item) => redactValue(item, depth + 1));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).slice(0, 50).map(([key, item]) => [key, sensitive.test(key) ? "[REDACTED]" : redactValue(item, depth + 1)]));
}
return value;
}
+65
View File
@@ -0,0 +1,65 @@
import { randomUUID } from "node:crypto";
import type { EventInput, HarnessEvent, HarnessEventType } from "@agent-studio/shared";
export interface EventCursor { sequence?: number; eventId?: string }
export interface EventBatch { events: HarnessEvent[]; gap?: { requestedSequence: number; oldestAvailableSequence: number } }
export interface EventStore {
append<T extends HarnessEventType>(sessionId: string, event: EventInput<T>): Extract<HarnessEvent, { type: T }>;
listAfter(sessionId: string, cursor?: EventCursor): EventBatch;
subscribe(sessionId: string, listener: (event: HarnessEvent) => void): () => void;
clear(sessionId: string): void;
}
interface Bucket { sequence: number; events: HarnessEvent[]; listeners: Set<(event: HarnessEvent) => void> }
export class InMemoryEventStore implements EventStore {
private readonly buckets = new Map<string, Bucket>();
constructor(private readonly limit = 1_000) {}
private bucket(sessionId: string): Bucket {
let bucket = this.buckets.get(sessionId);
if (!bucket) {
bucket = { sequence: 0, events: [], listeners: new Set() };
this.buckets.set(sessionId, bucket);
}
return bucket;
}
append<T extends HarnessEventType>(sessionId: string, input: EventInput<T>): Extract<HarnessEvent, { type: T }> {
const bucket = this.bucket(sessionId);
const value = {
...input,
eventId: `evt_${randomUUID()}`,
sessionId,
timestamp: new Date().toISOString(),
sequence: ++bucket.sequence,
} as Extract<HarnessEvent, { type: T }>;
bucket.events.push(value);
if (bucket.events.length > this.limit) bucket.events.splice(0, bucket.events.length - this.limit);
for (const listener of bucket.listeners) listener(value);
return value;
}
listAfter(sessionId: string, cursor: EventCursor = {}): EventBatch {
const bucket = this.bucket(sessionId);
let requested = cursor.sequence ?? 0;
if (cursor.eventId) {
const found = bucket.events.find((item) => item.eventId === cursor.eventId);
if (found) requested = found.sequence;
}
const oldest = bucket.events[0]?.sequence;
if (oldest !== undefined && requested > 0 && requested < oldest - 1) {
return { events: bucket.events.slice(), gap: { requestedSequence: requested, oldestAvailableSequence: oldest } };
}
return { events: bucket.events.filter((item) => item.sequence > requested) };
}
subscribe(sessionId: string, listener: (event: HarnessEvent) => void): () => void {
const listeners = this.bucket(sessionId).listeners;
listeners.add(listener);
return () => listeners.delete(listener);
}
clear(sessionId: string): void { this.buckets.delete(sessionId); }
}
+11
View File
@@ -0,0 +1,11 @@
export * from "./approval.js";
export * from "./attachments.js";
export * from "./credential-vault.js";
export * from "./errors.js";
export * from "./event-store.js";
export * from "./model-store.js";
export * from "./pi-event-adapter.js";
export * from "./safe-workspace.js";
export * from "./session.js";
export * from "./skills.js";
export * from "./tools.js";
+178
View File
@@ -0,0 +1,178 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import type { Model } from "@earendil-works/pi-ai";
import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { modelConnectionInputSchema, type ModelConnectionInput, type RedactedModelConnection } from "@agent-studio/shared";
import { HarnessError } from "./errors.js";
import { modelCredentialIdentity, type CredentialVault } from "./credential-vault.js";
interface Entry { input: ModelConnectionInput; apiKey?: string; runtime?: ModelRuntime; credentialStorage?: "memory" | "encrypted" | "environment" }
export interface ResolvedPiModelRuntime { runtime: ModelRuntime; model: Model<string>; supportsImages: boolean }
export interface ModelConnectionStore {
configure(sessionId: string, input: ModelConnectionInput): Promise<RedactedModelConnection>;
getRedacted(sessionId: string): RedactedModelConnection;
clearCredentials(sessionId: string): Promise<void>;
createRuntime(sessionId: string): Promise<ResolvedPiModelRuntime>;
remove(sessionId: string): Promise<void>;
}
const metadataHosts = new Set(["169.254.169.254", "100.100.100.200", "metadata.google.internal", "metadata.azure.internal"]);
function restrictedIp(address: string): boolean {
if (!isIP(address)) return true;
if (address === "::1" || address === "0.0.0.0" || address.startsWith("fe80:") || address.startsWith("fc") || address.startsWith("fd")) return true;
const parts = address.split(".").map(Number);
if (parts.length === 4) return parts[0] === 10 || parts[0] === 127 || parts[0] === 0 || (parts[0] === 169 && parts[1] === 254) || (parts[0] === 172 && (parts[1] ?? 0) >= 16 && (parts[1] ?? 0) <= 31) || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 100 && (parts[1] ?? 0) >= 64 && (parts[1] ?? 0) <= 127);
return false;
}
export async function validateBaseUrl(input: string, allowPrivate = false, development = process.env.NODE_ENV !== "production"): Promise<string> {
let url: URL;
try { url = new URL(input); } catch { throw new HarnessError("INVALID_BASE_URL", "Base URL is not a valid URL", 400); }
if (url.username || url.password || url.hash) throw new HarnessError("INVALID_BASE_URL", "Base URL cannot contain credentials or a fragment", 400);
const hostname = url.hostname.replace(/^\[|\]$/g, "");
const developmentLoopback = development && url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(hostname);
if (url.protocol !== "https:" && !developmentLoopback) throw new HarnessError("UNSAFE_BASE_URL", "Base URL must use HTTPS (loopback HTTP is allowed only in development)", 400);
if (metadataHosts.has(hostname.toLowerCase())) throw new HarnessError("SSRF_BLOCKED", "Cloud metadata endpoints are not allowed", 400);
for (const key of url.searchParams.keys()) if (/key|token|secret|auth|password/i.test(key)) throw new HarnessError("CREDENTIAL_IN_URL", "Base URL must not contain credential query parameters", 400);
const addresses = isIP(hostname) ? [{ address: hostname }] : await lookup(hostname, { all: true, verbatim: true }).catch(() => { throw new HarnessError("BASE_URL_DNS_FAILED", "Base URL hostname could not be resolved", 400); });
if (!allowPrivate && !developmentLoopback && addresses.some(({ address }) => restrictedIp(address))) throw new HarnessError("SSRF_BLOCKED", "Base URL resolves to a private, loopback, or link-local address", 400);
return url.toString().replace(/\/$/, "");
}
export class InMemoryModelConnectionStore implements ModelConnectionStore {
private readonly entries = new Map<string, Entry>();
private environmentEntry: Entry | undefined;
constructor(private readonly env: NodeJS.ProcessEnv = process.env, private readonly vault?: CredentialVault) {}
async initialize(): Promise<void> {
this.environmentEntry = await this.resolveEnvironmentInput();
}
async configure(sessionId: string, raw: ModelConnectionInput): Promise<RedactedModelConnection> {
const input = modelConnectionInputSchema.parse(raw);
const baseUrl = input.baseUrl ? await validateBaseUrl(input.baseUrl, this.env.ALLOW_PRIVATE_LLM_BASE_URLS === "true") : undefined;
const previous = this.entries.get(sessionId);
if (previous?.runtime) await previous.runtime.removeRuntimeApiKey(previous.input.providerId).catch(() => undefined);
const normalized: ModelConnectionInput = { ...input, ...(baseUrl ? { baseUrl } : {}) };
const identity = modelCredentialIdentity(normalized);
let apiKey = input.apiKey;
let credentialStorage: Entry["credentialStorage"] = apiKey ? this.vault ? "encrypted" : "memory" : undefined;
if (apiKey && this.vault) await this.vault.set(identity, apiKey);
if (!apiKey && previous?.apiKey && modelCredentialIdentity(previous.input) === identity) {
apiKey = previous.apiKey;
credentialStorage = previous.credentialStorage;
}
if (!apiKey && this.vault) {
apiKey = await this.vault.get(identity);
if (apiKey) credentialStorage = "encrypted";
}
const entry: Entry = { input: normalized };
if (apiKey) entry.apiKey = apiKey;
if (credentialStorage) entry.credentialStorage = credentialStorage;
this.entries.set(sessionId, entry);
return this.redact(entry, "session");
}
getRedacted(sessionId: string): RedactedModelConnection {
const entry = this.entries.get(sessionId);
if (entry) return this.redact(entry, "session");
return this.environmentEntry ? this.redact(this.environmentEntry, "environment") : { configured: false, source: "missing", keyConfigured: false };
}
async clearCredentials(sessionId: string): Promise<void> {
let entry = this.entries.get(sessionId);
if (!entry && this.environmentEntry) {
entry = this.cloneEntry(this.environmentEntry);
this.entries.set(sessionId, entry);
}
if (!entry) return;
if (entry.runtime) await entry.runtime.removeRuntimeApiKey(entry.input.providerId).catch(() => undefined);
if (this.vault) await this.vault.delete(modelCredentialIdentity(entry.input));
delete entry.apiKey;
delete entry.credentialStorage;
}
async createRuntime(sessionId: string): Promise<ResolvedPiModelRuntime> {
let entry = this.entries.get(sessionId);
if (!entry && this.environmentEntry) {
entry = this.cloneEntry(this.environmentEntry);
this.entries.set(sessionId, entry);
}
if (!entry) throw new HarnessError("MODEL_NOT_CONFIGURED", "No model connection is configured", 422);
if (!entry.apiKey) throw new HarnessError("MODEL_CREDENTIAL_NOT_CONFIGURED", "The selected provider has no API key", 422);
const checkedBaseUrl = entry.input.baseUrl ? await validateBaseUrl(entry.input.baseUrl, this.env.ALLOW_PRIVATE_LLM_BASE_URLS === "true") : undefined;
const credentials = new InMemoryCredentialStore();
const runtime = await ModelRuntime.create({ credentials, modelsPath: null, refreshOnCreate: false, allowModelNetwork: false, signal: AbortSignal.timeout(10_000), modelRefreshTimeoutMs: 10_000 });
const defaultBaseUrl = checkedBaseUrl ?? (entry.input.api === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1");
runtime.registerProvider(entry.input.providerId, {
name: entry.input.displayName ?? entry.input.providerId,
baseUrl: defaultBaseUrl,
api: entry.input.api,
models: [{ id: entry.input.modelId, name: entry.input.displayName ?? entry.input.modelId, api: entry.input.api, baseUrl: defaultBaseUrl, reasoning: false, input: entry.input.supportsImages ? ["text", "image"] : ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: entry.input.contextWindow ?? 128_000, maxTokens: entry.input.maxTokens ?? 8_192 }],
});
await runtime.setRuntimeApiKey(entry.input.providerId, entry.apiKey);
const model = runtime.getModel(entry.input.providerId, entry.input.modelId);
if (!model || model.api !== entry.input.api) throw new HarnessError("MODEL_RESOLUTION_FAILED", "Configured model could not be resolved exactly", 422);
entry.runtime = runtime;
return { runtime, model, supportsImages: model.input.includes("image") };
}
async remove(sessionId: string): Promise<void> {
const entry = this.entries.get(sessionId);
if (entry?.runtime) await entry.runtime.removeRuntimeApiKey(entry.input.providerId).catch(() => undefined);
this.entries.delete(sessionId);
}
private async resolveEnvironmentInput(): Promise<Entry | undefined> {
const providerId = this.env.AGENT_PROVIDER;
const modelId = this.env.AGENT_MODEL;
if (!providerId && !modelId) return undefined;
if (!providerId || !modelId) throw new HarnessError("INVALID_ENV_MODEL_CONFIG", "AGENT_PROVIDER and AGENT_MODEL must be configured together", 500);
try {
const input = modelConnectionInputSchema.parse({
providerId,
modelId,
api: this.env.LLM_API ?? "openai-responses",
supportsImages: parseEnvironmentBoolean(this.env.AGENT_SUPPORTS_IMAGES, false, "AGENT_SUPPORTS_IMAGES"),
...(this.env.LLM_BASE_URL ? { baseUrl: this.env.LLM_BASE_URL } : {}),
...(this.env.AGENT_DISPLAY_NAME ? { displayName: this.env.AGENT_DISPLAY_NAME } : {}),
...(this.env.AGENT_CONTEXT_WINDOW ? { contextWindow: parseEnvironmentInteger(this.env.AGENT_CONTEXT_WINDOW, "AGENT_CONTEXT_WINDOW") } : {}),
...(this.env.AGENT_MAX_TOKENS ? { maxTokens: parseEnvironmentInteger(this.env.AGENT_MAX_TOKENS, "AGENT_MAX_TOKENS") } : {}),
});
const baseUrl = input.baseUrl ? await validateBaseUrl(input.baseUrl, this.env.ALLOW_PRIVATE_LLM_BASE_URLS === "true") : undefined;
const normalized: ModelConnectionInput = { ...input, ...(baseUrl ? { baseUrl } : {}) };
let apiKey = input.api === "anthropic-messages" ? this.env.ANTHROPIC_API_KEY : this.env.OPENAI_API_KEY;
let credentialStorage: Entry["credentialStorage"] = apiKey ? "environment" : undefined;
if (!apiKey && this.vault) {
apiKey = await this.vault.get(modelCredentialIdentity(normalized));
if (apiKey) credentialStorage = "encrypted";
}
const entry: Entry = { input: normalized };
if (apiKey) entry.apiKey = apiKey;
if (credentialStorage) entry.credentialStorage = credentialStorage;
return entry;
} catch (error) {
if (error instanceof HarnessError) throw error;
throw new HarnessError("INVALID_ENV_MODEL_CONFIG", "Environment model configuration is invalid", 500);
}
}
private cloneEntry(entry: Entry): Entry {
return { input: { ...entry.input }, ...(entry.apiKey ? { apiKey: entry.apiKey } : {}), ...(entry.credentialStorage ? { credentialStorage: entry.credentialStorage } : {}) };
}
private redact(entry: Entry, source: "session" | "environment"): RedactedModelConnection {
const { input, apiKey } = entry;
return { configured: true, source, providerId: input.providerId, api: input.api, modelId: input.modelId, supportsImages: input.supportsImages, keyConfigured: Boolean(apiKey), ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}), ...(input.displayName ? { displayName: input.displayName } : {}), ...(apiKey ? { keyHint: `••••${apiKey.slice(-4)}` } : {}), ...(entry.credentialStorage ? { credentialStorage: entry.credentialStorage } : {}) };
}
}
function parseEnvironmentBoolean(value: string | undefined, fallback: boolean, name: string): boolean {
if (value === undefined || value === "") return fallback;
if (value === "true") return true;
if (value === "false") return false;
throw new HarnessError("INVALID_ENV_MODEL_CONFIG", `${name} must be true or false`, 500);
}
function parseEnvironmentInteger(value: string, name: string): number {
if (!/^\d+$/.test(value)) throw new HarnessError("INVALID_ENV_MODEL_CONFIG", `${name} must be a positive integer`, 500);
return Number(value);
}
+30
View File
@@ -0,0 +1,30 @@
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import type { EventInput, HarnessEventType } from "@agent-studio/shared";
import { redactValue } from "./errors.js";
export interface PiAdapterContext { runId: string; assistantMessageId: string }
export type MappedEvent = { [T in HarnessEventType]: EventInput<T> }[HarnessEventType];
export function mapPiEvent(event: AgentSessionEvent, context: PiAdapterContext): MappedEvent[] {
switch (event.type) {
case "message_update":
if (event.assistantMessageEvent.type === "text_delta") return [{ type: "assistant.delta", runId: context.runId, payload: { messageId: context.assistantMessageId, delta: event.assistantMessageEvent.delta } }];
return [];
case "message_end":
if (event.message.role === "assistant") return [{ type: "assistant.completed", runId: context.runId, payload: { messageId: context.assistantMessageId } }];
return [];
case "tool_execution_start":
return [{ type: "tool.requested", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, arguments: redactValue(event.args) } }];
case "tool_execution_end":
return event.isError
? [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: "Tool execution failed" } }]
: [{ type: "tool.completed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, result: summarizeToolResult(event.toolName, event.result) } }];
default:
return [];
}
}
function summarizeToolResult(toolName: string, result: unknown): unknown {
if (["read", "grep", "find", "ls"].includes(toolName)) return { status: "success" };
return redactValue(result);
}
+58
View File
@@ -0,0 +1,58 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
import path from "node:path";
import { HarnessError } from "./errors.js";
export class SafeWorkspace {
private constructor(public readonly root: string) {}
static async create(root: string): Promise<SafeWorkspace> {
await mkdir(root, { recursive: true, mode: 0o700 });
return new SafeWorkspace(await realpath(root));
}
private contains(candidate: string): boolean {
const relative = path.relative(this.root, candidate);
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
private async assertParentsNoSymlink(candidate: string): Promise<void> {
const relative = path.relative(this.root, path.dirname(candidate));
let current = this.root;
for (const segment of relative.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
const stat = await lstat(current);
if (stat.isSymbolicLink()) throw new HarnessError("PATH_SYMLINK_DENIED", "Symbolic links are not allowed", 403);
}
}
async resolveExisting(input: string): Promise<string> {
const absolute = path.resolve(this.root, input);
if (!this.contains(absolute)) throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Path is outside the session workspace", 403);
const canonical = await realpath(absolute);
if (!this.contains(canonical)) throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Path resolves outside the session workspace", 403);
const stat = await lstat(absolute);
if (stat.isSymbolicLink()) throw new HarnessError("PATH_SYMLINK_DENIED", "Symbolic links are not allowed", 403);
return canonical;
}
async assertReadPath(input: string): Promise<void> {
if (/(^|[/\\])(?:\.env(?:\.|$)|\.git|\.ssh)([/\\]|$)/i.test(input)) throw new HarnessError("PROTECTED_PATH", "Protected path is not readable", 403);
await this.resolveExisting(input);
}
async writeReport(content: string, title?: string): Promise<{ path: "report.md"; byteSize: number }> {
const target = path.join(this.root, "report.md");
if (!this.contains(target)) throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Unsafe report path", 403);
await this.assertParentsNoSymlink(target);
try { if ((await lstat(target)).isSymbolicLink()) throw new HarnessError("PATH_SYMLINK_DENIED", "Report path is a symbolic link", 403); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
const body = title ? `# ${title}\n\n${content}` : content;
const temporary = path.join(this.root, `.report-${randomUUID()}.tmp`);
const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
try { await handle.writeFile(body, "utf8"); await handle.sync(); } finally { await handle.close(); }
try { await rename(temporary, target); } catch (error) { await unlink(temporary).catch(() => undefined); throw error; }
await access(target, constants.R_OK);
return { path: "report.md", byteSize: Buffer.byteLength(body) };
}
}
+303
View File
@@ -0,0 +1,303 @@
import { randomUUID } from "node:crypto";
import { mkdir, realpath, rm } from "node:fs/promises";
import path from "node:path";
import type { ImageContent } from "@earendil-works/pi-ai";
import {
SessionManager,
SettingsManager,
createAgentSession,
isToolCallEventType,
type AgentSession,
type AgentSessionEvent,
type InlineExtension,
} from "@earendil-works/pi-coding-agent";
import type { ModelConnectionInput, SendMessageInput } from "@agent-studio/shared";
import { InMemoryApprovalBroker, type ApprovalBroker } from "./approval.js";
import type { FileAttachmentStore } from "./attachments.js";
import { HarnessError, redactValue, safeErrorMessage } from "./errors.js";
import type { EventStore } from "./event-store.js";
import type { ModelConnectionStore } from "./model-store.js";
import { mapPiEvent } from "./pi-event-adapter.js";
import { SafeWorkspace } from "./safe-workspace.js";
import type { SkillCatalog } from "./skills.js";
import type { SkillIndexEntry } from "./skills.js";
import { createMockAdsMetricsTool, createSaveReportDraftTool } from "./tools.js";
export interface AgentSessionPort {
readonly modelId: string;
readonly supportsImages: boolean;
prompt(text: string, options?: { images?: unknown[] }): Promise<void>;
abort(): Promise<void>;
subscribe(listener: (event: unknown) => void): () => void;
dispose(): void;
}
export interface AgentSessionFactoryInput {
sessionId: string;
cwd: string;
workspace: SafeWorkspace;
getRun: () => { runId: string; signal: AbortSignal; assistantMessageId: string } | undefined;
skillRequested: (skill: SkillIndexEntry, toolCallId: string) => void;
skillLoaded: (skill: SkillIndexEntry, toolCallId: string) => void;
toolRequested: (toolCallId: string, toolName: string, argumentsValue: unknown) => void;
}
export interface AgentSessionFactory { create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> }
class PiAgentSessionPort implements AgentSessionPort {
readonly modelId: string;
readonly supportsImages: boolean;
constructor(private readonly session: AgentSession) {
if (!session.model) throw new Error("Pi AgentSession was created without an explicit model");
this.modelId = session.model.id;
this.supportsImages = session.model.input.includes("image");
}
async prompt(text: string, options?: { images?: unknown[] }): Promise<void> {
const images = options?.images as ImageContent[] | undefined;
await this.session.prompt(text, images ? { images } : undefined);
}
abort(): Promise<void> { return this.session.abort(); }
subscribe(listener: (event: unknown) => void): () => void { return this.session.subscribe(listener); }
dispose(): void { this.session.dispose(); }
}
export class PiAgentSessionFactory implements AgentSessionFactory {
constructor(
private readonly fixturePath: string,
private readonly catalog: SkillCatalog,
private readonly models: ModelConnectionStore,
private readonly approvals: ApprovalBroker,
) {}
async create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> {
const resolved = await this.models.createRuntime(input.sessionId);
const extension: InlineExtension = { name: "agent-studio-safety", hidden: true, factory: (pi) => {
const pendingSkills = new Map<string, SkillIndexEntry>();
pi.on("tool_call", async (event) => {
const run = input.getRun();
if (!run) return { block: true, reason: "No active Harness run" };
const known = new Set(["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"]);
if (!known.has(event.toolName)) {
input.toolRequested(event.toolCallId, event.toolName, publicToolArguments(event.input, input.cwd));
return { block: true, reason: "Tool is not allowed by the Harness policy", terminate: true };
}
let matchedSkill: SkillIndexEntry | undefined;
if (isToolCallEventType("read", event)) {
const skill = await this.catalog.matchReadPath(event.input.path, input.cwd);
if (skill) {
matchedSkill = skill;
pendingSkills.set(event.toolCallId, skill);
input.skillRequested(skill, event.toolCallId);
} else await input.workspace.assertReadPath(event.input.path);
} else if (["grep", "find", "ls"].includes(event.toolName)) {
const maybePath = (event.input as Record<string, unknown>).path;
if (typeof maybePath === "string") await input.workspace.assertReadPath(maybePath);
}
input.toolRequested(event.toolCallId, event.toolName, publicToolArguments(event.input, input.cwd, matchedSkill));
if (event.toolName === "save_report_draft") {
const decision = await this.approvals.request({ sessionId: input.sessionId, runId: run.runId, toolCallId: event.toolCallId, toolName: event.toolName, arguments: event.input, riskLevel: "medium" }, run.signal);
if (decision.status !== "approved") return { block: true, reason: `Human approval ${decision.status}` };
}
return undefined;
});
pi.on("tool_result", async (event) => {
const run = input.getRun();
const skill = pendingSkills.get(event.toolCallId);
if (run && skill && !event.isError && event.toolName === "read") {
input.skillLoaded(skill, event.toolCallId);
pendingSkills.delete(event.toolCallId);
}
});
} };
const loader = await this.catalog.createSessionLoader(input.cwd, path.join(input.cwd, ".pi-agent"), [extension]);
const { session } = await createAgentSession({
cwd: input.cwd,
agentDir: path.join(input.cwd, ".pi-agent"),
modelRuntime: resolved.runtime,
model: resolved.model,
tools: ["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"],
customTools: [createMockAdsMetricsTool(this.fixturePath), createSaveReportDraftTool(input.workspace)],
resourceLoader: loader,
sessionManager: SessionManager.inMemory(input.cwd),
settingsManager: SettingsManager.inMemory(),
});
return new PiAgentSessionPort(session);
}
}
export interface HarnessSession {
sessionId: string;
cwd: string;
workspace: SafeWorkspace;
busy: boolean;
currentRun: { runId: string; controller: AbortController; assistantMessageId: string; cancelReason?: "user" | "timeout" | "session_deleted" | "shutdown" } | undefined;
}
export interface CreateSessionInput { id?: string }
export interface SessionRegistry {
create(input?: CreateSessionInput): Promise<HarnessSession>;
get(sessionId: string): HarnessSession | undefined;
delete(sessionId: string): Promise<void>;
}
interface InternalSession extends HarnessSession { agent: AgentSessionPort | undefined; unsubscribe: (() => void) | undefined; timeout: ReturnType<typeof setTimeout> | undefined; disposed: boolean; requestedToolCalls: Set<string> }
export class InMemorySessionRegistry implements SessionRegistry {
private readonly sessions = new Map<string, InternalSession>();
readonly approvals: ApprovalBroker;
constructor(
private readonly sessionsRoot: string,
private readonly events: EventStore,
private readonly attachments: FileAttachmentStore,
private readonly models: ModelConnectionStore,
private readonly catalog: SkillCatalog,
private factory: AgentSessionFactory | undefined,
approvalTimeoutMs = 60_000,
private readonly runTimeoutMs = 120_000,
) {
this.approvals = new InMemoryApprovalBroker(approvalTimeoutMs, (resolution) => {
this.events.append(resolution.request.sessionId, { type: "approval.resolved", runId: resolution.request.runId, payload: { approvalId: resolution.request.approvalId, toolCallId: resolution.request.toolCallId, status: resolution.decision.status, resolvedAt: resolution.decision.resolvedAt } });
}, (request) => {
this.events.append(request.sessionId, { type: "approval.required", runId: request.runId, payload: request });
});
}
setFactory(factory: AgentSessionFactory): void { this.factory = factory; }
async create(input: CreateSessionInput = {}): Promise<HarnessSession> {
const sessionId = input.id ?? `ses_${randomUUID()}`;
const cwd = path.join(this.sessionsRoot, sessionId);
await mkdir(cwd, { recursive: false, mode: 0o700 });
const workspace = await SafeWorkspace.create(cwd);
const session: InternalSession = { sessionId, cwd: workspace.root, workspace, busy: false, currentRun: undefined, agent: undefined, unsubscribe: undefined, timeout: undefined, disposed: false, requestedToolCalls: new Set() };
this.sessions.set(sessionId, session);
this.events.append(sessionId, { type: "session.started", payload: { cwd: `workspace/sessions/${sessionId}` } });
return session;
}
get(id: string): HarnessSession | undefined { return this.sessions.get(id); }
private internal(id: string): InternalSession { const session = this.sessions.get(id); if (!session) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); return session; }
async configureModel(id: string, input: ModelConnectionInput) {
const session = this.internal(id);
if (session.busy) throw new HarnessError("SESSION_BUSY", "Cannot change model configuration while a run is active", 409);
await this.disposeAgent(session);
const value = await this.models.configure(id, input);
this.events.append(id, { type: "model.configured", payload: { providerId: input.providerId, modelId: input.modelId, api: input.api, supportsImages: input.supportsImages } });
return value;
}
async clearCredentials(id: string): Promise<void> { const session = this.internal(id); if (session.busy) throw new HarnessError("SESSION_BUSY", "Cannot clear credentials while a run is active", 409); await this.disposeAgent(session); await this.models.clearCredentials(id); }
async send(id: string, message: SendMessageInput): Promise<{ runId: string }> {
const session = this.internal(id);
if (session.busy) throw new HarnessError("SESSION_BUSY", "A run is already active for this session", 409);
const configuredModel = this.models.getRedacted(id);
if (!configuredModel.configured) throw new HarnessError("MODEL_NOT_CONFIGURED", "No model connection is configured", 422);
if (!configuredModel.keyConfigured) throw new HarnessError("MODEL_CREDENTIAL_NOT_CONFIGURED", "The selected provider has no API key", 422);
const maxImages = Number(process.env.MAX_IMAGES_PER_MESSAGE ?? 4);
if (message.attachmentIds.length > maxImages) throw new HarnessError("TOO_MANY_ATTACHMENTS", `A message can include at most ${maxImages} images`, 413);
const selected = message.attachmentIds.map((attachmentId) => {
const item = this.attachments.getStored(id, attachmentId);
if (!item) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found for this session", 404);
return item;
});
const runId = `run_${randomUUID()}`;
const assistantMessageId = `msg_${randomUUID()}`;
const controller = new AbortController();
session.currentRun = { runId, controller, assistantMessageId };
session.busy = true;
try { await this.ensureAgent(session); } catch (error) { session.busy = false; session.currentRun = undefined; this.events.append(id, { type: "run.failed", runId, payload: { code: "AGENT_SESSION_CREATE_FAILED", message: safeErrorMessage(error) } }); throw error; }
if (selected.length > 0 && !session.agent?.supportsImages) {
session.busy = false;
session.currentRun = undefined;
throw new HarnessError("MODEL_DOES_NOT_SUPPORT_IMAGES", "The selected model does not support image input", 422);
}
const agent = session.agent;
if (!agent) throw new HarnessError("AGENT_SESSION_CREATE_FAILED", "Agent session was not created", 500);
this.events.append(id, { type: "user.message", runId, payload: { messageId: `msg_${randomUUID()}`, text: message.text, attachmentIds: message.attachmentIds } });
this.events.append(id, { type: "run.started", runId, payload: { modelId: agent.modelId } });
const images: ImageContent[] = await Promise.all(selected.map(async (item) => ({ type: "image" as const, mimeType: item.mediaType, data: await this.attachments.readBase64(id, item.attachmentId) })));
session.timeout = setTimeout(() => void this.cancel(id, "timeout"), this.runTimeoutMs);
void this.executeRun(session, message.text, images);
return { runId };
}
private async ensureAgent(session: InternalSession): Promise<void> {
if (session.agent) return;
if (!this.factory) throw new Error("Agent session factory is not configured");
session.agent = await this.factory.create({
sessionId: session.sessionId,
cwd: session.cwd,
workspace: session.workspace,
getRun: () => session.currentRun ? { runId: session.currentRun.runId, signal: session.currentRun.controller.signal, assistantMessageId: session.currentRun.assistantMessageId } : undefined,
skillRequested: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.requested", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId } }); },
skillLoaded: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.loaded", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId } }); },
toolRequested: (toolCallId, toolName, argumentsValue) => { const run = session.currentRun; if (run && !session.requestedToolCalls.has(toolCallId)) { session.requestedToolCalls.add(toolCallId); this.events.append(session.sessionId, { type: "tool.requested", runId: run.runId, payload: { toolCallId, toolName, arguments: redactValue(argumentsValue) } }); } },
});
session.unsubscribe = session.agent.subscribe((event) => this.onPiEvent(session, event as AgentSessionEvent));
}
private onPiEvent(session: InternalSession, event: AgentSessionEvent): void {
const run = session.currentRun;
if (!run) return;
// The blocking Extension preflight is the single source for tool.requested.
// Pi's later execution-start event has the same toolCallId and would create
// duplicate assistant-ui Tool parts (and can expose pre-normalized paths).
if (event.type === "tool_execution_start") return;
for (const mapped of mapPiEvent(event, { runId: run.runId, assistantMessageId: run.assistantMessageId })) this.events.append(session.sessionId, mapped);
}
private async executeRun(session: InternalSession, text: string, images: ImageContent[]): Promise<void> {
const run = session.currentRun;
if (!run || !session.agent) return;
try {
await session.agent.prompt(text, images.length ? { images } : undefined);
if (!run.controller.signal.aborted) this.events.append(session.sessionId, { type: "run.completed", runId: run.runId, payload: {} });
} catch (error) {
if (!run.controller.signal.aborted) this.events.append(session.sessionId, { type: "run.failed", runId: run.runId, payload: { code: "AGENT_RUN_FAILED", message: safeErrorMessage(error) } });
} finally {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = undefined;
session.busy = false;
if (session.currentRun?.runId === run.runId) session.currentRun = undefined;
}
}
async cancel(id: string, reason: "user" | "timeout" | "session_deleted" | "shutdown" = "user"): Promise<void> {
const session = this.internal(id);
const run = session.currentRun;
if (!run || !session.busy || run.controller.signal.aborted) return;
run.cancelReason = reason;
run.controller.abort(reason);
this.approvals.cancelForSession(id);
await session.agent?.abort().catch(() => undefined);
this.events.append(id, { type: "run.cancelled", runId: run.runId, payload: { reason } });
}
async delete(id: string, endReason: "deleted" | "shutdown" = "deleted"): Promise<void> {
const session = this.internal(id);
if (session.busy) await this.cancel(id, endReason === "shutdown" ? "shutdown" : "session_deleted");
this.approvals.cancelForSession(id);
this.events.append(id, { type: "session.ended", payload: { reason: endReason } });
await this.disposeAgent(session);
await this.attachments.removeAll(id);
await this.models.remove(id);
const root = await realpath(this.sessionsRoot);
const target = await realpath(session.cwd);
const relative = path.relative(root, target);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new HarnessError("UNSAFE_SESSION_DELETE", "Refusing unsafe session directory deletion", 500);
await rm(target, { recursive: true, force: true });
session.disposed = true;
this.sessions.delete(id);
}
async shutdown(): Promise<void> { for (const id of [...this.sessions.keys()]) await this.delete(id, "shutdown"); }
private async disposeAgent(session: InternalSession): Promise<void> { session.unsubscribe?.(); session.unsubscribe = undefined; session.agent?.dispose(); session.agent = undefined; }
}
function publicToolArguments(value: unknown, cwd: string, skill?: SkillIndexEntry): unknown {
const redacted = redactValue(value);
if (!redacted || typeof redacted !== "object" || Array.isArray(redacted)) return redacted;
const result = { ...(redacted as Record<string, unknown>) };
for (const key of ["path", "cwd", "filePath"]) {
const candidate = result[key];
if (typeof candidate !== "string" || !path.isAbsolute(candidate)) continue;
if (skill && key === "path") result[key] = skill.filePath;
else {
const relative = path.relative(cwd, candidate);
result[key] = relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : "<protected-path>";
}
}
return result;
}
+91
View File
@@ -0,0 +1,91 @@
import { access, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { DefaultResourceLoader, SettingsManager, type InlineExtension, type ResourceDiagnostic, type Skill } from "@earendil-works/pi-coding-agent";
import type { SkillCatalogItem } from "@agent-studio/shared";
export interface SkillIndexEntry extends SkillCatalogItem { canonicalPath: string }
export class SkillCatalog {
private items: SkillIndexEntry[] = [];
private loader?: DefaultResourceLoader;
private canonicalDirectories: string[] = [];
constructor(private readonly projectRoot: string, private readonly agentDir: string, private readonly envDirs = process.env.AGENT_SKILLS_DIRS) {}
async reload(): Promise<void> {
const diagnostics: Array<{ directory: string; diagnostic: ResourceDiagnostic }> = [];
const candidates = [path.join(this.projectRoot, ".agents", "skills"), path.join(this.projectRoot, ".pi", "skills")];
if (this.envDirs) candidates.push(...this.envDirs.split(path.delimiter).map((item) => item.trim()).filter(Boolean));
const canonical = new Map<string, string>();
for (const candidate of candidates) {
try {
const resolved = await realpath(path.resolve(candidate));
if (!(await stat(resolved)).isDirectory()) throw new Error("not a directory");
await access(resolved);
canonical.set(resolved, candidate);
} catch {
diagnostics.push({ directory: candidate, diagnostic: { type: "error", message: `Skill directory is missing, unreadable, or not a directory: ${this.displayPath(candidate)}`, path: candidate } });
}
}
this.loader = new DefaultResourceLoader({
cwd: this.projectRoot,
agentDir: this.agentDir,
settingsManager: SettingsManager.inMemory(),
additionalSkillPaths: [...canonical.keys()],
noExtensions: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
this.canonicalDirectories = [...canonical.keys()];
await this.loader.reload();
const discovered = this.loader.getSkills();
const byPath = new Map<string, SkillIndexEntry>();
for (const skill of discovered.skills) {
const skillPath = await realpath(skill.filePath).catch(() => path.resolve(skill.filePath));
const related = discovered.diagnostics.filter((item) => item.path && path.resolve(item.path) === path.resolve(skill.filePath));
byPath.set(skillPath, this.toItem(skill, skillPath, related));
}
for (const { directory, diagnostic } of diagnostics) {
const key = `diagnostic:${path.resolve(directory)}`;
byPath.set(key, { name: path.basename(directory) || "unknown", description: "Skill directory diagnostic", source: "environment", filePath: this.displayPath(directory), canonicalPath: key, diagnostics: [{ severity: "error", message: diagnostic.message }] });
}
for (const diagnostic of discovered.diagnostics.filter((item) => ![...byPath.values()].some((skill) => item.path && path.resolve(item.path) === path.resolve(skill.canonicalPath)))) {
const key = `diagnostic:${diagnostic.path ?? diagnostic.message}`;
byPath.set(key, { name: diagnostic.path ? path.basename(path.dirname(diagnostic.path)) : "unknown", description: "Invalid Skill", source: "pi-loader", filePath: this.displayPath(diagnostic.path ?? "unknown"), canonicalPath: key, diagnostics: [{ severity: diagnostic.type === "warning" ? "warning" : "error", message: diagnostic.message }] });
}
this.items = [...byPath.values()].sort((a, b) => a.name.localeCompare(b.name));
}
getResourceLoader(): DefaultResourceLoader {
if (!this.loader) throw new Error("Skill catalog has not been loaded");
return this.loader;
}
async createSessionLoader(cwd: string, agentDir: string, extensionFactories: InlineExtension[]): Promise<DefaultResourceLoader> {
const loader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager: SettingsManager.inMemory(),
additionalSkillPaths: this.canonicalDirectories,
extensionFactories,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await loader.reload({ resolveProjectTrust: async () => true });
return loader;
}
list(): SkillCatalogItem[] { return this.items.map((item) => ({ name: item.name, description: item.description, source: item.source, filePath: item.filePath, diagnostics: item.diagnostics })); }
findCanonical(filePath: string): SkillIndexEntry | undefined { return this.items.find((item) => item.canonicalPath === filePath); }
async matchReadPath(inputPath: string, cwd: string): Promise<SkillIndexEntry | undefined> {
const canonical = await realpath(path.resolve(cwd, inputPath)).catch(() => undefined);
return canonical ? this.findCanonical(canonical) : undefined;
}
private toItem(skill: Skill, canonicalPath: string, diagnostics: ResourceDiagnostic[]): SkillIndexEntry {
return { name: skill.name, description: skill.description, source: skill.sourceInfo.source, filePath: this.displayPath(canonicalPath), canonicalPath, diagnostics: diagnostics.map((item) => ({ severity: item.type === "warning" ? "warning" : "error", message: item.message })) };
}
private displayPath(input: string): string {
const absolute = path.resolve(input);
const relative = path.relative(this.projectRoot, absolute);
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : `<external>/${path.basename(absolute)}`;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { readFile } from "node:fs/promises";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { SafeWorkspace } from "./safe-workspace.js";
interface CampaignInput { id: string; name: string; spend: number; impressions: number; clicks: number; conversions: number; revenue: number }
interface Fixture { accountId: string; currency: string; timeRange: { from: string; to: string }; campaigns: CampaignInput[] }
export interface CampaignMetrics extends CampaignInput { ctr: number; cpa: number; roas: number }
export interface AdsMetrics { accountId: string; days: number; currency: string; timeRange: { from: string; to: string }; spend: number; impressions: number; clicks: number; ctr: number; conversions: number; cpa: number; revenue: number; roas: number; campaigns: CampaignMetrics[] }
const round = (value: number, digits = 2): number => Number(value.toFixed(digits));
const ratio = (numerator: number, denominator: number, scale = 1): number => denominator === 0 ? 0 : round((numerator / denominator) * scale, 4);
export const mockAdsMetricsParameters = Type.Object({
accountId: Type.String({ minLength: 1, maxLength: 100, pattern: "^[a-zA-Z0-9_-]+$" }),
days: Type.Integer({ minimum: 1, maximum: 30 }),
}, { additionalProperties: false });
export async function getMockAdsMetrics(fixturePath: string, accountId: string, days: number): Promise<AdsMetrics> {
const fixture = JSON.parse(await readFile(fixturePath, "utf8")) as Fixture;
if (accountId !== fixture.accountId) throw new Error(`Unknown demo account: ${accountId}`);
const campaigns = fixture.campaigns.map((item) => ({ ...item, spend: round(item.spend), revenue: round(item.revenue), ctr: ratio(item.clicks, item.impressions, 100), cpa: ratio(item.spend, item.conversions), roas: ratio(item.revenue, item.spend) }));
const totals = campaigns.reduce((sum, item) => ({ spend: sum.spend + item.spend, impressions: sum.impressions + item.impressions, clicks: sum.clicks + item.clicks, conversions: sum.conversions + item.conversions, revenue: sum.revenue + item.revenue }), { spend: 0, impressions: 0, clicks: 0, conversions: 0, revenue: 0 });
return { accountId, days, currency: fixture.currency, timeRange: fixture.timeRange, spend: round(totals.spend), impressions: totals.impressions, clicks: totals.clicks, ctr: ratio(totals.clicks, totals.impressions, 100), conversions: totals.conversions, cpa: ratio(totals.spend, totals.conversions), revenue: round(totals.revenue), roas: ratio(totals.revenue, totals.spend), campaigns };
}
export function createMockAdsMetricsTool(fixturePath: string) {
return defineTool({
name: "mock_ads_metrics",
label: "Mock Ads Metrics",
description: "Read deterministic classroom advertising metrics for a demo account. This is not real platform data.",
promptSnippet: "Read classroom advertising metrics",
parameters: mockAdsMetricsParameters,
async execute(_toolCallId, params) {
const data = await getMockAdsMetrics(fixturePath, params.accountId, params.days);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], details: data };
},
});
}
export const saveReportDraftParameters = Type.Object({
content: Type.String({ minLength: 1, maxLength: 100_000 }),
title: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
}, { additionalProperties: false });
export function createSaveReportDraftTool(workspace: SafeWorkspace) {
return defineTool({
name: "save_report_draft",
label: "Save Report Draft",
description: "Save a report draft to the fixed report.md file in this session workspace. Requires human approval.",
promptSnippet: "Save an approved report draft",
parameters: saveReportDraftParameters,
executionMode: "sequential",
async execute(_toolCallId, params) {
const result = await workspace.writeReport(params.content, params.title);
return { content: [{ type: "text", text: `Report draft saved to ${result.path} (${result.byteSize} bytes).` }], details: result };
},
});
}
@@ -0,0 +1,17 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import sharp from "sharp";
import { FileAttachmentStore } from "../src/index.js";
async function image(format: "jpeg" | "png" | "webp") { const pipeline = sharp({ create: { width: 4, height: 3, channels: 3, background: "#25f4ee" } }); return format === "jpeg" ? pipeline.jpeg().toBuffer() : format === "png" ? pipeline.png().toBuffer() : pipeline.webp().toBuffer(); }
function upload(filename: string, mimetype: string, data: Buffer) { return { filename, mimetype, stream: (async function* () { yield data; })() }; }
describe("FileAttachmentStore", () => {
it.each([["jpeg", "photo.jpg", "image/jpeg"], ["png", "photo.png", "image/png"], ["webp", "photo.webp", "image/webp"]] as const)("normalizes valid %s", async (format, name, mime) => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("s", upload(name, mime, await image(format))); expect(item).toMatchObject({ sessionId: "s", mediaType: mime, width: 4, height: 3, status: "ready" }); });
it("rejects spoofed MIME and SVG", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); await expect(store.add("s", upload("fake.jpg", "image/jpeg", Buffer.from("not jpeg")))).rejects.toMatchObject({ code: "ATTACHMENT_MAGIC_MISMATCH" }); await expect(store.add("s", upload("x.svg", "image/svg+xml", Buffer.from("<svg/>")))).rejects.toMatchObject({ code: "ATTACHMENT_TYPE_UNSUPPORTED" }); });
it("rejects an oversized stream before decoding", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root, 10); await expect(store.add("s", upload("x.jpg", "image/jpeg", Buffer.alloc(11)))).rejects.toMatchObject({ code: "ATTACHMENT_TOO_LARGE" }); });
it("isolates attachments across sessions", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("one", upload("x.png", "image/png", await image("png"))); expect(store.get("two", item.attachmentId)).toBeUndefined(); });
it("removes all session attachment state", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("one", upload("x.png", "image/png", await image("png"))); await store.removeAll("one"); expect(store.get("one", item.attachmentId)).toBeUndefined(); });
});
+121
View File
@@ -0,0 +1,121 @@
import { mkdtemp, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
InMemoryApprovalBroker,
InMemoryEventStore,
SafeWorkspace,
SkillCatalog,
createSaveReportDraftTool,
getMockAdsMetrics,
mockAdsMetricsParameters,
redactValue,
validateBaseUrl,
} from "../src/index.js";
import { Value } from "typebox/value";
const projectRoot = path.resolve(import.meta.dirname, "../../..");
describe("InMemoryEventStore", () => {
it("allocates monotonic per-session sequences atomically", () => {
const store = new InMemoryEventStore();
const first = store.append("s", { type: "run.completed", runId: "r", payload: {} });
const second = store.append("s", { type: "run.completed", runId: "r", payload: {} });
expect([first.sequence, second.sequence]).toEqual([1, 2]);
expect(store.append("other", { type: "run.completed", runId: "r", payload: {} }).sequence).toBe(1);
});
it("replays by sequence and event id", () => {
const store = new InMemoryEventStore();
const one = store.append("s", { type: "run.completed", payload: {} });
store.append("s", { type: "run.completed", payload: {} });
expect(store.listAfter("s", { sequence: 1 }).events).toHaveLength(1);
expect(store.listAfter("s", { eventId: one.eventId }).events).toHaveLength(1);
});
it("reports a retained-history gap", () => {
const store = new InMemoryEventStore(2);
for (let index = 0; index < 4; index++) store.append("s", { type: "run.completed", payload: {} });
expect(store.listAfter("s", { sequence: 1 }).gap?.oldestAvailableSequence).toBe(3);
});
it("unsubscribes listeners", () => {
const store = new InMemoryEventStore(); const listener = vi.fn(); const unsubscribe = store.subscribe("s", listener); unsubscribe(); store.append("s", { type: "run.completed", payload: {} }); expect(listener).not.toHaveBeenCalled();
});
});
describe("ApprovalBroker", () => {
const request = { sessionId: "s", runId: "r", toolCallId: "t", toolName: "save_report_draft", arguments: { content: "x" }, riskLevel: "medium" as const };
it("approves and is idempotent for the same decision", async () => {
let id = ""; const broker = new InMemoryApprovalBroker(1_000, undefined, (value) => { id = value.approvalId; });
const pending = broker.request(request, new AbortController().signal); const first = broker.approve(id); const second = broker.approve(id);
expect((await pending).status).toBe("approved"); expect(second.decision).toEqual(first.decision);
});
it("denies and rejects a conflicting repeat", async () => {
let id = ""; const broker = new InMemoryApprovalBroker(1_000, undefined, (value) => { id = value.approvalId; }); const pending = broker.request(request, new AbortController().signal); broker.deny(id); expect((await pending).status).toBe("denied"); expect(() => broker.approve(id)).toThrow(/already resolved/i);
});
it("expires", async () => {
const broker = new InMemoryApprovalBroker(5); const decision = await broker.request(request, new AbortController().signal); expect(decision.status).toBe("expired");
});
it("cancels immediately with AbortSignal", async () => {
const controller = new AbortController(); const broker = new InMemoryApprovalBroker(1_000); const pending = broker.request(request, controller.signal); controller.abort(); expect((await pending).status).toBe("cancelled");
});
it("redacts sensitive arguments", () => {
expect(redactValue({ apiKey: "secret", nested: { authorization: "Bearer x" } })).toEqual({ apiKey: "[REDACTED]", nested: { authorization: "[REDACTED]" } });
});
});
describe("SafeWorkspace", () => {
it("writes only fixed report.md through an atomic operation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); const result = await workspace.writeReport("正文", "报告"); expect(result.path).toBe("report.md"); expect(await readFile(path.join(root, "report.md"), "utf8")).toContain("# 报告");
});
it("rejects .. traversal and absolute escape", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await expect(workspace.resolveExisting("../outside")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" }); await expect(workspace.resolveExisting("/etc/passwd")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects a symlink escape", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await symlink("/etc/passwd", path.join(root, "linked")); await expect(workspace.resolveExisting("linked")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects protected paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await writeFile(path.join(root, ".env"), "KEY=x"); await expect(workspace.assertReadPath(".env")).rejects.toMatchObject({ code: "PROTECTED_PATH" });
});
it("save tool schema exposes no output path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); const tool = createSaveReportDraftTool(workspace); expect((tool.parameters as { properties: Record<string, unknown> }).properties).not.toHaveProperty("path");
});
});
describe("mock_ads_metrics", () => {
it("validates days in the TypeBox schema", () => {
expect(Value.Check(mockAdsMetricsParameters, { accountId: "demo-account", days: 7 })).toBe(true); expect(Value.Check(mockAdsMetricsParameters, { accountId: "demo-account", days: 31 })).toBe(false);
});
it("computes deterministic metrics and handles precision", async () => {
const metrics = await getMockAdsMetrics(path.join(projectRoot, "fixtures", "ads-account.json"), "demo-account", 7); expect(metrics.spend).toBe(4415.5); expect(metrics.campaigns).toHaveLength(3); expect(metrics.roas).toBeGreaterThan(4);
});
it("rejects unknown accounts", async () => {
await expect(getMockAdsMetrics(path.join(projectRoot, "fixtures", "ads-account.json"), "missing", 7)).rejects.toThrow("Unknown demo account");
});
});
describe("SkillCatalog", () => {
it("discovers .agents and .pi skills without reading bodies into the catalog", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await createSkill(root, ".pi/skills/two", "two", "Second skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect(catalog.list().filter((item) => ["one", "two"].includes(item.name))).toHaveLength(2); expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
});
it("loads path-delimited AGENT_SKILLS_DIRS and de-duplicates canonical paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const extra = path.join(root, "extra"); await createSkill(root, "extra/custom", "custom", "Extra skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir"), [extra, extra].join(path.delimiter)); await catalog.reload(); expect(catalog.list().filter((item) => item.name === "custom")).toHaveLength(1);
});
it("turns an unreadable extra directory into a visible diagnostic", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir"), path.join(root, "missing")); await catalog.reload(); expect(catalog.list().some((item) => item.diagnostics.some((diagnostic) => diagnostic.severity === "error"))).toBe(true);
});
it("matches requested skill reads by canonical path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect((await catalog.matchReadPath(path.join(root, ".agents/skills/one/SKILL.md"), root))?.name).toBe("one"); expect(await catalog.matchReadPath("SKILL.md", root)).toBeUndefined();
});
});
describe("Base URL safety", () => {
it("rejects metadata, userinfo, dangerous protocols, and private HTTPS", async () => {
await expect(validateBaseUrl("https://169.254.169.254/latest", false, false)).rejects.toMatchObject({ code: "SSRF_BLOCKED" }); await expect(validateBaseUrl("https://user:pass@example.com", false, false)).rejects.toMatchObject({ code: "INVALID_BASE_URL" }); await expect(validateBaseUrl("file:///etc/passwd", false, false)).rejects.toMatchObject({ code: "UNSAFE_BASE_URL" }); await expect(validateBaseUrl("https://127.0.0.1/v1", false, false)).rejects.toMatchObject({ code: "SSRF_BLOCKED" });
});
it("allows explicit loopback HTTP only in development", async () => {
await expect(validateBaseUrl("http://localhost:11434/v1", false, true)).resolves.toContain("localhost"); await expect(validateBaseUrl("http://localhost:11434/v1", false, false)).rejects.toMatchObject({ code: "UNSAFE_BASE_URL" });
});
it("rejects credential query parameters", async () => { await expect(validateBaseUrl("https://8.8.8.8/v1?api_key=x", false, false)).rejects.toMatchObject({ code: "CREDENTIAL_IN_URL" }); });
});
async function createSkill(root: string, relative: string, name: string, description: string) { const directory = path.join(root, relative); await mkdir(directory, { recursive: true }); await writeFile(path.join(directory, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n\nSECRET BODY\n`); }
@@ -0,0 +1,101 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { EncryptedFileCredentialVault, InMemoryModelConnectionStore, modelCredentialIdentity } from "../src/index.js";
const model = {
providerId: "training",
api: "openai-responses" as const,
baseUrl: "https://8.8.8.8/v1",
modelId: "training-model",
supportsImages: true,
};
describe("EncryptedFileCredentialVault", () => {
it("persists only authenticated ciphertext and decrypts after restart", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const filePath = path.join(root, "credentials.enc.json");
const masterKey = randomBytes(32).toString("base64");
const identity = modelCredentialIdentity(model);
const first = new EncryptedFileCredentialVault(filePath, masterKey);
await first.set(identity, "secret-model-key");
const raw = await readFile(filePath, "utf8");
expect(raw).not.toContain("secret-model-key");
expect((await stat(filePath)).mode & 0o777).toBe(0o600);
const restarted = new EncryptedFileCredentialVault(filePath, masterKey);
await expect(restarted.get(identity)).resolves.toBe("secret-model-key");
});
it("restores a credential for a new Web Session and preserves it when the old Session is removed", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
const first = new InMemoryModelConnectionStore({}, vault);
await first.configure("session-one", { ...model, apiKey: "persistent-secret" });
expect(first.getRedacted("session-one")).toMatchObject({ keyConfigured: true, credentialStorage: "encrypted" });
await first.remove("session-one");
const restarted = new InMemoryModelConnectionStore({}, vault);
await restarted.configure("session-two", model);
expect(restarted.getRedacted("session-two")).toMatchObject({ keyConfigured: true, keyHint: "••••cret", credentialStorage: "encrypted" });
});
it("deletes the persisted credential only through Clear Credentials", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
const store = new InMemoryModelConnectionStore({}, vault);
await store.configure("session-one", { ...model, apiKey: "persistent-secret" });
await store.clearCredentials("session-one");
const restarted = new InMemoryModelConnectionStore({}, vault);
await restarted.configure("session-two", model);
expect(restarted.getRedacted("session-two")).toMatchObject({ keyConfigured: false });
});
it("fails closed for an invalid or different master key", async () => {
expect(() => new EncryptedFileCredentialVault("unused", "not-a-32-byte-key")).toThrow(/32-byte key/);
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const filePath = path.join(root, "credentials.enc.json");
const identity = modelCredentialIdentity(model);
const first = new EncryptedFileCredentialVault(filePath, randomBytes(32).toString("base64"));
await first.set(identity, "secret-model-key");
const wrong = new EncryptedFileCredentialVault(filePath, randomBytes(32).toString("base64"));
await expect(wrong.get(identity)).rejects.toMatchObject({ code: "CREDENTIAL_DECRYPTION_FAILED" });
});
it("loads a strict environment default and restores its encrypted credential", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
await vault.set(modelCredentialIdentity(model), "environment-default-secret");
const store = new InMemoryModelConnectionStore({
AGENT_PROVIDER: model.providerId,
AGENT_MODEL: model.modelId,
LLM_API: model.api,
LLM_BASE_URL: model.baseUrl,
AGENT_DISPLAY_NAME: "Training Model",
AGENT_SUPPORTS_IMAGES: "true",
}, vault);
await store.initialize();
expect(store.getRedacted("fresh-session")).toMatchObject({ source: "environment", modelId: model.modelId, displayName: "Training Model", supportsImages: true, keyConfigured: true, credentialStorage: "encrypted" });
});
it("uses an environment API key without returning the raw credential", async () => {
const store = new InMemoryModelConnectionStore({
AGENT_PROVIDER: model.providerId,
AGENT_MODEL: model.modelId,
LLM_API: model.api,
LLM_BASE_URL: model.baseUrl,
OPENAI_API_KEY: "environment-api-secret",
});
await store.initialize();
const redacted = store.getRedacted("fresh-session");
expect(redacted).toMatchObject({ source: "environment", keyConfigured: true, keyHint: "••••cret", credentialStorage: "environment" });
expect(JSON.stringify(redacted)).not.toContain("environment-api-secret");
});
it("fails startup for partial or malformed environment defaults", async () => {
const partial = new InMemoryModelConnectionStore({ AGENT_PROVIDER: "openai" });
await expect(partial.initialize()).rejects.toMatchObject({ code: "INVALID_ENV_MODEL_CONFIG" });
const malformed = new InMemoryModelConnectionStore({ AGENT_PROVIDER: "openai", AGENT_MODEL: "model", AGENT_SUPPORTS_IMAGES: "yes" });
await expect(malformed.initialize()).rejects.toMatchObject({ code: "INVALID_ENV_MODEL_CONFIG" });
});
});
+122
View File
@@ -0,0 +1,122 @@
import { mkdtemp, mkdir, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import {
FileAttachmentStore,
InMemoryApprovalBroker,
InMemoryEventStore,
InMemoryModelConnectionStore,
InMemorySessionRegistry,
PiAgentSessionFactory,
SafeWorkspace,
SkillCatalog,
type AgentSessionFactory,
type AgentSessionFactoryInput,
type AgentSessionPort,
} from "../src/index.js";
class FakePort implements AgentSessionPort {
readonly modelId = "fake-model";
constructor(public readonly supportsImages = true) {}
listeners = new Set<(event: unknown) => void>();
promptOptions: { images?: unknown[] } | undefined;
disposed = false; aborted = false;
private finish: (() => void) | undefined;
prompt(_text: string, options?: { images?: unknown[] }): Promise<void> { this.promptOptions = options; return new Promise((resolve) => { this.finish = resolve; }); }
async abort(): Promise<void> { this.aborted = true; this.finish?.(); }
complete(): void { this.finish?.(); }
subscribe(listener: (event: unknown) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
dispose(): void { this.disposed = true; }
emit(event: unknown): void { for (const listener of this.listeners) listener(event); }
}
class FakeFactory implements AgentSessionFactory {
port: FakePort;
input?: AgentSessionFactoryInput;
constructor(supportsImages = true) { this.port = new FakePort(supportsImages); }
async create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> { this.input = input; return this.port; }
}
async function setup(supportsImages = true) {
const root = await mkdtemp(path.join(tmpdir(), "studio-session-")); const sessionsRoot = path.join(root, "sessions"); await mkdir(sessionsRoot);
await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true });
const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload();
const events = new InMemoryEventStore(); const attachments = new FileAttachmentStore(sessionsRoot); const models = new InMemoryModelConnectionStore({}); const factory = new FakeFactory(supportsImages);
const registry = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, catalog, factory, 500, 5_000);
const session = await registry.create(); await registry.configureModel(session.sessionId, { providerId: "fake", api: "openai-responses", apiKey: "test-secret", modelId: "fake-model", supportsImages });
return { root, sessionsRoot, events, attachments, models, factory, registry, session };
}
describe("session lifecycle", () => {
it("creates a real Pi AgentSession for the exact configured model without model network access", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-real-pi-"));
await mkdir(path.join(root, ".agents/skills"), { recursive: true });
await mkdir(path.join(root, ".pi/skills"), { recursive: true });
const cwd = path.join(root, "session"); await mkdir(cwd);
const catalog = new SkillCatalog(root, path.join(root, "catalog")); await catalog.reload();
const models = new InMemoryModelConnectionStore({ ALLOW_PRIVATE_LLM_BASE_URLS: "false" });
await models.configure("ses_real", { providerId: "training", api: "openai-responses", baseUrl: "http://127.0.0.1:9/v1", apiKey: "fake-no-network", modelId: "training-model", supportsImages: true });
const workspace = await SafeWorkspace.create(cwd);
const factory = new PiAgentSessionFactory(path.join(root, "unused-fixture.json"), catalog, models, new InMemoryApprovalBroker());
const port = await factory.create({ sessionId: "ses_real", cwd, workspace, getRun: () => undefined, skillRequested: vi.fn(), skillLoaded: vi.fn(), toolRequested: vi.fn() });
expect(port.modelId).toBe("training-model"); expect(port.supportsImages).toBe(true); port.dispose(); await models.remove("ses_real");
});
it("rejects unconfigured models instead of falling back", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-session-")); const sessionsRoot = path.join(root, "sessions"); await mkdir(sessionsRoot); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent")); await catalog.reload(); const models = new InMemoryModelConnectionStore({}); const registry = new InMemorySessionRegistry(sessionsRoot, new InMemoryEventStore(), new FileAttachmentStore(sessionsRoot), models, catalog, new FakeFactory()); const session = await registry.create(); await expect(registry.send(session.sessionId, { text: "hello", attachmentIds: [] })).rejects.toMatchObject({ code: "MODEL_NOT_CONFIGURED" }); await expect(models.createRuntime(session.sessionId)).rejects.toMatchObject({ code: "MODEL_NOT_CONFIGURED" });
});
it("accepts a run without waiting for prompt completion and rejects concurrency", async () => {
const context = await setup(); const result = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); expect(result.runId).toMatch(/^run_/); expect(context.session.busy).toBe(true); await expect(context.registry.send(context.session.sessionId, { text: "again", attachmentIds: [] })).rejects.toMatchObject({ code: "SESSION_BUSY" }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false));
});
it("cancels an active run and disposes on deletion", async () => {
const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); await context.registry.cancel(context.session.sessionId, "user"); expect(context.factory.port.aborted).toBe(true); await vi.waitFor(() => expect(context.session.busy).toBe(false)); await context.registry.delete(context.session.sessionId); expect(context.factory.port.disposed).toBe(true); await expect(stat(context.session.cwd)).rejects.toThrow();
});
it("maps fake Pi deltas and tool events centrally", async () => {
const context = await setup(); const { runId } = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); context.factory.port.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "流式" } }); context.factory.input?.toolRequested("tool_1", "mock_ads_metrics", { accountId: "demo-account", days: 7 }); context.factory.port.emit({ type: "tool_execution_start", toolCallId: "tool_1", toolName: "mock_ads_metrics", args: {} }); context.factory.port.emit({ type: "tool_execution_end", toolCallId: "tool_1", toolName: "mock_ads_metrics", result: { ok: true }, isError: false }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); const runEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.runId === runId); expect(runEvents.map((event) => event.type)).toContain("assistant.delta"); expect(runEvents.filter((event) => event.type === "tool.requested")).toHaveLength(1); expect(runEvents.map((event) => event.type)).toContain("tool.completed");
});
it("uses Extension preflight as the sole tool request source when Pi start arrives first", async () => {
const context = await setup(); const { runId } = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); context.factory.port.emit({ type: "tool_execution_start", toolCallId: "tool_race", toolName: "read", args: { path: "/absolute/private/path" } }); context.factory.input?.toolRequested("tool_race", "read", { path: ".agents/skills/ads-analysis/SKILL.md" }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); const requests = context.events.listAfter(context.session.sessionId).events.filter((event) => event.runId === runId && event.type === "tool.requested"); expect(requests).toHaveLength(1); expect(requests[0]?.payload.arguments).toEqual({ path: ".agents/skills/ads-analysis/SKILL.md" });
});
it("derives skill requested/loaded callbacks with the same toolCallId", async () => {
const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); const skill = { name: "ads-analysis", description: "x", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", canonicalPath: "/tmp/SKILL.md", diagnostics: [] }; context.factory.input?.skillRequested(skill, "read_1"); context.factory.input?.skillLoaded(skill, "read_1"); const skillEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.type.startsWith("skill.")); expect(skillEvents.map((event) => event.type)).toEqual(["skill.requested", "skill.loaded"]); context.factory.port.complete();
});
});
describe("multimodal session path", () => {
it("maps a normalized image to the installed Pi ImageContent shape", async () => {
const context = await setup(true); const data = await sharp({ create: { width: 3, height: 2, channels: 3, background: "red" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await context.registry.send(context.session.sessionId, { text: "看图", attachmentIds: [item.attachmentId] }); await vi.waitFor(() => expect(context.factory.port.promptOptions).toBeDefined()); const imagePart = context.factory.port.promptOptions?.images?.[0] as { type: string; mimeType: string; data: string }; expect(imagePart).toMatchObject({ type: "image", mimeType: "image/png" }); expect(imagePart.data).toBe(Buffer.from(imagePart.data, "base64").toString("base64")); const serializedEvents = JSON.stringify(context.events.listAfter(context.session.sessionId).events); expect(serializedEvents).not.toContain(imagePart.data); context.factory.port.complete();
});
it("returns 422 semantics for a text-only model", async () => {
const context = await setup(false); const data = await sharp({ create: { width: 3, height: 2, channels: 3, background: "red" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await expect(context.registry.send(context.session.sessionId, { text: "看图", attachmentIds: [item.attachmentId] })).rejects.toMatchObject({ code: "MODEL_DOES_NOT_SUPPORT_IMAGES", statusCode: 422 });
});
it("rejects too many images before creating a run", async () => { const context = await setup(); const ids = Array.from({ length: 5 }, (_, index) => `att_${index}`); await expect(context.registry.send(context.session.sessionId, { text: "x", attachmentIds: ids })).rejects.toMatchObject({ code: "TOO_MANY_ATTACHMENTS" }); });
it("cleans attachments when deleting the session", async () => { const context = await setup(); const data = await sharp({ create: { width: 2, height: 2, channels: 3, background: "blue" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await context.registry.delete(context.session.sessionId); expect(context.attachments.get(context.session.sessionId, item.attachmentId)).toBeUndefined(); });
});
describe("fake Pi integration flow", () => {
it("runs message → tool → approval → report → completion without a real model", async () => {
const context = await setup(true);
const { runId } = await context.registry.send(context.session.sessionId, { text: "生成报告", attachmentIds: [] });
context.factory.port.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "正在分析" } });
context.factory.input?.toolRequested("metrics_1", "mock_ads_metrics", { accountId: "demo-account", days: 7 });
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "metrics_1", toolName: "mock_ads_metrics", result: { spend: 1 }, isError: false });
context.factory.input?.toolRequested("save_1", "save_report_draft", { content: "报告正文" });
const signal = context.session.currentRun?.controller.signal;
expect(signal).toBeDefined();
const pending = context.registry.approvals.request({ sessionId: context.session.sessionId, runId, toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" }, riskLevel: "medium" }, signal!);
const required = context.events.listAfter(context.session.sessionId).events.findLast((event) => event.type === "approval.required");
expect(required?.type).toBe("approval.required");
if (!required || required.type !== "approval.required") throw new Error("approval event missing");
context.registry.approvals.approve(required.payload.approvalId);
expect((await pending).status).toBe("approved");
await context.session.workspace.writeReport("报告正文");
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "save_1", toolName: "save_report_draft", result: { path: "report.md" }, isError: false });
context.factory.port.complete();
await vi.waitFor(() => expect(context.session.busy).toBe(false));
expect(await readFile(path.join(context.session.cwd, "report.md"), "utf8")).toBe("报告正文");
const types = context.events.listAfter(context.session.sessionId).events.map((event) => event.type);
expect(types.indexOf("approval.required")).toBeLessThan(types.indexOf("approval.resolved"));
expect(types.at(-1)).toBe("run.completed");
await context.registry.delete(context.session.sessionId);
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" },
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@agent-studio/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": { "types": "./src/index.ts", "development": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" } },
"scripts": { "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.json" },
"dependencies": { "zod": "^4.1.13" },
"devDependencies": { "typescript": "^5.9.3" }
}
+51
View File
@@ -0,0 +1,51 @@
import { z } from "zod";
import { approvalRequestSchema, imageAttachmentSchema, llmApiSchema } from "./schemas.js";
const baseShape = {
eventId: z.string(),
sessionId: z.string(),
runId: z.string().optional(),
timestamp: z.string().datetime(),
sequence: z.number().int().positive(),
};
const event = <T extends string, S extends z.ZodType>(type: T, payload: S) =>
z.object({ ...baseShape, type: z.literal(type), payload }).strict();
const empty = z.object({}).strict();
const errorPayload = z.object({ code: z.string(), message: z.string() }).strict();
const toolBase = z.object({ toolCallId: z.string(), toolName: z.string(), arguments: z.unknown().optional() }).strict();
export const harnessEventSchema = z.discriminatedUnion("type", [
event("session.started", z.object({ cwd: z.string() }).strict()),
event("session.ended", z.object({ reason: z.enum(["deleted", "shutdown", "error"]) }).strict()),
event("user.message", z.object({ messageId: z.string(), text: z.string(), attachmentIds: z.array(z.string()) }).strict()),
event("assistant.delta", z.object({ messageId: z.string(), delta: z.string() }).strict()),
event("assistant.completed", z.object({ messageId: z.string() }).strict()),
event("skill.requested", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string() }).strict()),
event("skill.loaded", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string() }).strict()),
event("tool.requested", toolBase),
event("tool.completed", z.object({ toolCallId: z.string(), toolName: z.string(), result: z.unknown().optional() }).strict()),
event("tool.failed", z.object({ toolCallId: z.string(), toolName: z.string(), error: z.string() }).strict()),
event("approval.required", approvalRequestSchema),
event("approval.resolved", z.object({ approvalId: z.string(), toolCallId: z.string(), status: z.enum(["approved", "denied", "expired", "cancelled"]), resolvedAt: z.string().datetime() }).strict()),
event("attachment.uploaded", imageAttachmentSchema.omit({ sessionId: true })),
event("attachment.rejected", z.object({ name: z.string(), code: z.string(), message: z.string() }).strict()),
event("model.configured", z.object({ providerId: z.string(), modelId: z.string(), api: llmApiSchema, supportsImages: z.boolean() }).strict()),
event("model.validation_failed", errorPayload),
event("run.started", z.object({ modelId: z.string() }).strict()),
event("run.cancelled", z.object({ reason: z.enum(["user", "timeout", "session_deleted", "shutdown"]) }).strict()),
event("run.completed", empty),
event("run.failed", errorPayload),
]);
export type HarnessEvent = z.infer<typeof harnessEventSchema>;
export type HarnessEventType = HarnessEvent["type"];
export type EventOfType<T extends HarnessEventType> = Extract<HarnessEvent, { type: T }>;
export type EventInput<T extends HarnessEventType> = Omit<EventOfType<T>, "eventId" | "sessionId" | "timestamp" | "sequence">;
export const sseControlEventSchema = z.object({
type: z.literal("stream.gap"),
oldestAvailableSequence: z.number().int().positive(),
requestedSequence: z.number().int().nonnegative(),
}).strict();
+2
View File
@@ -0,0 +1,2 @@
export * from "./schemas.js";
export * from "./events.js";
+91
View File
@@ -0,0 +1,91 @@
import { z } from "zod";
export const llmApiSchema = z.enum(["openai-responses", "openai-completions", "anthropic-messages"]);
export const modelConnectionInputSchema = z.object({
providerId: z.string().trim().min(1).max(80).regex(/^[a-zA-Z0-9._-]+$/),
api: llmApiSchema,
baseUrl: z.string().trim().url().max(2048).optional(),
apiKey: z.string().min(1).max(8192).optional(),
modelId: z.string().trim().min(1).max(200),
displayName: z.string().trim().min(1).max(200).optional(),
supportsImages: z.boolean(),
contextWindow: z.number().int().min(1_024).max(10_000_000).optional(),
maxTokens: z.number().int().min(1).max(1_000_000).optional(),
}).strict();
export type ModelConnectionInput = z.infer<typeof modelConnectionInputSchema>;
export const persistedModelConnectionSchema = modelConnectionInputSchema.omit({ apiKey: true });
export type PersistedModelConnection = z.infer<typeof persistedModelConnectionSchema>;
export const redactedModelConnectionSchema = z.object({
configured: z.boolean(),
source: z.enum(["session", "environment", "missing"]),
providerId: z.string().optional(),
api: llmApiSchema.optional(),
baseUrl: z.string().optional(),
modelId: z.string().optional(),
displayName: z.string().optional(),
supportsImages: z.boolean().optional(),
keyConfigured: z.boolean(),
keyHint: z.string().optional(),
credentialStorage: z.enum(["memory", "encrypted", "environment"]).optional(),
}).strict();
export type RedactedModelConnection = z.infer<typeof redactedModelConnectionSchema>;
export const sendMessageSchema = z.object({
text: z.string().max(100_000).default(""),
attachmentIds: z.array(z.string().regex(/^att_[a-zA-Z0-9_-]+$/)).max(16).default([]),
}).strict().refine((value) => value.text.trim().length > 0 || value.attachmentIds.length > 0, {
message: "Text or at least one attachment is required",
});
export type SendMessageInput = z.infer<typeof sendMessageSchema>;
export const imageAttachmentSchema = z.object({
attachmentId: z.string(),
sessionId: z.string(),
name: z.string(),
mediaType: z.enum(["image/jpeg", "image/png", "image/webp"]),
byteSize: z.number().int().nonnegative(),
width: z.number().int().positive(),
height: z.number().int().positive(),
status: z.literal("ready"),
}).strict();
export type ImageAttachment = z.infer<typeof imageAttachmentSchema>;
export const approvalRequestSchema = z.object({
approvalId: z.string(),
sessionId: z.string(),
runId: z.string(),
toolCallId: z.string(),
toolName: z.string(),
arguments: z.unknown(),
riskLevel: z.enum(["low", "medium", "high"]),
createdAt: z.string().datetime(),
expiresAt: z.string().datetime(),
status: z.enum(["pending", "approved", "denied", "expired", "cancelled"]),
}).strict();
export type ApprovalRequest = z.infer<typeof approvalRequestSchema>;
export const apiErrorSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
requestId: z.string(),
details: z.record(z.string(), z.unknown()),
}).strict(),
}).strict();
export type ApiError = z.infer<typeof apiErrorSchema>;
export const skillDiagnosticSchema = z.object({
severity: z.enum(["warning", "error"]),
message: z.string(),
}).strict();
export const skillCatalogItemSchema = z.object({
name: z.string(),
description: z.string(),
source: z.string(),
filePath: z.string(),
diagnostics: z.array(skillDiagnosticSchema),
}).strict();
export type SkillCatalogItem = z.infer<typeof skillCatalogItemSchema>;
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { apiErrorSchema, harnessEventSchema, modelConnectionInputSchema, persistedModelConnectionSchema, sendMessageSchema } from "../src/index.js";
describe("shared boundary schemas", () => {
it("accepts a typed HarnessEvent variant", () => {
expect(harnessEventSchema.parse({ eventId: "evt_1", sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence: 1, type: "assistant.delta", payload: { messageId: "msg_1", delta: "Hi" } }).type).toBe("assistant.delta");
});
it("rejects the wrong discriminated payload", () => {
expect(() => harnessEventSchema.parse({ eventId: "e", sessionId: "s", timestamp: new Date().toISOString(), sequence: 1, type: "run.completed", payload: { unexpected: true } })).toThrow();
});
it("strictly validates model configuration", () => {
expect(() => modelConnectionInputSchema.parse({ providerId: "openai", api: "made-up", modelId: "x", supportsImages: false })).toThrow();
expect(() => modelConnectionInputSchema.parse({ providerId: "openai", api: "openai-responses", modelId: "x", supportsImages: false, headers: {} })).toThrow();
});
it("accepts valid model configuration", () => {
expect(modelConnectionInputSchema.parse({ providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true }).modelId).toBe("gpt");
});
it("persists only non-sensitive model configuration", () => {
const value = { providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true };
expect(persistedModelConnectionSchema.parse(value)).toEqual(value);
expect(() => persistedModelConnectionSchema.parse({ ...value, apiKey: "secret" })).toThrow();
});
it("requires text or attachments", () => {
expect(() => sendMessageSchema.parse({ text: "", attachmentIds: [] })).toThrow();
expect(sendMessageSchema.parse({ text: "", attachmentIds: ["att_ok"] }).attachmentIds).toEqual(["att_ok"]);
});
it("rejects malformed attachment ids", () => {
expect(() => sendMessageSchema.parse({ text: "x", attachmentIds: ["../escape"] })).toThrow();
});
it("validates the unified error envelope", () => {
expect(apiErrorSchema.parse({ error: { code: "SESSION_NOT_FOUND", message: "Session not found", requestId: "req_1", details: {} } }).error.code).toBe("SESSION_NOT_FOUND");
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" },
"include": ["src"]
}