diff --git a/README.md b/README.md index 8a3372d..61b5da0 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,37 @@ The package installs the local data directory at `~/.hasna/tai/`. ```bash tai propose "show the largest files in this repo" tai plan "inspect the repo status, run the relevant checks, and show me the safe next commands" +tai agents +tai agents --json +tai agents show todos: tai classify "rm -rf dist" tai run "ls -la" --yes ``` `tai run` streams stdout/stderr directly and exits with the child command status. It preserves the current working directory and environment by default, and forwards common process signals to the child. +### Headless agent visibility + +`tai agents [--limit <1-200>]` is a stateless, read-only projection of independently safe provider surfaces. The default output limit is 50. `tai agents show :` performs an exact lookup against only the selected provider. Add `--json` to either form for the versioned machine contract. `--limit` is rejected for exact lookup. + +TAI does not invoke the installed `codewith agent list`, `claude agents`, or `todos active` routes: those routes can create configuration or database files, change permissions, or clean expired locks. It also does not call the authenticated Todos `/v1` GET surface because the provider initializes database schema before request dispatch. Codewith, Claude, and exact Todos therefore report `side-effect-free-surface-unavailable` until those providers expose a demonstrably side-effect-free structured read API. Todos list reports `side-effect-free-source-limit-unavailable`: its task-list implementation cleans expired locks, while its agent-list API has no authoritative source limit. + +No current provider surface satisfies the full read-only proof, so `tai agents` returns a structured unavailable envelope and nonzero exit without consulting provider configuration, credentials, HTTP endpoints, or local state. Exact show still selects only the encoded provider, but an unsupported source returns `agent-lookup-incomplete` without performing an operation. Configuration such as `TODOS_URL`, including loopback URLs, does not enable Todos visibility. + +Missing providers fail soft while any independently safe source responds. If every source is unavailable or errors, the command exits nonzero. Exact lookup may return `agent-not-found` only after a future safe provider surface proves structured, canonical absence; HTTP status, HTML, redirects, partial responses, malformed bodies, unavailable, truncated, or otherwise unproven evidence remains `agent-lookup-incomplete`. A future Todos projection must follow authoritative provider lease ownership and expiry semantics; assignment, task status, or a printable `agent_id` alone is insufficient. + +JSON v1 has these stable fields: + +- Envelope: `schema_version` (number, currently `1`), `generated_at` (ISO-8601 string), `partial` (boolean), `sources` (source diagnostics), and `agents` (normalized records). Command errors add a bounded `error` object. +- Source diagnostic: `provider` (`codewith|claude|todos`), `status` (`ok|partial|unavailable|error`), `freshness_at` (the bounded source-operation completion time or `null`), `coverage: {complete, provider_records, projected_records, dropped_records}`, and optional `error: {code, message}`. +- Agent identity/state: `id`, `provider`, `run_id`, `status`, `active`, `started_at`, `updated_at`, `freshness_at`. +- Agent context: `worktree`, `branch`, `last_tool_call: {name, at, summary}`, `goal: {id, title, status}`, `task: {id, short_id, title, status}`, and `profile: {alias}`. +- `gaps` is a sorted array of stable field names for every missing normalized value. Unavailable or withheld fields remain `null`; they are never inferred from prompts, transcripts, paths, matching text, or task assignment. + +Output is allowlisted rather than denylist-redacted. Run and task IDs must be canonical lowercase UUIDs, task short IDs use a narrow `E-00104`-style form, statuses use an explicit own closed set, and profile aliases use the configured `accountNNN` form. Timestamps must be strict RFC3339 with an explicit timezone and are emitted as canonical UTC; ambiguous locale strings and missing, invalid, or future observations fail closed. Future live-agent sources must also reject stale liveness evidence. Provider-controlled titles, paths, branches, last-tool text, goal text, raw diagnostics, account IDs, credential labels or values, signed URI components, query or fragment values, and nonprinting Unicode controls are omitted. Human cells are truncated by grapheme cluster. + +List coverage remains explicitly unknown until a provider exposes a side-effect-free source limit. Future safe sources may perform at most one bounded operation per selected source, must use the default output limit of 50 and hard maximum of 200, and must mark any source or result truncation `partial: true` with known or unknown dropped coverage. TAI never persists agent data, initializes a provider, mutates locks or tasks, inspects transcripts or prompts, calls a provider once per result, switches profiles, or owns agent lifecycle state. + ## Provider Routing Defaults are local-first and configurable: diff --git a/docs/architecture.md b/docs/architecture.md index 41fa965..c7d1fd3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,20 @@ Routing is local-first: Model IDs are intentionally configuration-driven because availability is time-sensitive. +## Agent Visibility Facade + +The `tai agents` surface is separate from model routing. It is a stateless, read-only facade over provider-owned structured APIs that are demonstrably side-effect free. It does not invoke installed Codewith, Claude, or Todos list commands because those routes can initialize state, change permissions, create databases, or mutate expired task locks. + +Codewith and Claude remain explicit unavailable sources until they expose a safe structured read surface. Todos list is also unavailable: its task-list implementation performs expired-lock cleanup, and its agent-list API has no authoritative source-level limit. Exact Todos is unavailable because authenticated `/v1` dispatch initializes provider database schema even for GET requests. Provider configuration and credentials are never consulted. Exact show calls only its selected provider and performs zero operations when that source is unsupported. Absence may be reported only from a future proven structured response contract; HTTP status, HTML, redirects, partial responses, malformed bodies, unavailable, truncated, or unproven results remain incomplete. + +Normalization is omission-first. Only canonical lowercase UUIDs, narrow task short IDs, explicit own closed-set statuses, strict RFC3339 timestamps with explicit timezone, and `accountNNN` profile aliases may cross the output boundary. Ambiguous locale timestamps and missing, invalid, or future observations fail closed; a future live-agent source must additionally reject stale liveness evidence. Provider-controlled titles, paths, branch names, tool text, goal text, raw diagnostics, URI credentials, query/fragment values, account identifiers, and nonprinting Unicode controls never do. Every null field has a stable named gap, and every source reports explicit complete/returned/dropped coverage. + +A future Todos projection must use authoritative provider lease-owner and lease-expiry semantics. Assignment, task status, or a printable agent/session identifier does not establish a live agent. Duplicate state is selected by newest valid observation first, then explicit status precedence and a canonical tie-breaker. When no valid observation exists, comparison continues without `NaN`; reversed inputs produce the same winner and order. + +The reusable process boundary used by provider integrations starts termination inside its deadline, continuously tracks Linux descendant PIDs with their process start times, kills tracked descendants even after reparenting, kills the process group and direct child, destroys retained pipes, and resolves around the configured deadline. PID start-time checks prevent a recycled PID from becoming a cleanup target. Output overflow follows the same bounded cleanup. Current agent visibility uses no provider process because no installed command meets the side-effect-free contract. + +TAI owns no database, daemon, cache, task assignment, authorization, WorkRun, or agent lifecycle state, and this surface contains no stop, retry, resume, profile-switch, notification, fleet, web, or TUI behavior. + ## Safety Policy Read-only commands may run without confirmation. Writes, network calls, package manager operations, process control, and mutable git operations require confirmation. Destructive operations, credential disclosure, privilege escalation, deploy/publish, and force-push operations are blocked unless the user uses an explicit override. diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..e4c8f11 --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,1162 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; + +export const AGENTS_SCHEMA_VERSION = 1 as const; +export const DEFAULT_AGENTS_LIMIT = 50; +export const MAX_AGENTS_LIMIT = 200; + +const DEFAULT_PROCESS_TIMEOUT_MS = 15_000; +const DEFAULT_PROCESS_STDOUT_BYTES = 4 * 1024 * 1024; +const DEFAULT_PROCESS_STDERR_BYTES = 16 * 1024; +const PROCESS_TERMINATION_RESERVE_MS = 250; +const EARLIEST_VALID_TIME_MS = Date.parse("2000-01-01T00:00:00.000Z"); +const RFC3339_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/; + +const PROVIDERS = ["codewith", "claude", "todos"] as const; +const ACTIVE_STATUSES = new Set(["idle", "in_progress", "running"]); +const STATUS_PRECEDENCE = new Map([ + ["running", 80], + ["in_progress", 70], + ["idle", 60], + ["blocked", 50], + ["pending", 40], + ["succeeded", 30], + ["completed", 30], + ["failed", 20], + ["stopped", 10], + ["cancelled", 10] +]); + +export type AgentProvider = (typeof PROVIDERS)[number]; +export type AgentSourceStatus = "ok" | "partial" | "unavailable" | "error"; + +export interface AgentSourceError { + code: string; + message: string; +} + +export interface AgentSourceCoverage { + complete: boolean; + provider_records: number | null; + projected_records: number; + dropped_records: number | null; +} + +export interface AgentSource { + provider: AgentProvider; + status: AgentSourceStatus; + freshness_at: string | null; + coverage: AgentSourceCoverage; + error?: AgentSourceError; +} + +export interface AgentRecord { + id: string; + provider: AgentProvider; + run_id: string; + status: string; + active: boolean; + started_at: string | null; + updated_at: string | null; + worktree: string | null; + branch: string | null; + last_tool_call: { + name: string | null; + at: string | null; + summary: string | null; + }; + goal: { + id: string | null; + title: string | null; + status: string | null; + }; + task: { + id: string | null; + short_id: string | null; + title: string | null; + status: string | null; + }; + profile: { + alias: string | null; + }; + freshness_at: string | null; + gaps: string[]; +} + +export interface AgentsEnvelope { + schema_version: typeof AGENTS_SCHEMA_VERSION; + generated_at: string; + partial: boolean; + sources: AgentSource[]; + agents: AgentRecord[]; + error?: AgentSourceError; +} + +export interface ProviderHttpRequest { + provider: AgentProvider; + method: "GET"; + url: string; + headers: Readonly>; + timeoutMs: number; + maxBytes: number; +} + +export interface ProviderHttpResult { + status: number | null; + body: string; + failure?: "network-error" | "output-limit" | "timeout"; +} + +export type ProviderHttpRunner = (request: ProviderHttpRequest) => Promise; + +export interface ProviderProcessRequest { + command: string; + args: string[]; + timeoutMs?: number; + maxStdoutBytes?: number; + maxStderrBytes?: number; + cwd?: string; + env?: NodeJS.ProcessEnv; +} + +export interface ProviderProcessResult { + stdout: string; + stderr: string; + exitCode: number | null; + failure?: "not-found" | "output-limit" | "spawn-error" | "timeout"; +} + +export interface CollectAgentsOptions { + env?: Readonly>; + httpRunner?: ProviderHttpRunner; + limit?: number; + now?: () => Date; +} + +export interface AgentsCliResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface ParsedAgentsArguments { + json: boolean; + limit: number; + limitSpecified: boolean; + mode: "list" | "show"; + id: string | null; +} + +interface ProviderCollection { + source: AgentSource; + agents: AgentRecord[]; +} + +interface NormalizedStatus { + value: string; + active: boolean; + complete: boolean; +} + +export async function collectAgentVisibility( + options: CollectAgentsOptions = {} +): Promise { + const now = options.now ?? (() => new Date()); + const generatedAt = safeNow(now); + const limit = validateLimit(options.limit ?? DEFAULT_AGENTS_LIMIT); + const collections = await Promise.all( + PROVIDERS.map((provider) => collectProvider(provider, null)) + ); + const sources = collections.map(({ source }) => source); + const allAgents = sortAgents(dedupeAgents(collections.flatMap(({ agents }) => agents))); + const visibleAgents = allAgents.slice(0, limit); + applyResultLimitCoverage(sources, allAgents, visibleAgents); + + return { + schema_version: AGENTS_SCHEMA_VERSION, + generated_at: generatedAt, + partial: sources.some(({ status, coverage }) => status !== "ok" || !coverage.complete), + sources, + agents: visibleAgents + }; +} + +export async function runAgentsCli( + args: string[], + options: CollectAgentsOptions = {} +): Promise { + const now = options.now ?? (() => new Date()); + const generatedAt = safeNow(now); + let parsed: ParsedAgentsArguments; + + try { + parsed = parseAgentsArguments(args); + } catch { + return cliError( + 2, + args.includes("--json"), + errorEnvelope(generatedAt, { + code: "invalid-arguments", + message: + "Usage: tai agents [--json] [--limit <1-200>] | tai agents show : [--json]." + }) + ); + } + + const parsedId = parsed.mode === "show" ? parseAgentId(parsed.id) : null; + if (parsed.mode === "show" && !parsedId) { + return cliError( + 2, + parsed.json, + errorEnvelope(generatedAt, { + code: "invalid-agent-id", + message: "Agent ID must use :." + }) + ); + } + + if (parsed.mode === "show" && parsed.limitSpecified) { + return cliError( + 2, + parsed.json, + errorEnvelope(generatedAt, { + code: "invalid-arguments", + message: "--limit is not valid for an exact agent lookup." + }) + ); + } + + if (parsed.mode === "show" && parsedId) { + const collection = await collectProvider(parsedId.provider, parsedId.runId); + const envelope: AgentsEnvelope = { + schema_version: AGENTS_SCHEMA_VERSION, + generated_at: generatedAt, + partial: + collection.source.status !== "ok" || !collection.source.coverage.complete, + sources: [collection.source], + agents: sortAgents(dedupeAgents(collection.agents)) + }; + const agent = envelope.agents.find(({ id }) => id === parsed.id); + + if (agent) { + envelope.agents = [agent]; + return { + exitCode: 0, + stdout: parsed.json + ? JSON.stringify(envelope, null, 2) + : formatAgentDetails(agent, envelope.sources), + stderr: "" + }; + } + + if ( + collection.source.status === "ok" && + collection.source.coverage.complete && + collection.source.coverage.dropped_records === 0 + ) { + envelope.error = { + code: "agent-not-found", + message: "The selected provider proved that no exact agent record exists." + }; + return cliError(4, parsed.json, envelope); + } + + envelope.error = { + code: "agent-lookup-incomplete", + message: "The selected provider could not prove an exact lookup result." + }; + return cliError(5, parsed.json, envelope); + } + + const envelope = await collectAgentVisibility({ ...options, limit: parsed.limit, now }); + if (!envelope.sources.some(({ status }) => status === "ok" || status === "partial")) { + envelope.error = { + code: "all-sources-unavailable", + message: "No side-effect-free authoritative agent source is configured." + }; + return parsed.json + ? cliError(3, true, envelope) + : { + exitCode: 3, + stdout: formatAgentsTable(envelope), + stderr: `error: ${envelope.error.message}` + }; + } + + return { + exitCode: 0, + stdout: parsed.json ? JSON.stringify(envelope, null, 2) : formatAgentsTable(envelope), + stderr: "" + }; +} + +export function formatAgentsTable(envelope: AgentsEnvelope): string { + const widths = [23, 46, 12, 28, 20, 24]; + const headers = [ + "STATUS", + "PROVIDER/RUN", + "WORKTREE", + "TASK/GOAL", + "LAST TOOL", + "FRESHNESS" + ]; + const rows = envelope.agents.map((agent) => { + const status = safeOutputStatus(agent.status); + const active = isSafelyActive(agent); + return [ + `${active ? "active" : "inactive"}:${status}`, + safeOutputAgentId(agent.id), + "—", + formatTaskGoal(agent), + "—", + safeOutputTimestamp(agent.freshness_at) + ]; + }); + const lines = [ + headers.map((value, index) => fitCell(value, widths[index] ?? 20)).join(" "), + ...rows.map((row) => + row.map((value, index) => fitCell(value, widths[index] ?? 20)).join(" ") + ) + ]; + + if (envelope.agents.length === 0) { + lines.push("No safely projectable agents found."); + } + + const warnings = envelope.sources.flatMap((source) => { + if ( + !PROVIDERS.includes(source.provider) || + (source.status === "ok" && source.coverage.complete) + ) { + return []; + } + return [ + `${source.provider}=${safeSourceStatus(source.status)}${ + source.error ? `:${safeDiagnosticCode(source.error.code)}` : "" + }` + ]; + }); + if (warnings.length > 0) { + lines.push(`WARNING incomplete sources: ${warnings.join(", ")}`); + } + + return lines.join("\n"); +} + +export function dedupeAgents(agents: AgentRecord[]): AgentRecord[] { + const deduped = new Map(); + for (const candidate of agents) { + const current = deduped.get(candidate.id); + if (!current || compareObservations(candidate, current) < 0) { + deduped.set(candidate.id, candidate); + } + } + return [...deduped.values()]; +} + +export function sortAgents(agents: AgentRecord[]): AgentRecord[] { + return [...agents].sort((left, right) => { + const leftActive = isSafelyActive(left); + const rightActive = isSafelyActive(right); + if (leftActive !== rightActive) { + return leftActive ? -1 : 1; + } + const observationOrder = compareObservationTime(left, right); + if (observationOrder !== 0) { + return observationOrder; + } + return left.id.localeCompare(right.id); + }); +} + +export async function runBoundedProviderProcess( + request: ProviderProcessRequest +): Promise { + const timeoutMs = positiveBound(request.timeoutMs, DEFAULT_PROCESS_TIMEOUT_MS); + const maxStdoutBytes = positiveBound( + request.maxStdoutBytes, + DEFAULT_PROCESS_STDOUT_BYTES + ); + const maxStderrBytes = positiveBound( + request.maxStderrBytes, + DEFAULT_PROCESS_STDERR_BYTES + ); + + return await new Promise((resolve) => { + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + let settled = false; + let terminating = false; + let deadlineTimer: ReturnType | null = null; + let cleanupTimer: ReturnType | null = null; + let descendantTimer: ReturnType | null = null; + const trackedDescendants = new Map(); + const scopeId = randomUUID(); + const cleanupGraceMs = Math.min(50, Math.max(1, Math.floor(timeoutMs / 4))); + const terminationReserveMs = + timeoutMs >= 1_000 + ? Math.min(PROCESS_TERMINATION_RESERVE_MS, Math.max(1, timeoutMs - 1)) + : cleanupGraceMs; + const child = spawn(request.command, request.args, { + cwd: request.cwd, + detached: process.platform !== "win32", + env: { + ...(request.env ?? process.env), + TAI_PROVIDER_PROCESS_SCOPE: scopeId + }, + shell: false, + stdio: ["ignore", "pipe", "pipe"] + }); + + const finish = ( + exitCode: number | null, + failure?: ProviderProcessResult["failure"] + ): void => { + if (settled) { + return; + } + settled = true; + if (deadlineTimer) { + clearTimeout(deadlineTimer); + } + if (cleanupTimer) { + clearTimeout(cleanupTimer); + } + if (descendantTimer) { + clearInterval(descendantTimer); + } + resolve({ + stdout: stdout.toString("utf8"), + stderr: stderr.toString("utf8"), + exitCode, + ...(failure ? { failure } : {}) + }); + }; + + const finishTermination = ( + failure: "output-limit" | "timeout", + knownDescendants: number[] + ): void => { + if (settled || !terminating) { + return; + } + const descendants = child.pid + ? [ + ...new Set([ + ...knownDescendants, + ...collectDescendantPids(child.pid) + ]) + ] + : knownDescendants; + killPids(descendants); + killTrackedPids(trackedDescendants); + if (child.pid && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // The process group may already be gone. + } + } + try { + child.kill("SIGKILL"); + } catch { + // The direct child may already be gone. + } + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + finish(null, failure); + }; + + const beginTermination = (failure: "output-limit" | "timeout"): void => { + if (settled || terminating) { + return; + } + terminating = true; + if (child.pid && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGSTOP"); + } catch { + // The process group may already be gone. + } + } + if (timeoutMs >= 1_000) { + mergeTrackedPids(trackedDescendants, collectScopedProcesses(scopeId)); + } + const descendants = child.pid + ? collectDescendantPids(child.pid) + : []; + killPids(descendants); + killTrackedPids(trackedDescendants); + cleanupTimer = setTimeout( + () => finishTermination(failure, descendants), + cleanupGraceMs + ); + }; + + deadlineTimer = setTimeout( + () => beginTermination("timeout"), + Math.max(1, timeoutMs - terminationReserveMs) + ); + const trackDescendants = (): void => { + if (!child.pid || terminating) { + return; + } + for (const pid of collectDescendantPids(child.pid)) { + const startTime = readProcessStartTime(pid); + if (startTime) { + trackedDescendants.set(pid, startTime); + } + } + }; + trackDescendants(); + descendantTimer = setInterval(trackDescendants, 2); + descendantTimer.unref(); + + child.stdout.on("data", (chunk: Buffer) => { + if (stdout.length + chunk.length > maxStdoutBytes) { + beginTermination("output-limit"); + return; + } + stdout = Buffer.concat([stdout, chunk]); + }); + child.stderr.on("data", (chunk: Buffer) => { + if (stderr.length + chunk.length > maxStderrBytes) { + beginTermination("output-limit"); + return; + } + stderr = Buffer.concat([stderr, chunk]); + }); + child.on("error", (error: NodeJS.ErrnoException) => { + finish(null, error.code === "ENOENT" ? "not-found" : "spawn-error"); + }); + child.on("close", (exitCode) => { + if (!terminating) { + terminating = true; + mergeTrackedPids(trackedDescendants, collectScopedProcesses(scopeId)); + killTrackedPids(trackedDescendants); + cleanupTimer = setTimeout(() => { + finish(exitCode); + }, cleanupGraceMs); + } + }); + }); +} + +function collectDescendantPids(rootPid: number): number[] { + if (process.platform !== "linux") { + return []; + } + const pending = [rootPid]; + const seen = new Set([rootPid]); + const descendants: number[] = []; + while (pending.length > 0) { + const parentPid = pending.shift(); + if (!parentPid) { + continue; + } + let children = ""; + try { + children = readFileSync( + `/proc/${parentPid}/task/${parentPid}/children`, + "utf8" + ); + } catch { + continue; + } + for (const token of children.trim().split(/\s+/)) { + const childPid = Number(token); + if ( + !Number.isSafeInteger(childPid) || + childPid <= 1 || + childPid === process.pid || + seen.has(childPid) + ) { + continue; + } + seen.add(childPid); + descendants.push(childPid); + pending.push(childPid); + } + } + return descendants.reverse(); +} + +function readProcessStartTime(pid: number): string | null { + if (process.platform !== "linux") { + return null; + } + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/); + return fields[19] ?? null; + } catch { + return null; + } +} + +function collectScopedProcesses(scopeId: string): Map { + const matches = new Map(); + if (process.platform !== "linux") { + return matches; + } + const marker = Buffer.from(`TAI_PROVIDER_PROCESS_SCOPE=${scopeId}\0`); + let entries: string[]; + try { + entries = readdirSync("/proc"); + } catch { + return matches; + } + for (const entry of entries) { + if (!/^[0-9]+$/.test(entry)) { + continue; + } + const pid = Number(entry); + if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) { + continue; + } + let environment: Buffer | null = null; + try { + environment = readFileSync(`/proc/${pid}/environ`); + if (!environment.includes(marker)) { + continue; + } + const startTime = readProcessStartTime(pid); + if (startTime) { + matches.set(pid, startTime); + } + } catch { + // The process may have exited or may not expose its environment. + } finally { + environment?.fill(0); + } + } + return matches; +} + +function mergeTrackedPids( + target: Map, + source: ReadonlyMap +): void { + for (const [pid, startTime] of source) { + target.set(pid, startTime); + } +} + +function killTrackedPids(tracked: ReadonlyMap): void { + for (const [pid, expectedStartTime] of tracked) { + if (readProcessStartTime(pid) !== expectedStartTime) { + continue; + } + killPids([pid]); + } +} + +function killPids(pids: number[]): void { + for (const pid of pids) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // A descendant may have exited between the process-tree snapshot and kill. + } + } +} + +async function collectProvider( + provider: AgentProvider, + exactRunId: string | null +): Promise { + if (provider === "codewith" || provider === "claude") { + return unsupportedCollection( + provider, + "side-effect-free-surface-unavailable", + `No side-effect-free structured ${provider} agent read surface is configured.` + ); + } + return collectTodos(exactRunId); +} + +function collectTodos(exactRunId: string | null): ProviderCollection { + if (!exactRunId) { + return unsupportedCollection( + "todos", + "side-effect-free-source-limit-unavailable", + "Todos has no side-effect-free source-level bounded list surface." + ); + } + return unsupportedCollection( + "todos", + "side-effect-free-surface-unavailable", + "Todos has no demonstrably side-effect-free structured exact-read surface." + ); +} + +function normalizeStatus(value: unknown): NormalizedStatus { + if (typeof value !== "string") { + return { value: "unknown", active: false, complete: false }; + } + const candidate = value.trim().toLowerCase(); + if (candidate === "unknown" || !STATUS_PRECEDENCE.has(candidate)) { + return { value: "unknown", active: false, complete: false }; + } + return { + value: candidate, + active: ACTIVE_STATUSES.has(candidate), + complete: true + }; +} + +function parseTimestampMs(value: unknown): number | null { + if (typeof value !== "string") { + return null; + } + const match = RFC3339_PATTERN.exec(value); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const zone = match[8] ?? ""; + if ( + year < 2000 || + month < 1 || + month > 12 || + day < 1 || + day > new Date(Date.UTC(year, month, 0)).getUTCDate() || + hour > 23 || + minute > 59 || + second > 59 + ) { + return null; + } + if (zone !== "Z") { + const zoneHour = Number(zone.slice(1, 3)); + const zoneMinute = Number(zone.slice(4, 6)); + if (zoneHour > 23 || zoneMinute > 59) { + return null; + } + } + const milliseconds = Date.parse(value); + return Number.isFinite(milliseconds) ? milliseconds : null; +} + +function safeUuid(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value + ) + ? value + : null; +} + +function safeShortId(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + return /^[A-Z][A-Z0-9]{0,7}-[0-9]{1,8}$/.test(value) ? value : null; +} + +function safeProfileAlias(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + return /^account[0-9]{3}$/.test(value) ? value : null; +} + +export async function runBoundedHttpRequest( + request: ProviderHttpRequest +): Promise { + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, request.timeoutMs); + + try { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + signal: controller.signal, + redirect: "error" + }); + if (!response.body) { + return { status: response.status, body: "" }; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + while (true) { + const item = await reader.read(); + if (item.done) { + break; + } + bytes += item.value.byteLength; + if (bytes > request.maxBytes) { + controller.abort(); + await reader.cancel().catch(() => undefined); + return { status: response.status, body: "", failure: "output-limit" }; + } + chunks.push(item.value); + } + return { + status: response.status, + body: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8") + }; + } catch { + return { + status: null, + body: "", + failure: timedOut ? "timeout" : "network-error" + }; + } finally { + clearTimeout(timer); + } +} + +function unsupportedCollection( + provider: AgentProvider, + code: string, + message: string +): ProviderCollection { + return { + source: { + provider, + status: "unavailable", + freshness_at: null, + coverage: { + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }, + error: { code, message } + }, + agents: [] + }; +} + +function applyResultLimitCoverage( + sources: AgentSource[], + allAgents: AgentRecord[], + visibleAgents: AgentRecord[] +): void { + if (visibleAgents.length === allAgents.length) { + return; + } + const visibleIds = new Set(visibleAgents.map(({ id }) => id)); + for (const source of sources) { + const sourceAgents = allAgents.filter(({ provider }) => provider === source.provider); + const visibleSourceAgents = sourceAgents.filter(({ id }) => visibleIds.has(id)); + const omitted = sourceAgents.length - visibleSourceAgents.length; + if (omitted <= 0) { + continue; + } + source.status = "partial"; + source.coverage = { + ...source.coverage, + complete: false, + projected_records: visibleSourceAgents.length, + dropped_records: + source.coverage.dropped_records === null + ? null + : source.coverage.dropped_records + omitted + }; + source.error ??= { + code: "result-limit-applied", + message: "The requested result limit omitted safely projectable records." + }; + } +} + +function compareObservations(left: AgentRecord, right: AgentRecord): number { + const timeOrder = compareObservationTime(left, right); + if (timeOrder !== 0) { + return timeOrder; + } + const statusOrder = + (STATUS_PRECEDENCE.get(right.status) ?? 0) - + (STATUS_PRECEDENCE.get(left.status) ?? 0); + if (statusOrder !== 0) { + return statusOrder; + } + return canonicalRecord(left).localeCompare(canonicalRecord(right)); +} + +function compareObservationTime(left: AgentRecord, right: AgentRecord): number { + const leftTime = observationTime(left); + const rightTime = observationTime(right); + if (leftTime === rightTime) { + return 0; + } + return leftTime > rightTime ? -1 : 1; +} + +function observationTime(agent: AgentRecord): number { + for (const value of [agent.updated_at, agent.freshness_at, agent.started_at]) { + const parsed = parseTimestampMs(value); + if (parsed !== null && parsed <= Date.now()) { + return parsed; + } + } + return Number.NEGATIVE_INFINITY; +} + +function isSafelyActive(agent: AgentRecord): boolean { + const status = normalizeStatus(agent.status); + return ( + agent.active && + status.complete && + status.active && + observationTime(agent) !== Number.NEGATIVE_INFINITY + ); +} + +function canonicalRecord(agent: AgentRecord): string { + return JSON.stringify(agent); +} + +function parseAgentsArguments(args: string[]): ParsedAgentsArguments { + let json = false; + let limit = DEFAULT_AGENTS_LIMIT; + let limitSpecified = false; + const positionals: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] ?? ""; + if (argument === "--json") { + json = true; + continue; + } + if (argument === "--limit") { + const value = args[index + 1]; + if (!value) throw new Error("invalid limit"); + limit = validateLimit(Number(value)); + limitSpecified = true; + index += 1; + continue; + } + if (argument.startsWith("--limit=")) { + limit = validateLimit(Number(argument.slice("--limit=".length))); + limitSpecified = true; + continue; + } + if (argument.startsWith("-")) { + throw new Error("unknown option"); + } + positionals.push(argument); + } + + if (positionals.length === 0 || (positionals.length === 1 && positionals[0] === "list")) { + return { json, limit, limitSpecified, mode: "list", id: null }; + } + if (positionals.length === 2 && positionals[0] === "show") { + return { + json, + limit, + limitSpecified, + mode: "show", + id: positionals[1] ?? null + }; + } + throw new Error("invalid arguments"); +} + +function validateLimit(value: number): number { + if (!Number.isSafeInteger(value) || value < 1 || value > MAX_AGENTS_LIMIT) { + throw new Error("invalid limit"); + } + return value; +} + +function parseAgentId( + value: string | null +): { provider: AgentProvider; runId: string } | null { + if (!value) { + return null; + } + const separator = value.indexOf(":"); + if (separator <= 0 || separator !== value.lastIndexOf(":")) { + return null; + } + const provider = value.slice(0, separator); + const runId = safeUuid(value.slice(separator + 1)); + return PROVIDERS.includes(provider as AgentProvider) && runId + ? { provider: provider as AgentProvider, runId } + : null; +} + +function errorEnvelope(generatedAt: string, error: AgentSourceError): AgentsEnvelope { + return { + schema_version: AGENTS_SCHEMA_VERSION, + generated_at: generatedAt, + partial: true, + sources: [], + agents: [], + error + }; +} + +function cliError( + exitCode: number, + json: boolean, + envelope: AgentsEnvelope +): AgentsCliResult { + return { + exitCode, + stdout: json ? JSON.stringify(envelope, null, 2) : "", + stderr: json ? "" : `error: ${envelope.error?.message ?? "Agent visibility failed."}` + }; +} + +function formatTaskGoal(agent: AgentRecord): string { + const shortId = safeShortId(agent.task.short_id); + if (shortId) { + return shortId; + } + const taskId = safeUuid(agent.task.id); + if (taskId) { + return taskId; + } + const goalId = safeUuid(agent.goal.id); + if (goalId) { + return goalId; + } + return "—"; +} + +function formatAgentDetails(agent: AgentRecord, sources: AgentSource[]): string { + const safeStatus = safeOutputStatus(agent.status); + const safeGaps = agent.gaps.filter((gap) => SAFE_GAP_NAMES.has(gap)).sort(); + const active = isSafelyActive(agent); + return [ + `Agent: ${safeOutputAgentId(agent.id)}`, + `Status: ${safeStatus} (${active ? "active" : "inactive"})`, + `Started: ${safeOutputTimestamp(agent.started_at)}`, + `Updated: ${safeOutputTimestamp(agent.updated_at)}`, + "Worktree: —", + "Branch: —", + `Task: ${formatTaskGoal(agent)}`, + `Goal: ${safeUuid(agent.goal.id) ?? "—"}`, + "Last tool: —", + `Profile: ${safeProfileAlias(agent.profile.alias) ?? "—"}`, + `Freshness: ${safeOutputTimestamp(agent.freshness_at)}`, + `Gaps: ${safeGaps.length > 0 ? safeGaps.join(", ") : "none"}`, + "Sources:", + ...sources.flatMap((source) => + PROVIDERS.includes(source.provider) + ? [ + ` ${source.provider}: ${safeSourceStatus(source.status)}${ + source.error ? ` (${safeDiagnosticCode(source.error.code)})` : "" + }` + ] + : [] + ) + ].join("\n"); +} + +function safeOutputAgentId(value: unknown): string { + if (typeof value !== "string") { + return "—"; + } + const parsed = parseAgentId(value); + return parsed && `${parsed.provider}:${parsed.runId}` === value ? value : "—"; +} + +function safeOutputStatus(value: unknown): string { + return normalizeStatus(value).value; +} + +function safeOutputTimestamp(value: unknown): string { + const parsed = parseTimestampMs(value); + return parsed !== null && + parsed >= EARLIEST_VALID_TIME_MS && + parsed <= Date.now() + ? new Date(parsed).toISOString() + : "—"; +} + +function safeSourceStatus(value: unknown): AgentSourceStatus { + return value === "ok" || + value === "partial" || + value === "unavailable" || + value === "error" + ? value + : "error"; +} + +function safeDiagnosticCode(value: unknown): string { + return typeof value === "string" && SAFE_DIAGNOSTIC_CODES.has(value) + ? value + : "diagnostic-withheld"; +} + +function fitCell(value: string, width: number): string { + const graphemes = [...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(value)].map( + ({ segment }) => segment + ); + const fitted = + graphemes.length <= width + ? value + : `${graphemes.slice(0, Math.max(0, width - 1)).join("")}…`; + const fittedLength = [ + ...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(fitted) + ].length; + return fitted.padEnd(fitted.length + Math.max(0, width - fittedLength), " "); +} + +function safeNow(now: () => Date): string { + const value = now(); + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return new Date(0).toISOString(); + } + return value.toISOString(); +} + +function positiveBound(value: number | undefined, fallback: number): number { + return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : fallback; +} + +const SAFE_GAP_NAMES = new Set([ + "branch", + "freshness_at", + "goal.id", + "goal.status", + "goal.title", + "last_tool_call.at", + "last_tool_call.name", + "last_tool_call.summary", + "profile.alias", + "started_at", + "status", + "task.short_id", + "task.status", + "task.title", + "updated_at", + "worktree" +]); + +const SAFE_DIAGNOSTIC_CODES = new Set([ + "agent-provenance-missing", + "diagnostic-withheld", + "normalized-fields-unavailable", + "provider-http-error", + "provider-identity-mismatch", + "provider-invalid-json", + "provider-invalid-payload", + "provider-network-error", + "provider-output-limit", + "provider-timeout", + "record-withheld", + "result-limit-applied", + "side-effect-free-source-limit-unavailable", + "side-effect-free-surface-unavailable" +]); diff --git a/src/cli/index.ts b/src/cli/index.ts index 1c8d6b7..1708ac5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createTai } from "../sdk"; import { formatAgenticPlan } from "../agentic-plan"; +import { runAgentsCli } from "../agents"; import { formatCommandPreview } from "../proposal"; import { classifyCommand } from "../safety"; import { runShellCommand } from "../shell"; @@ -33,6 +34,17 @@ async function main(): Promise { return plan.blocked ? 2 : 0; } + if (command === "agents") { + const result = await runAgentsCli(args); + if (result.stdout) { + console.log(result.stdout); + } + if (result.stderr) { + console.error(result.stderr); + } + return result.exitCode; + } + if (command === "run") { const yesIndex = args.indexOf("--yes"); const overrideIndex = args.indexOf("--override"); @@ -65,6 +77,8 @@ function printHelp(): void { Usage: tai propose tai plan + tai agents [--json] [--limit <1-200>] + tai agents show : [--json] tai classify tai run [--yes] [--override] `); diff --git a/tests/agents.test.ts b/tests/agents.test.ts new file mode 100644 index 0000000..57f25a6 --- /dev/null +++ b/tests/agents.test.ts @@ -0,0 +1,1106 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AGENTS_SCHEMA_VERSION, + collectAgentVisibility, + dedupeAgents, + formatAgentsTable, + runAgentsCli, + runBoundedHttpRequest, + runBoundedProviderProcess, + sortAgents, + type AgentRecord, + type ProviderHttpRequest, + type ProviderHttpResult, + type ProviderHttpRunner +} from "../src/agents"; + +const REQUEST_AT = new Date("2026-07-23T12:00:00.000Z"); +const OBSERVED_AT = new Date("2026-07-23T12:00:05.000Z"); +const TODOS_ENV = { + TODOS_URL: "https://todos.example.test", + TODOS_API_KEY: "test-only-key" +}; + +function uuid(index: number): string { + return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; +} + +function task( + index: number, + overrides: Record = {} +): Record { + return { + id: uuid(index), + short_id: `E-${String(index).padStart(5, "0")}`, + title: `Task ${index}`, + status: "in_progress", + started_at: "2026-07-23T10:00:00.000Z", + updated_at: "2026-07-23T11:00:00.000Z", + agent_id: uuid(index + 10_000), + ...overrides + }; +} + +function listResult( + tasks: unknown[], + total = tasks.length, + status = 200 +): ProviderHttpResult { + return { + status, + body: JSON.stringify({ tasks, count: tasks.length, total }) + }; +} + +function exactResult(value: unknown, status = 200): ProviderHttpResult { + return { + status, + body: JSON.stringify({ task: value }) + }; +} + +function fakeHttp( + resultOrHandler: + | ProviderHttpResult + | ((request: ProviderHttpRequest) => ProviderHttpResult | Promise) +): { runner: ProviderHttpRunner; calls: ProviderHttpRequest[] } { + const calls: ProviderHttpRequest[] = []; + return { + calls, + runner: async (request) => { + calls.push(request); + return typeof resultOrHandler === "function" + ? await resultOrHandler(request) + : resultOrHandler; + } + }; +} + +function fixedNow(): Date { + return REQUEST_AT; +} + +function sequenceNow(...values: Date[]): () => Date { + let index = 0; + return () => values[Math.min(index++, values.length - 1)] ?? REQUEST_AT; +} + +function isProcessLive(pid: number): boolean { + if (process.platform === "linux") { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const state = stat.slice(stat.lastIndexOf(")") + 2).charAt(0); + return state !== "Z" && state !== "X"; + } catch { + return false; + } + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function normalizedAgent(overrides: Partial = {}): AgentRecord { + const id = overrides.run_id ?? uuid(1); + return { + id: `todos:${id}`, + provider: "todos", + run_id: id, + status: "running", + active: true, + started_at: "2026-07-23T10:00:00.000Z", + updated_at: "2026-07-23T11:00:00.000Z", + worktree: null, + branch: null, + last_tool_call: { name: null, at: null, summary: null }, + goal: { id: null, title: null, status: null }, + task: { id, short_id: "E-00104", title: null, status: "running" }, + profile: { alias: null }, + freshness_at: "2026-07-23T11:00:00.000Z", + gaps: [], + ...overrides + }; +} + +describe("final repair-cycle regressions", () => { + test("never calls the authenticated Todos HTTP surface, regardless of response semantics", async () => { + for (const result of [ + exactResult(task(1)), + exactResult(task(1), 201), + exactResult(task(1), 206), + exactResult(task(1), 226), + { status: 302, body: "" }, + { status: 404, body: "not found" } + ]) { + const http = fakeHttp(result); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(http.calls).toHaveLength(0); + expect(response.exitCode).toBe(5); + expect(envelope.agents).toEqual([]); + expect(envelope.error.code).toBe("agent-lookup-incomplete"); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.sources[0].coverage.complete).toBe(false); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); + } + }); + + test("prototype names are not recognized statuses and remain fail-closed", () => { + for (const status of ["constructor", "__proto__"]) { + const hostile = normalizedAgent({ + id: `todos:${uuid(2)}`, + run_id: uuid(2), + status, + active: true, + started_at: null, + updated_at: null, + freshness_at: null, + task: { id: uuid(1), short_id: "E-00104", title: null, status }, + gaps: ["status", "task.status"] + }); + const output = formatAgentsTable({ + schema_version: 1, + generated_at: REQUEST_AT.toISOString(), + partial: true, + sources: [], + agents: [hostile] + }); + + expect(output).toContain("inactive:unknown"); + expect(output).not.toContain(status); + expect(hostile.gaps).toEqual(["status", "task.status"]); + const valid = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "completed", + active: false + }); + expect(sortAgents([hostile, valid])).toEqual([valid, hostile]); + } + }); + + test("a stale printable agent_id never proves live Todos agent provenance", async () => { + const stale = task(1, { + agent_id: "printable-agent", + status: "running", + started_at: "2000-01-01T00:00:00.000Z", + updated_at: "2000-01-01T00:00:00.000Z", + locked_at: "2000-01-01T00:00:00.000Z" + }); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(stale)).runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(envelope.agents).toEqual([]); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.sources[0].coverage.complete).toBe(false); + }); + + test("rejects uppercase UUIDs as noncanonical before any provider execution", async () => { + const lowercase = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const http = fakeHttp(exactResult(task(1))); + const response = await runAgentsCli( + ["show", `todos:${lowercase.toUpperCase()}`, "--json"], + { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + } + ); + + expect(response.exitCode).toBe(2); + expect(http.calls).toHaveLength(0); + expect(JSON.parse(response.stdout).agents).toEqual([]); + }); + + test("documents IPv6 loopback as unsupported without attempting a request", async () => { + const http = fakeHttp(exactResult(task(1))); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: { TODOS_URL: "http://[::1]:43117" }, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(http.calls).toHaveLength(0); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); + }); + + test("uses status and canonical tie-breakers when every timestamp is invalid", () => { + const running = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "running", + active: true, + started_at: null, + updated_at: null, + freshness_at: null, + profile: { alias: "account002" } + }); + const completed = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "completed", + active: false, + started_at: null, + updated_at: null, + freshness_at: null, + profile: { alias: "account001" } + }); + const first = normalizedAgent({ + id: `todos:${uuid(2)}`, + run_id: uuid(2), + status: "completed", + active: false, + started_at: null, + updated_at: null, + freshness_at: null + }); + const second = normalizedAgent({ + id: `todos:${uuid(3)}`, + run_id: uuid(3), + status: "completed", + active: false, + started_at: null, + updated_at: null, + freshness_at: null + }); + + expect(dedupeAgents([running, completed])).toEqual([running]); + expect(dedupeAgents([completed, running])).toEqual([running]); + expect(sortAgents([second, first])).toEqual([first, second]); + expect(sortAgents([first, second])).toEqual([first, second]); + }); + + test("ambiguous non-RFC3339 timestamps cannot win dedupe ordering", () => { + const ambiguous = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "completed", + active: false, + started_at: "01/02/2026", + updated_at: "01/02/2026", + freshness_at: "01/02/2026" + }); + const untimedRunning = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "running", + active: true, + started_at: null, + updated_at: null, + freshness_at: null + }); + + expect(dedupeAgents([ambiguous, untimedRunning])).toEqual([untimedRunning]); + expect(dedupeAgents([untimedRunning, ambiguous])).toEqual([untimedRunning]); + + const future = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "completed", + active: false, + started_at: "9999-12-31T23:59:59.000Z", + updated_at: "9999-12-31T23:59:59.000Z", + freshness_at: "9999-12-31T23:59:59.000Z" + }); + expect(dedupeAgents([future, untimedRunning])).toEqual([untimedRunning]); + expect(dedupeAgents([untimedRunning, future])).toEqual([untimedRunning]); + }); + + test("kills a detached grandchild before resolving the timeout", async () => { + const root = mkdtempSync(join(tmpdir(), "tai-agents-detached-grandchild-")); + const pidFile = join(root, "grandchild.pid"); + const script = [ + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + `const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));`, + "child.unref();", + "setInterval(() => {}, 1000);" + ].join(""); + let escapedPid: number | null = null; + + try { + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", script], + timeoutMs: 200 + }); + escapedPid = Number(readFileSync(pidFile, "utf8")); + + expect(result.failure).toBe("timeout"); + expect(isProcessLive(Number(escapedPid))).toBe(false); + } finally { + if (escapedPid && Number.isSafeInteger(escapedPid)) { + try { + process.kill(escapedPid, "SIGKILL"); + } catch { + // The repaired implementation already terminated it. + } + } + rmSync(root, { recursive: true, force: true }); + } + }); + + test("kills a detached grandchild even after the direct child exits", async () => { + const root = mkdtempSync(join(tmpdir(), "tai-agents-reparented-grandchild-")); + const pidFile = join(root, "grandchild.pid"); + const script = [ + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + `const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));`, + "child.unref();" + ].join(""); + let escapedPid: number | null = null; + + try { + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", script], + timeoutMs: 1_000 + }); + escapedPid = Number(readFileSync(pidFile, "utf8")); + + expect(result.exitCode).toBe(0); + expect(result.failure).toBeUndefined(); + expect(isProcessLive(Number(escapedPid))).toBe(false); + } finally { + if (escapedPid && Number.isSafeInteger(escapedPid)) { + try { + process.kill(escapedPid, "SIGKILL"); + } catch { + // The repaired implementation already terminated it. + } + } + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("side-effect-free provider selection", () => { + test("does not invoke mutating installed CLIs when no safe source is configured", async () => { + const http = fakeHttp(listResult([])); + const envelope = await collectAgentVisibility({ + env: {}, + httpRunner: http.runner, + now: fixedNow + }); + + expect(http.calls).toHaveLength(0); + expect(envelope.partial).toBe(true); + expect(envelope.agents).toEqual([]); + expect(envelope.sources.map(({ provider, status }) => [provider, status])).toEqual([ + ["codewith", "unavailable"], + ["claude", "unavailable"], + ["todos", "unavailable"] + ]); + expect(envelope.sources.every(({ coverage }) => coverage.complete === false)).toBe(true); + }); + + test("keeps Todos list unavailable because no side-effect-free source limit exists", async () => { + const http = fakeHttp(listResult([task(1)])); + const envelope = await collectAgentVisibility({ + env: TODOS_ENV, + httpRunner: http.runner, + now: sequenceNow(REQUEST_AT, OBSERVED_AT) + }); + + expect(http.calls).toHaveLength(0); + const source = envelope.sources.find(({ provider }) => provider === "todos"); + expect(source?.status).toBe("unavailable"); + expect(source?.error?.code).toBe("side-effect-free-source-limit-unavailable"); + expect(source?.coverage).toEqual({ + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }); + }); + + test("rejects credential-bearing or signed API base URLs without executing a request", async () => { + for (const baseUrl of [ + "https://user:pass@todos.example.test", + "https://todos.example.test?signature=value", + "https://todos.example.test#credential", + "http://todos.example.test" + ]) { + const http = fakeHttp(listResult([])); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: { TODOS_URL: baseUrl }, + httpRunner: http.runner, + now: fixedNow + }); + expect(http.calls).toHaveLength(0); + expect(response.exitCode).toBe(5); + expect(response.stdout).not.toContain("signature"); + expect(response.stdout).not.toContain("credential"); + expect(response.stdout).not.toContain("user"); + } + }); + + test("black-box CLI leaves provider homes untouched when safe APIs are absent", () => { + const root = mkdtempSync(join(tmpdir(), "tai-agents-side-effects-")); + const environment = { ...process.env }; + delete environment.TODOS_URL; + delete environment.TODOS_API_URL; + delete environment.TODOS_API_KEY; + environment.HOME = root; + environment.XDG_CONFIG_HOME = join(root, "xdg-config"); + environment.XDG_DATA_HOME = join(root, "xdg-data"); + environment.XDG_CACHE_HOME = join(root, "xdg-cache"); + + try { + const child = spawnSync( + process.execPath, + ["run", "src/cli/index.ts", "agents", "--json"], + { + cwd: process.cwd(), + env: environment, + encoding: "utf8", + timeout: 5_000 + } + ); + expect(child.status).toBe(3); + expect(JSON.parse(child.stdout).error.code).toBe("all-sources-unavailable"); + expect(existsSync(join(root, ".codewith"))).toBe(false); + expect(existsSync(join(root, ".claude"))).toBe(false); + expect(existsSync(join(root, ".hasna", "todos"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("strict safe-output normalization", () => { + test("omits all uncontrolled text, paths, credential metadata, and unsafe aliases", async () => { + const hostile = [ + "X-API-Key secret-value", + "Authorization Bearer secret-value", + "--token secret-value", + "AWS_SECRET_ACCESS_KEY secret-value", + "https://user:pass@example.test/path?signature=secret#fragment", + "/home/private-user/.env", + "\u009b\u202e\u2066\u200b\u2028\u2029" + ].join(" "); + const hostileTask = task(1, { + title: hostile, + working_dir: hostile, + branch: hostile, + account_id: uuid(99), + profile_alias: "prod-secret", + metadata: { + profile_alias: "prod-secret", + last_tool: hostile, + goal_title: hostile + } + }); + const http = fakeHttp(exactResult(hostileTask)); + const json = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const human = await runAgentsCli(["show", `todos:${uuid(1)}`], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(hostileTask)).runner, + now: fixedNow + }); + + for (const output of [json.stdout, human.stdout]) { + expect(output).not.toContain("secret-value"); + expect(output).not.toContain("private-user"); + expect(output).not.toContain("signature"); + expect(output).not.toContain("fragment"); + expect(output).not.toContain("prod-secret"); + expect(output).not.toContain(uuid(99)); + expect(output).not.toMatch(/[\u0080-\u009f\u200b\u2028\u2029\u202a-\u202e\u2066-\u2069]/u); + } + + const envelope = JSON.parse(json.stdout); + expect(http.calls).toHaveLength(0); + expect(envelope.agents).toEqual([]); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.sources[0].coverage.complete).toBe(false); + }); + + test("does not project profile aliases from an unsupported provider", async () => { + const safe = fakeHttp(exactResult(task(1, { profile_alias: "account001" }))); + const unsafe = fakeHttp(exactResult(task(1, { profile_alias: "account-id-123" }))); + + const safeResponse = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: safe.runner, + now: fixedNow + }); + const unsafeResponse = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: unsafe.runner, + now: fixedNow + }); + const safeEnvelope = JSON.parse(safeResponse.stdout); + const unsafeEnvelope = JSON.parse(unsafeResponse.stdout); + + expect(safe.calls).toHaveLength(0); + expect(unsafe.calls).toHaveLength(0); + expect(safeEnvelope.agents).toEqual([]); + expect(unsafeEnvelope.agents).toEqual([]); + expect(JSON.stringify(safeEnvelope)).not.toContain("account001"); + expect(JSON.stringify(unsafeEnvelope)).not.toContain("account-id-123"); + }); + + test("withholds secret-like opaque IDs instead of projecting them", async () => { + const secretLikeId = ["glpat", "abcdefghijklmnopqrst"].join("-"); + const http = fakeHttp( + exactResult({ + ...task(1), + id: secretLikeId + }) + ); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(envelope.agents).toHaveLength(0); + expect(JSON.stringify(envelope)).not.toContain(secretLikeId); + expect(http.calls).toHaveLength(0); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); + }); + + test("never projects raw HTTP failure bodies as diagnostics", async () => { + const raw = JSON.stringify({ + error: "Authorization Bearer secret-value", + password: "secret-value", + account_id: uuid(99) + }); + const http = fakeHttp({ status: 500, body: raw }); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + + expect(http.calls).toHaveLength(0); + expect(response.exitCode).toBe(5); + expect(response.stdout).not.toContain("secret-value"); + expect(response.stdout).not.toContain(uuid(99)); + expect(JSON.parse(response.stdout).sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); + }); + + test("human formatting revalidates normalized fields instead of trusting callers", () => { + const hostile = "secret-value\u202e"; + const malicious = normalizedAgent({ + id: hostile, + run_id: hostile, + status: hostile, + active: true, + updated_at: "9999-12-31T23:59:59.000Z", + freshness_at: "9999-12-31T23:59:59.000Z", + task: { id: hostile, short_id: hostile, title: hostile, status: hostile }, + profile: { alias: hostile }, + gaps: [hostile] + }); + const output = formatAgentsTable({ + schema_version: 1, + generated_at: REQUEST_AT.toISOString(), + partial: true, + agents: [malicious], + sources: [ + { + provider: "todos", + status: "partial", + freshness_at: REQUEST_AT.toISOString(), + coverage: { + complete: false, + provider_records: 1, + projected_records: 1, + dropped_records: 0 + }, + error: { code: hostile, message: hostile } + } + ] + }); + + expect(output).not.toContain("secret-value"); + expect(output).not.toContain("9999"); + expect(output).not.toContain("\u202e"); + expect(output).toContain("inactive:unknown"); + expect(output).toContain("diagnostic-withheld"); + }); +}); + +describe("truthful state and completeness", () => { + test("missing or unsupported status and invalid timestamps render fail-closed", () => { + for (const hostile of [ + normalizedAgent({ + status: "unknown", + active: true, + started_at: null, + updated_at: null, + freshness_at: null, + gaps: ["status", "started_at", "updated_at", "freshness_at"] + }), + normalizedAgent({ + status: "mystery\u202e", + active: true, + started_at: "not-a-date", + updated_at: "not-a-date", + freshness_at: "9999-12-31T23:59:59.000Z", + gaps: ["status", "started_at", "updated_at", "freshness_at"] + }) + ]) { + const output = formatAgentsTable({ + schema_version: 1, + generated_at: REQUEST_AT.toISOString(), + partial: true, + sources: [], + agents: [hostile] + }); + + expect(output).toContain("inactive:unknown"); + expect(output).not.toContain("mystery"); + expect(output).not.toContain("9999"); + expect(hostile.gaps).toEqual( + expect.arrayContaining(["status", "started_at", "updated_at", "freshness_at"]) + ); + } + }); + + test("unsupported exact source reports stable incomplete coverage without records", async () => { + const http = fakeHttp( + exactResult( + task(1, { + short_id: "unsafe short id", + started_at: null, + updated_at: null, + profile_alias: null + }) + ) + ); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(http.calls).toHaveLength(0); + expect(envelope.agents).toEqual([]); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.sources[0].coverage).toEqual({ + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); + }); + + test("does not project any Todos task without a safe authoritative surface", async () => { + const cases = [ + { value: task(1, { agent_id: undefined }) }, + { + value: task(2, { agent_id: undefined, assigned_to: "account001" }) + }, + { + value: task(3, { session_id: uuid(103), agent_id: undefined }) + }, + { + value: task(4, { + agent_id: undefined, + locked_by: "lease-owner", + lock_expires_at: "2026-07-23T12:10:00.000Z" + }) + }, + { + value: task(5, { + agent_id: undefined, + locked_by: "expired-owner", + lock_expires_at: "2026-07-23T11:59:00.000Z" + }) + } + ]; + for (const item of cases) { + const http = fakeHttp(exactResult(item.value)); + const response = await runAgentsCli( + ["show", `todos:${String(item.value.id)}`, "--json"], + { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + } + ); + expect(response.exitCode).toBe(5); + expect(http.calls).toHaveLength(0); + expect(JSON.parse(response.stdout).agents).toEqual([]); + } + }); + + test("dedupe selects newest valid observation before status and ties canonically", () => { + const olderActive = normalizedAgent({ + run_id: uuid(1), + id: `todos:${uuid(1)}`, + status: "running", + active: true, + updated_at: "2026-07-23T10:00:00.000Z", + freshness_at: "2026-07-23T10:00:00.000Z", + profile: { alias: "account001" } + }); + const newerTerminal = normalizedAgent({ + run_id: uuid(1), + id: `todos:${uuid(1)}`, + status: "completed", + active: false, + updated_at: "2026-07-23T11:00:00.000Z", + freshness_at: "2026-07-23T11:00:00.000Z", + profile: { alias: "account002" } + }); + const tieA = normalizedAgent({ + run_id: uuid(2), + id: `todos:${uuid(2)}`, + profile: { alias: "account001" } + }); + const tieB = normalizedAgent({ + run_id: uuid(2), + id: `todos:${uuid(2)}`, + profile: { alias: "account002" } + }); + + expect(dedupeAgents([olderActive, newerTerminal])).toEqual([newerTerminal]); + expect(dedupeAgents([newerTerminal, olderActive])).toEqual([newerTerminal]); + expect(dedupeAgents([tieA, tieB])).toEqual(dedupeAgents([tieB, tieA])); + }); + + test("unsupported Todos list reports unknown coverage instead of capturing a stream", async () => { + const http = fakeHttp(listResult(Array.from({ length: 201 }, (_, index) => task(index + 1)), 250)); + const envelope = await collectAgentVisibility({ + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const source = envelope.sources.find(({ provider }) => provider === "todos"); + + expect(http.calls).toHaveLength(0); + expect(envelope.agents).toHaveLength(0); + expect(envelope.partial).toBe(true); + expect(source?.status).toBe("unavailable"); + expect(source?.error?.code).toBe("side-effect-free-source-limit-unavailable"); + expect(source?.coverage).toEqual({ + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }); + }); + + test("list hard maximum is rejected before any source operation", async () => { + const http = fakeHttp(listResult([])); + const response = await runAgentsCli(["--limit", "201", "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + + expect(response.exitCode).toBe(2); + expect(JSON.parse(response.stdout).error.code).toBe("invalid-arguments"); + expect(http.calls).toHaveLength(0); + }); + + test("unobserved source freshness remains null instead of using request start", async () => { + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(task(1))).runner, + now: sequenceNow(REQUEST_AT, OBSERVED_AT) + }); + const envelope = JSON.parse(response.stdout); + const source = envelope.sources[0]; + + expect(envelope.generated_at).toBe(REQUEST_AT.toISOString()); + expect(source?.freshness_at).toBeNull(); + expect(source?.coverage.complete).toBe(false); + }); +}); + +describe("exact lookup and CLI semantics", () => { + test("exact show queries only the selected provider and performs no unsafe operation", async () => { + const target = task(201); + const http = fakeHttp((request) => { + expect(new URL(request.url).pathname).toBe(`/v1/tasks/${uuid(201)}`); + expect(new URL(request.url).search).toBe(""); + return exactResult(target); + }); + const response = await runAgentsCli(["show", `todos:${uuid(201)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(http.calls).toHaveLength(0); + expect(envelope.sources).toHaveLength(1); + expect(envelope.sources[0].provider).toBe("todos"); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.agents).toEqual([]); + }); + + test("selected-source unavailability is incomplete, never false not-found", async () => { + const http = fakeHttp(listResult([task(1)])); + const response = await runAgentsCli(["show", `claude:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(envelope.error.code).toBe("agent-lookup-incomplete"); + expect(envelope.sources).toHaveLength(1); + expect(envelope.sources[0].provider).toBe("claude"); + expect(http.calls).toHaveLength(0); + }); + + test("arbitrary HTTP 404 cannot become authoritative agent-not-found", async () => { + const http = fakeHttp({ status: 404, body: '{"error":"not found"}' }); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(http.calls).toHaveLength(0); + expect(envelope.error.code).toBe("agent-lookup-incomplete"); + expect(envelope.sources[0].coverage.complete).toBe(false); + }); + + test("an exact task without agent provenance is incomplete, not absent", async () => { + const unassigned = task(1, { agent_id: undefined, assigned_to: "account001" }); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(unassigned)).runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(envelope.error.code).toBe("agent-lookup-incomplete"); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); + }); + + test("show rejects --limit and malformed or secret-like IDs before provider execution", async () => { + for (const args of [ + ["show", `todos:${uuid(1)}`, "--limit", "1", "--json"], + ["show", "todos:not-a-safe-id", "--json"], + ["show", ["todos:glpat", "abcdefghijklmnopqrst"].join("-"), "--json"] + ]) { + const http = fakeHttp(exactResult(task(1))); + const response = await runAgentsCli(args, { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + expect(response.exitCode).toBe(2); + expect(http.calls).toHaveLength(0); + } + }); + + test("JSON v1 and human output expose stable shape and explicit warnings", async () => { + const json = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(task(1))).runner, + now: fixedNow + }); + const envelope = JSON.parse(json.stdout); + + expect(json.exitCode).toBe(5); + expect(envelope.schema_version).toBe(AGENTS_SCHEMA_VERSION); + expect(Object.keys(envelope.sources[0]).sort()).toEqual([ + "coverage", + "error", + "freshness_at", + "provider", + "status" + ]); + expect(Object.keys(envelope.sources[0].coverage).sort()).toEqual([ + "complete", + "dropped_records", + "projected_records", + "provider_records" + ]); + expect(envelope.agents).toEqual([]); + expect(formatAgentsTable(envelope)).toContain("WARNING incomplete sources:"); + }); + + test("all unavailable sources return a stable nonzero JSON error", async () => { + const response = await runAgentsCli(["--json"], { env: {}, now: fixedNow }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(3); + expect(envelope.error.code).toBe("all-sources-unavailable"); + expect(envelope.partial).toBe(true); + }); + + test("all unavailable sources still render the compact human table and warnings", async () => { + const response = await runAgentsCli([], { env: {}, now: fixedNow }); + + expect(response.exitCode).toBe(3); + expect(response.stdout).toContain("STATUS"); + expect(response.stdout).toContain("WORKTREE"); + expect(response.stdout).toContain("No safely projectable agents found."); + expect(response.stdout).toContain("WARNING incomplete sources:"); + expect(response.stderr).toBe( + "error: No side-effect-free authoritative agent source is configured." + ); + }); +}); + +describe("real process wall-clock and byte bounds", () => { + test("forces resolution for a direct hang", async () => { + const started = performance.now(); + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + timeoutMs: 100 + }); + + expect(result.failure).toBe("timeout"); + expect(performance.now() - started).toBeLessThan(1_000); + }); + + test("terminates the process group when a descendant retains stdio", async () => { + const script = [ + 'const { spawn } = require("node:child_process");', + 'spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit" });', + "setInterval(() => {}, 1000);" + ].join(""); + const started = performance.now(); + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", script], + timeoutMs: 150 + }); + + expect(result.failure).toBe("timeout"); + expect(performance.now() - started).toBeLessThan(1_000); + }); + + test("preserves a bounded nonzero result without projecting stderr", async () => { + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", 'process.stderr.write("sensitive stderr"); process.exit(7)'], + timeoutMs: 1_000, + maxStderrBytes: 32 + }); + + expect(result.exitCode).toBe(7); + expect(result.failure).toBeUndefined(); + expect(result.stderr.length).toBeLessThanOrEqual(32); + }); + + test("terminates the process group on stdout byte overflow", async () => { + const started = performance.now(); + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", 'process.stdout.write("x".repeat(4096)); setInterval(() => {}, 1000)'], + timeoutMs: 2_000, + maxStdoutBytes: 128 + }); + + expect(result.failure).toBe("output-limit"); + expect(performance.now() - started).toBeLessThan(1_000); + }); + + test("terminates the process group on stderr byte overflow", async () => { + const started = performance.now(); + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", 'process.stderr.write("x".repeat(4096)); setInterval(() => {}, 1000)'], + timeoutMs: 2_000, + maxStderrBytes: 128 + }); + + expect(result.failure).toBe("output-limit"); + expect(performance.now() - started).toBeLessThan(1_000); + }); +}); + +describe("real HTTP wall-clock and byte bounds", () => { + test("aborts and resolves a direct HTTP hang at the wall-clock deadline", async () => { + const server = Bun.serve({ + port: 0, + fetch: async () => await new Promise(() => undefined) + }); + const started = performance.now(); + try { + const result = await runBoundedHttpRequest({ + provider: "todos", + method: "GET", + url: `http://127.0.0.1:${server.port}/v1/tasks/${uuid(1)}`, + headers: { Accept: "application/json" }, + timeoutMs: 100, + maxBytes: 1024 + }); + expect(result.failure).toBe("timeout"); + expect(performance.now() - started).toBeLessThan(1_000); + } finally { + server.stop(true); + } + }); + + test("aborts an HTTP response at the byte cap", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("x".repeat(4096)) + }); + try { + const result = await runBoundedHttpRequest({ + provider: "todos", + method: "GET", + url: `http://127.0.0.1:${server.port}/v1/tasks/${uuid(1)}`, + headers: { Accept: "application/json" }, + timeoutMs: 1_000, + maxBytes: 128 + }); + expect(result.failure).toBe("output-limit"); + expect(result.body).toBe(""); + } finally { + server.stop(true); + } + }); +});