From 938824dcbcde9ab3a769b95dd131e37dfaab1d7c Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:41:41 -0400 Subject: [PATCH 1/4] feat(online): gate terminal agents on account readiness --- COMMANDS.md | 8 ++-- README.md | 4 ++ src/commands/managed_agents.ts | 44 ++++++++++++++---- src/core/diagnostics.ts | 10 ++++ src/core/doctor_live.ts | 23 +++++++++- src/core/managed_agents.ts | 83 +++++++++++++++++++++++++++++++++- test/ats_agent.test.ts | 9 ++++ test/diagnostics.test.ts | 1 + test/doctor_live.test.ts | 31 +++++++++++-- test/managed_agents.test.ts | 61 +++++++++++++++++++++++-- 10 files changed, 250 insertions(+), 24 deletions(-) diff --git a/COMMANDS.md b/COMMANDS.md index 3056a5f8..44e2d3b1 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -376,15 +376,17 @@ check is an error, so it is safe to gate scripts on. call, no session, no opener launch, no credential refresh, no write. Covers runtime, workspace, git, transport config, auth config, tools, memory, MCP registry, persistence, the media output index, the opener, GitHub, and -Protocol-C receipt storage. +Protocol-C receipt storage. Terminal Online readiness is listed as unverified +until an authenticated live probe runs. **`aether doctor --live`** — proves the paths end to end, right now: authenticated catalog fetch, a dev session, sequence-numbered frames, pause/resume/steer acknowledgement, a sandboxed tool write/read/compare/delete round trip, clean session close, a real browser open confirmed by a loopback callback, GitHub identity, branch freshness compared **without fetching**, the -MCP broker, and a Protocol-C receipt round trip. Billing is accounted across -the run and reported as `spend.none`; the agent loop runs only when the server +MCP broker, a Protocol-C receipt round trip, and terminal managed-agent +registry, DM, and model/UVT readiness without sending a message. Billing is +accounted across the run and reported as `spend.none`; the agent loop runs only when the server confirms a non-billable doctor session, and is reported as unproven otherwise. `--no-ui` skips the browser proof on a headless box (reported as skipped, not passed). diff --git a/README.md b/README.md index 63c800b4..6ed70386 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ and [operator packet](docs/releases/OPERATOR-PACKET-v0.4.0.md). After signing in, use `aether agent list` to see the same managed agents as Aether Online. `aether agent chat` opens the one-column picker; select an agent to read and send messages in its existing Online conversation. +The list and show commands report fresh account registry, DM, and model/UVT +readiness separately. `aether doctor --live` checks the same read-only Cloud +contract; plain `aether doctor` leaves account readiness unverified. A saved DM +is reported as admitted only when Cloud's message admission says so. ```bash aether agent list diff --git a/src/commands/managed_agents.ts b/src/commands/managed_agents.ts index ab8192a1..581d876d 100644 --- a/src/commands/managed_agents.ts +++ b/src/commands/managed_agents.ts @@ -3,7 +3,8 @@ import { randomUUID } from "node:crypto"; import type { Writable } from "node:stream"; import type { AppContext } from "../core/context.js"; import { - ManagedAgentsClient, MANAGED_AGENT_ID, managedAgentError, + ManagedAgentsClient, MANAGED_AGENT_ID, managedAgentError, probeManagedReadiness, + type ManagedReadiness, type ReadinessGate, type ManagedAgent, type ManagedAgentConfig, type AgentMessage, } from "../core/managed_agents.js"; import { theme } from "../ui/theme.js"; @@ -65,6 +66,19 @@ const HELP = [ "New agents start as drafts with zero budget. Configure UVT limits, then activate.", ].join("\n") + "\n"; +function readinessLine(readiness: ManagedReadiness): string { + return (["registry", "dm", "model_uvt"] as const).map(key => { + const gate = readiness[key]; + return `${key}: ${gate.state} (${gate.code}) · ${gate.reason} ${gate.state === "enabled" ? "" : gate.remedy}`.trim(); + }).join("\n") + "\n"; +} + +function writeGateError(gate: ReadinessGate, ctx: AppContext, out: Writable): void { + out.write(ctx.flags.json + ? JSON.stringify({ error: { code: gate.code, message: gate.reason, remedy: gate.remedy } }) + "\n" + : `✗ ${gate.code}: ${sanitizeTerm(gate.reason)} ${sanitizeTerm(gate.remedy)}\n`); +} + function cell(value: string, width: number): string { const safe = sliceVisible(sanitizeTerm(value).replace(/[\r\n\t]/g, " "), width); return safe + " ".repeat(Math.max(0, width - visibleWidth(safe))); @@ -278,13 +292,18 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine try { if (!(await ctx.tokens.get())) throw new Error("Sign in with `aether auth login` to sync your agents."); if (ctx.flags.local) throw new Error("Managed agents require your Aether account. Omit --local to sync with Cloud."); + const readiness = await probeManagedReadiness(ctx.api, signal); + if (readiness.registry.state !== "enabled") { writeGateError(readiness.registry, ctx, deps.err ?? process.stderr); return 1; } + if (readiness.dm.state !== "enabled") { writeGateError(readiness.dm, ctx, deps.err ?? process.stderr); return 1; } + if (ctx.flags.json) out.write(JSON.stringify({ type: "readiness", readiness }) + "\n"); + else out.write(readinessLine(readiness)); let agent = id ? await client.get(id, signal) : await pickManagedAgent(await client.list(signal), out, signal); if (!agent) return 0; closeSession = await deps.hooks?.beforeChat?.(ctx, agent, surface); const thread = await client.thread(agent.agent_id, signal); if (typeof thread["id"] !== "string") throw new Error("Cloud did not return a conversation ID."); const conversationId = thread["id"]; - const send = async (body: string): Promise => { + const send = async (body: string): Promise => { const nonce = randomUUID(); let receipt; try { receipt = await client.send(agent!.agent_id, conversationId, body, nonce, signal); } @@ -292,13 +311,14 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine // No automatic replay with a new nonce: the server may already have saved it. throw new Error(`${managedAgentError(error)} Delivery is unconfirmed; check the shared conversation before sending again.`); } - if (ctx.flags.json) out.write(JSON.stringify(receipt) + "\n"); - else { - const admission = receipt.admission; - surface.write(theme.dim(`Message saved · ${sanitizeTerm(admission?.state ?? "admission not reported")}${admission?.reason ? ` · ${sanitizeTerm(admission.reason)}` : ""}`) + "\n"); - } + const state = receipt.admission?.state ?? "unreported"; + const accepted = state === "admitted" || state === "replied"; + const code = accepted ? "ADMITTED" : "SEND_NOT_ADMITTED"; + if (ctx.flags.json) out.write(JSON.stringify({ type: "message_admission", code, state }) + "\n"); + else surface.write(theme.dim(`${accepted ? "Message admitted" : "Message saved; execution not accepted"} · ${sanitizeTerm(state)}`) + "\n"); + return accepted; }; - if (prompt.trim()) { await send(prompt); return 0; } + if (prompt.trim()) return await send(prompt) ? 0 : 1; if (!ctx.flags.json) { out.write(renderAgent(agent) + theme.dim("Shared Online DM · /refresh · /exit") + "\n"); const help = deps.hooks?.help?.(agent); @@ -440,9 +460,13 @@ export async function cmdManagedAgents(ctx: AppContext, argv: string[], deps: Ma if (ctx.flags.local) throw new Error("Managed agents require your Aether account. Omit --local to sync with Cloud."); const [verb = "list", id, ...rest] = argv; if (verb === "chat") return await cmdManagedAgentChat(ctx, id, rest.join(" "), deps); + const readiness = verb === "list" || verb === "show" ? await probeManagedReadiness(ctx.api, deps.signal) : undefined; + if (readiness?.registry.state !== undefined && readiness.registry.state !== "enabled") { + writeGateError(readiness.registry, ctx, deps.err ?? process.stderr); return 1; + } if (verb === "list") { const agents = await client.list(deps.signal); - out.write(ctx.flags.json ? JSON.stringify({ agents }) + "\n" : renderManagedAgents(agents, (out as Writable & { columns?: number }).columns ?? 80)); + out.write(ctx.flags.json ? JSON.stringify({ readiness, agents }) + "\n" : readinessLine(readiness!) + renderManagedAgents(agents, (out as Writable & { columns?: number }).columns ?? 80)); return 0; } let agent: ManagedAgent; @@ -467,7 +491,7 @@ export async function cmdManagedAgents(ctx: AppContext, argv: string[], deps: Ma agent = await client.control(agent, verb as "activate" | "pause" | "resume" | "retire", deps.signal); } } else { out.write(HELP); return 2; } - out.write(ctx.flags.json ? JSON.stringify({ agent }) + "\n" : renderAgent(agent)); + out.write(ctx.flags.json ? JSON.stringify({ ...(readiness ? { readiness } : {}), agent }) + "\n" : (readiness ? readinessLine(readiness) : "") + renderAgent(agent)); if (verb === "create" && !ctx.flags.json) out.write(theme.dim(`Saved to your account. Configure UVT limits, then activate: aether agent activate ${agent.agent_id}`) + "\n"); return 0; } catch (error) { diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts index 5080dfca..ebe002ca 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -220,6 +220,16 @@ export function fastCheckSpecs( severity: "info", }), }, + { + id: "online.terminal.readiness", + category: "online", + title: "Terminal managed agents", + run: (): CheckOutcome => ({ + configured: axis("unknown", { evidence: "account entitlement needs an authenticated Cloud probe" }), + severity: "warning", + remediation: LIVE_HINT, + }), + }, { id: "tools.schemas", category: "tools", diff --git a/src/core/doctor_live.ts b/src/core/doctor_live.ts index f4a51d50..351f2749 100644 --- a/src/core/doctor_live.ts +++ b/src/core/doctor_live.ts @@ -31,6 +31,7 @@ import { buildDevSessionRequest } from "./envelope.js"; import { decodeSse, type StreamFrame } from "./stream.js"; import { TOOLS } from "./brain_protocol.js"; import { DEV_PROTOCOL_VERSION } from "./brain_cloud.js"; +import { probeManagedReadiness, type ReadinessGate } from "./managed_agents.js"; import { appendCustody, readCustodyLog } from "./custody.js"; import { McpClient } from "./mcp.js"; import { diagnosePredatorDrive } from "./predator_drive_readiness.js"; @@ -57,6 +58,25 @@ import { const DEFAULT_PROBE_TIMEOUT_MS = 10_000; const DEFAULT_OPENER_TIMEOUT_MS = 15_000; +function onlineChecks(ctx: AppContext, authed: boolean): Promise { + const gates = ["registry", "dm", "model_uvt"] as const; + if (!authed) return Promise.resolve(gates.map(name => check({ id: `online.terminal.${name}`, category: "online", title: `Terminal ${name}` }, { + configured: axis("no", { evidence: "AUTH_REQUIRED" }), + reachable: notChecked("signed out"), verified: notChecked("signed out"), + severity: "warning", remediation: "run: aether auth login", + }))); + return probeManagedReadiness(ctx.api).then(readiness => gates.map(name => { + const gate: ReadinessGate = readiness[name]; + const enabled = gate.state === "enabled"; + return check({ id: `online.terminal.${name}`, category: "online", title: `Terminal ${name}` }, { + configured: axis(enabled ? "yes" : gate.state === "disabled" ? "no" : "unknown", { evidence: gate.code }), + reachable: axis(gate.code === "TEMPORARILY_UNAVAILABLE" ? "no" : "yes", { evidence: gate.code }), + verified: axis(enabled ? "yes" : "no", { evidence: gate.code }), + severity: enabled ? "info" : "warning", remediation: enabled ? undefined : gate.remedy, + }); + })); +} + export interface LiveOptions { now?: () => string; /** Per-probe bound. A broken service must never hang the CLI. */ @@ -1003,10 +1023,11 @@ export async function liveReport(ctx: AppContext, options: LiveOptions = {}): Pr const agent = authed ? await agentProbes(ctx, ledger, { ...options, runId, timeoutMs }, sandbox) : agentUnproven("signed out; the agent loop cannot be exercised"); + const online = await onlineChecks(ctx, authed); return buildReport( "live", - [authCheck, ...agent, ...independent, ...automationChecks(), spendCheck(ledger)], + [authCheck, ...agent, ...online, ...independent, ...automationChecks(), spendCheck(ledger)], now(), ); } finally { diff --git a/src/core/managed_agents.ts b/src/core/managed_agents.ts index 088e25c1..54bc4564 100644 --- a/src/core/managed_agents.ts +++ b/src/core/managed_agents.ts @@ -6,6 +6,85 @@ import { HttpError } from "./errors.js"; export const MANAGED_AGENTS_PATH = "/agent/managed"; export const MANAGED_AGENT_ID = /^mag_[0-9a-f]{16}$/; +export const TERMINAL_READINESS_CONTRACT = "aether.terminal-readiness/1"; +export type ReadinessState = "enabled" | "disabled" | "unavailable"; +export interface ReadinessGate { + state: ReadinessState; + code: string; + reason: string; + remedy: string; +} +export interface ManagedReadiness { + schema_version: typeof TERMINAL_READINESS_CONTRACT; + required_contract: string; + registry: ReadinessGate; + dm: ReadinessGate; + model_uvt: ReadinessGate; +} + +const safeGate = (state: ReadinessState, code: string, reason: string, remedy: string): ReadinessGate => ({ state, code, reason, remedy }); +const unavailable = (code: string, reason: string, remedy: string): ManagedReadiness => ({ + schema_version: TERMINAL_READINESS_CONTRACT, required_contract: "unknown", + registry: safeGate("unavailable", code, reason, remedy), + dm: safeGate("unavailable", code, reason, remedy), + model_uvt: safeGate("unavailable", code, reason, remedy), +}); +const incompatible = (): ManagedReadiness => unavailable("INCOMPATIBLE_CONTRACT", "Terminal and Cloud agent contracts are incompatible.", "Update the terminal and Cloud adapter."); +const GATE_TEXT: Record = { + READY: { state: "enabled", reason: "Available for this account.", remedy: "No action needed." }, + ACCOUNT_DISABLED: { state: "disabled", reason: "Managed agents are disabled for this account.", remedy: "Ask the account administrator to enable managed agents." }, + REGISTRY_UNAVAILABLE: { state: "unavailable", reason: "The agent registry is temporarily unavailable.", remedy: "Retry after the service recovers." }, + DM_DISABLED: { state: "disabled", reason: "Agent DMs are disabled for this account.", remedy: "Ask the account administrator to enable agent DMs." }, + ONLINE_DISABLED: { state: "disabled", reason: "Aether Online is disabled for this account.", remedy: "Ask the account administrator to enable Aether Online." }, + DM_UNAVAILABLE: { state: "unavailable", reason: "Online DMs are temporarily unavailable.", remedy: "Retry after the service recovers." }, + ADMISSION_DISABLED: { state: "disabled", reason: "Model and UVT execution is disabled for this account.", remedy: "Ask the account administrator to enable agent execution and UVT." }, + EXECUTION_DISABLED: { state: "disabled", reason: "Managed-agent execution is disabled for this account.", remedy: "Ask the account administrator to enable agent execution." }, + UVT_DISABLED: { state: "disabled", reason: "UVT is disabled for this account.", remedy: "Ask the account administrator to enable UVT." }, + ADMISSION_UNAVAILABLE: { state: "unavailable", reason: "Model and UVT admission is unavailable.", remedy: "Retry after the service is repaired." }, + ADMISSION_AVAILABLE: { state: "enabled", reason: "Model and UVT admission is available; each message is checked separately.", remedy: "Check each message admission receipt." }, +}; + +/** Never reuse a probe across calls: a CLI token may rotate or switch accounts. */ +export async function probeManagedReadiness(api: ApiClient, signal?: AbortSignal): Promise { + try { + const value = await api.getJson(`${MANAGED_AGENTS_PATH}/readiness`, signal, 10_000); + if (!value || typeof value !== "object" || Array.isArray(value)) return incompatible(); + const raw = value as Record; + if (raw["schema_version"] !== TERMINAL_READINESS_CONTRACT || !["aether.managed-agents/1", "aether.managed-agents/1.1"].includes(String(raw["required_contract"]))) { + return incompatible(); + } + const gates: Partial> = {}; + for (const key of ["registry", "dm", "model_uvt"] as const) { + const value = raw[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) return incompatible(); + const gate = value as Record; + const code = String(gate["code"]); + const known = GATE_TEXT[code]; + if (!known || gate["state"] !== known.state || + (key === "registry" && !["READY", "ACCOUNT_DISABLED", "REGISTRY_UNAVAILABLE"].includes(code)) || + (key === "dm" && !["READY", "DM_DISABLED", "ONLINE_DISABLED", "DM_UNAVAILABLE"].includes(code)) || + (key === "model_uvt" && !["ADMISSION_AVAILABLE", "ADMISSION_DISABLED", "EXECUTION_DISABLED", "UVT_DISABLED", "ADMISSION_UNAVAILABLE"].includes(code)) || + typeof gate["reason"] !== "string" || typeof gate["remedy"] !== "string") { + return incompatible(); + } + gates[key] = safeGate(known.state, code, known.reason, known.remedy); + } + if (gates.registry!.state !== "enabled" && (gates.dm!.state === "enabled" || gates.model_uvt!.state === "enabled")) return incompatible(); + if (gates.dm!.state !== "enabled" && gates.model_uvt!.state === "enabled") return incompatible(); + return { schema_version: TERMINAL_READINESS_CONTRACT, required_contract: String(raw["required_contract"]), + registry: gates.registry!, dm: gates.dm!, model_uvt: gates.model_uvt! }; + } catch (error) { + if (error instanceof HttpError) { + const body = error.body && typeof error.body === "object" ? error.body as Record : {}; + const detail = body["detail"] && typeof body["detail"] === "object" ? body["detail"] as Record : {}; + if (detail["code"] === "WRONG_CREDENTIAL_CLASS") return unavailable("WRONG_CREDENTIAL_CLASS", "This credential cannot access terminal agents.", "Sign in with `aether auth login`."); + if (error.status === 401) return unavailable("AUTH_REQUIRED", "The CLI account credential is invalid or expired.", "Sign in with `aether auth login`."); + if (error.status === 403) return unavailable("ACCOUNT_DISABLED", "Terminal agents are disabled for this account.", "Ask the account administrator to enable managed agents."); + if (error.status === 404) return unavailable("INCOMPATIBLE_CONTRACT", "Cloud has not deployed the terminal readiness contract.", "Update the terminal and Cloud adapter."); + } + return unavailable("TEMPORARILY_UNAVAILABLE", "Terminal agent readiness could not be checked.", "Retry when Cloud is available."); + } +} export interface ManagedAgentConfig { profile?: { schema_version: "aether.managed-agent.profile/1"; kind: "ats" } | null; @@ -35,7 +114,7 @@ export interface AgentMessage { } export interface MessageAdmission { - state: string; + state: "saved" | "blocked_budget" | "blocked_policy" | "needs_review" | "admitted" | "replied"; reason?: string | null; run_id?: string | null; } @@ -161,7 +240,7 @@ export class ManagedAgentsClient { if (typeof receipt["id"] !== "string" || typeof receipt["body"] !== "string") throw new Error("Cloud did not confirm a saved message."); if (receipt["admission"] !== undefined) { const admission = record(receipt["admission"]); - if (typeof admission["state"] !== "string") throw new Error("Cloud returned an invalid message admission."); + if (!["saved", "blocked_budget", "blocked_policy", "needs_review", "admitted", "replied"].includes(String(admission["state"]))) throw new Error("Cloud returned an invalid message admission."); } return receipt as SentAgentMessage; } diff --git a/test/ats_agent.test.ts b/test/ats_agent.test.ts index a014cff9..cb85497e 100644 --- a/test/ats_agent.test.ts +++ b/test/ats_agent.test.ts @@ -22,6 +22,12 @@ const ID = "mag_0123456789abcdef"; const SUBJECT = "11111111-1111-4111-8111-111111111111"; const ACCOUNT = { cloudOrigin: "https://example.test", accountSubject: SUBJECT }; const identity = (): Response => new Response(JSON.stringify({ schema_version: "aether.terminal-account/1", account_subject: SUBJECT })); +const readiness = (): Response => new Response(JSON.stringify({ + schema_version: "aether.terminal-readiness/1", required_contract: "aether.managed-agents/1.1", + registry: { state: "enabled", code: "READY", reason: "Ready", remedy: "None" }, + dm: { state: "enabled", code: "READY", reason: "Ready", remedy: "None" }, + model_uvt: { state: "enabled", code: "ADMISSION_AVAILABLE", reason: "Ready", remedy: "None" }, +})); function context(): AppContext { const tokens = new StaticTokenStore("aek_test_cli"); return { cfg: { ...DEFAULT_CONFIG, baseUrl: "https://example.test/cloud" }, api: new ApiClient("https://example.test/cloud", tokens), tokens, @@ -461,6 +467,7 @@ test("managed chat routes local slash hooks sequentially and never sends their c let initialized = false; let closeSession = false; globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith("/readiness")) return readiness(); if (String(url).endsWith("/thread")) return new Response(JSON.stringify({ id: "thread-1" })); if (String(url).includes("/messages")) { if (init?.method === "POST") { sends++; throw new Error("must not send local commands"); } @@ -494,6 +501,7 @@ test("managed TTY queues Shift-Tab after the active command and removes its list const order: string[] = []; let started = false; globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).endsWith("/readiness")) return readiness(); if (String(url).endsWith("/thread")) return new Response(JSON.stringify({ id: "thread-1" })); if (String(url).includes("/messages")) { if (!started) { started = true; setImmediate(() => input.write("/ats wait\n")); } @@ -555,6 +563,7 @@ test("background visual status preserves an edited TTY draft through the real re const timeout = new AbortController(); const deadline = setTimeout(() => timeout.abort(), 2000); globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith("/readiness")) return readiness(); if (String(url).endsWith("/thread")) return new Response(JSON.stringify({ id: "thread-1" })); if (String(url).includes("/messages")) { if (init?.method === "POST") { diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index f3359d24..961b1a9d 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -143,6 +143,7 @@ test("fast doctor is local, ordered, fail-soft, and content-redacted", async () "agent.transport", "auth.credential", "agent.catalog", + "online.terminal.readiness", "tools.schemas", "tools.gates", "memory.health", diff --git a/test/doctor_live.test.ts b/test/doctor_live.test.ts index 026afc68..5be4f4f6 100644 --- a/test/doctor_live.test.ts +++ b/test/doctor_live.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +9,7 @@ import type { McpClient } from "../src/core/mcp.js"; import { LocalMcpStore } from "../src/core/mcp_store.js"; import type { RunResult, Runner } from "../src/core/worktree.js"; import type { HealthCheck, HealthReport } from "../src/core/health.js"; +import { renderHealthReport } from "../src/core/health.js"; import { CLOUD_DOCTOR_CONTRACT_COMMIT, CLOUD_DOCTOR_PROBE_CONTENT, @@ -549,15 +551,36 @@ test("signed out means the agent loop is unproven, not failed", async () => { ); }); +test("live doctor reports registry and DM gates separately in human and JSON output", async () => { + const { ctx } = fakeCtx(); + (ctx.api as unknown as { getJson: (path: string) => Promise }).getJson = async (path) => { + if (path.endsWith("/readiness")) return { + schema_version: "aether.terminal-readiness/1", required_contract: "aether.managed-agents/1.1", + registry: { state: "enabled", code: "READY", reason: "Ready", remedy: "None" }, + dm: { state: "disabled", code: "DM_DISABLED", reason: "Disabled", remedy: "Enable DMs" }, + model_uvt: { state: "disabled", code: "ADMISSION_DISABLED", reason: "Disabled", remedy: "Enable admission" }, + }; + return { models: [{ id: "sonnet", kind: "model", available: true }], tier: "pro", default: "sonnet" }; + }; + const report = await liveReport(ctx, liveOpts()); + assert.equal(find(report, "online.terminal.registry").verified.state, "yes"); + assert.equal(find(report, "online.terminal.dm").verified.evidence, "DM_DISABLED"); + assert.equal(find(report, "online.terminal.model_uvt").verified.evidence, "ADMISSION_DISABLED"); + assert.match(JSON.stringify(report), /DM_DISABLED/); + assert.match(renderHealthReport(report), /DM_DISABLED/); +}); + test("a live run leaves no doctor sandbox behind", async () => { - const before = readdirSync(tmpdir()).filter((n) => n.startsWith("aether-doctor-")).length; + const runId = randomUUID(); + const prefix = `aether-doctor-${runId.slice(0, 8)}-`; + const before = readdirSync(tmpdir()).filter((n) => n.startsWith(prefix)); const { ctx } = fakeCtx({ created: { session_id: "sess-0198f4c2", purpose: "doctor", billable: false }, frames: CLOUD_DOCTOR_FRAMES, }); - await liveReport(ctx, liveOpts()); - const after = readdirSync(tmpdir()).filter((n) => n.startsWith("aether-doctor-")).length; - assert.ok(after <= before, "the doctor sandbox was not cleaned up"); + await liveReport(ctx, liveOpts({ runId })); + const after = readdirSync(tmpdir()).filter((n) => n.startsWith(prefix)); + assert.deepEqual(after, before, "the doctor sandbox was not cleaned up"); }); test("a live report never leaks the stored credential", async () => { diff --git a/test/managed_agents.test.ts b/test/managed_agents.test.ts index d4a3a4f8..e5047466 100644 --- a/test/managed_agents.test.ts +++ b/test/managed_agents.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { visibleWidth } from "../src/ui/text.js"; import { Writable } from "node:stream"; -import { ManagedAgentsClient, managedAgentError, type ManagedAgent } from "../src/core/managed_agents.js"; +import { ManagedAgentsClient, managedAgentError, probeManagedReadiness, type ManagedAgent } from "../src/core/managed_agents.js"; import { ApiClient } from "../src/core/transport.js"; import { StaticTokenStore } from "../src/core/auth.js"; import { HttpError } from "../src/core/errors.js"; @@ -22,9 +22,16 @@ const agent: ManagedAgent = { runtime: { observation: "unavailable", tile_state: "draft" }, }; const envelope = (payload: Record): Record => ({ schema_version: "aether.managed-agents/1", availability: "ok", ...payload }); +const readiness = (registry = "enabled", dm = registry === "enabled" ? "enabled" : "disabled", model = dm === "enabled" ? "enabled" : "disabled") => ({ + schema_version: "aether.terminal-readiness/1", required_contract: "aether.managed-agents/1.1", + registry: { state: registry, code: registry === "enabled" ? "READY" : "ACCOUNT_DISABLED", reason: "Registry status.", remedy: "Check the account." }, + dm: { state: dm, code: dm === "enabled" ? "READY" : "DM_DISABLED", reason: "DM status.", remedy: "Check Online." }, + model_uvt: { state: model, code: model === "enabled" ? "ADMISSION_AVAILABLE" : "ADMISSION_DISABLED", reason: "Admission status.", remedy: "Check UVT." }, +}); const api = (): ApiClient => new ApiClient("https://example.test/cloud", new StaticTokenStore("aek_test_cli")); function context(): AppContext { - return { cfg: { ...DEFAULT_CONFIG }, api: api(), tokens: new StaticTokenStore("aek_test_cli"), flags: { json: false, audit: false, yes: false, cwd: process.cwd() }, confirm: async () => false }; + const tokens = new StaticTokenStore("aek_test_cli"); + return { cfg: { ...DEFAULT_CONFIG }, api: new ApiClient("https://example.test/cloud", tokens), tokens, flags: { json: false, audit: false, yes: false, cwd: process.cwd() }, confirm: async () => false }; } function capture(): { out: Writable; text: () => string } { let text = ""; @@ -104,6 +111,7 @@ test("DM send uses canonical conversation and nonce; no coding or chat runtime e const output = capture(); await stubFetch((url, init) => { paths.push(url.pathname); + if (url.pathname.endsWith("/readiness")) return json(readiness()); if (url.pathname.endsWith("/thread")) return json({ id: THREAD }); if (url.pathname.endsWith("/messages")) { assert.equal(url.searchParams.get("conversation_id"), THREAD); @@ -114,11 +122,11 @@ test("DM send uses canonical conversation and nonce; no coding or chat runtime e } return json(envelope({ agent })); }, async () => { - assert.equal(await cmdManagedAgentChat(context(), ID, "Review my strategy", { out: output.out, err: output.out, hooks: { beforeChat: async () => async () => { closed = true; } } }), 0); + assert.equal(await cmdManagedAgentChat(context(), ID, "Review my strategy", { out: output.out, err: output.out, hooks: { beforeChat: async () => async () => { closed = true; } } }), 1); }); assert.equal(closed, true); assert.match(output.text(), /blocked_budget/); - assert.equal(paths.length, 3); + assert.equal(paths.length, 4); assert.ok(paths.every((path) => path.includes("/agent/managed/"))); }); @@ -126,6 +134,7 @@ test("one-shot managed chat closes its attached session before aborting the life let abortedDuringClose: boolean | undefined; const output = capture(); await stubFetch((url) => { + if (url.pathname.endsWith("/readiness")) return json(readiness()); if (url.pathname.endsWith("/thread")) return json({ id: THREAD }); if (url.pathname.endsWith("/messages")) return json({ id: "message-1", body: "canary", sender_type: "user", admission: { state: "admitted" } }); return json(envelope({ agent })); @@ -142,6 +151,7 @@ test("failed DM delivery is reported as uncertain and never automatically resent let sends = 0; const output = capture(); await stubFetch((url) => { + if (url.pathname.endsWith("/readiness")) return json(readiness()); if (url.pathname.endsWith("/thread")) return json({ id: THREAD }); if (url.pathname.endsWith("/messages")) { sends++; throw new Error("network unavailable"); } return json(envelope({ agent })); @@ -231,3 +241,46 @@ test("inventory accepts both additive contracts and rejects unknown typed profil await stubFetch(() => json(envelope({ agents: [{ ...agent, config: { ...agent.config, profile: invalid } }] })), async () => { await assert.rejects(new ManagedAgentsClient(api()).list(), /unsupported managed-agent profile/); }); } }); + +test("readiness classifies entitlement, credential, contract and outage without leaking response details", async () => { + const cases: Array<[Response, string]> = [ + [json(readiness("disabled")), "ACCOUNT_DISABLED"], + [json(readiness("enabled", "disabled")), "DM_DISABLED"], + [json({ detail: { code: "WRONG_CREDENTIAL_CLASS", secret: "aek_private" } }, 403), "WRONG_CREDENTIAL_CLASS"], + [json({ detail: "not deployed" }, 404), "INCOMPATIBLE_CONTRACT"], + [json({ ...readiness(), required_contract: "aether.managed-agents/9" }), "INCOMPATIBLE_CONTRACT"], + [json({ detail: "aek_private" }, 503), "TEMPORARILY_UNAVAILABLE"], + ]; + for (const [response, expected] of cases) { + await stubFetch(() => response.clone(), async () => { + const result = await probeManagedReadiness(api()); + assert.ok(JSON.stringify(result).includes(expected)); + assert.equal(JSON.stringify(result).includes("aek_private"), false); + }); + } +}); + +test("list and chat use fresh readiness after account switch and avoid unavailable operations", async () => { + let owner = "first"; + const calls: string[] = []; + await stubFetch((url, init) => { + calls.push(`${owner}:${url.pathname}`); + assert.equal(new Headers(init.headers).get("Authorization"), `Bearer aek_${owner === "first" ? "test" : "second"}_cli`); + if (url.pathname.endsWith("/readiness")) return json(readiness(owner === "first" ? "enabled" : "disabled")); + return json(envelope({ agents: [agent], next_cursor: null })); + }, async () => { + const ctx = context(); + const first = capture(); + assert.equal(await cmdManagedAgents(ctx, ["list"], { out: first.out, err: first.out }), 0); + assert.match(first.text(), /registry: enabled/); + owner = "second"; + await ctx.tokens.set("aek_second_cli"); + const second = capture(); + ctx.flags.json = true; + assert.equal(await cmdManagedAgents(ctx, ["list"], { out: second.out, err: second.out }), 1); + assert.equal(JSON.parse(second.text()).error.code, "ACCOUNT_DISABLED"); + assert.equal(await cmdManagedAgentChat(ctx, ID, "hello", { out: second.out, err: second.out }), 1); + }); + assert.equal(calls.filter(path => path.endsWith("/readiness")).length, 3); + assert.equal(calls.filter(path => path.endsWith("/agent/managed")).length, 1); +}); From e7d40cadeee473e5d2a72a7bfa19c24ac9a28d18 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:43:57 -0400 Subject: [PATCH 2/4] fix(online): preserve cancelled readiness probes --- src/core/managed_agents.ts | 1 + test/managed_agents.test.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/core/managed_agents.ts b/src/core/managed_agents.ts index 54bc4564..d8e1a437 100644 --- a/src/core/managed_agents.ts +++ b/src/core/managed_agents.ts @@ -74,6 +74,7 @@ export async function probeManagedReadiness(api: ApiClient, signal?: AbortSignal return { schema_version: TERMINAL_READINESS_CONTRACT, required_contract: String(raw["required_contract"]), registry: gates.registry!, dm: gates.dm!, model_uvt: gates.model_uvt! }; } catch (error) { + if (signal?.aborted) throw error; if (error instanceof HttpError) { const body = error.body && typeof error.body === "object" ? error.body as Record : {}; const detail = body["detail"] && typeof body["detail"] === "object" ? body["detail"] as Record : {}; diff --git a/test/managed_agents.test.ts b/test/managed_agents.test.ts index e5047466..998d339e 100644 --- a/test/managed_agents.test.ts +++ b/test/managed_agents.test.ts @@ -260,6 +260,12 @@ test("readiness classifies entitlement, credential, contract and outage without } }); +test("a cancelled readiness probe stays cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + await assert.rejects(probeManagedReadiness(api(), controller.signal)); +}); + test("list and chat use fresh readiness after account switch and avoid unavailable operations", async () => { let owner = "first"; const calls: string[] = []; From 9a8fbfc71bb28d4c91c453c04b02bb6fa18daf75 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:46:33 -0400 Subject: [PATCH 3/4] fix(online): report operation refusal accurately --- src/core/managed_agents.ts | 8 ++++++-- test/managed_agents.test.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/core/managed_agents.ts b/src/core/managed_agents.ts index d8e1a437..02f6882e 100644 --- a/src/core/managed_agents.ts +++ b/src/core/managed_agents.ts @@ -250,9 +250,13 @@ export class ManagedAgentsClient { /** Stable, actionable errors without printing response bodies that may contain secrets. */ export function managedAgentError(error: unknown): string { if (error instanceof HttpError) { + const body = error.body && typeof error.body === "object" ? error.body as Record : {}; + const detail = body["detail"] && typeof body["detail"] === "object" ? body["detail"] as Record : {}; + if (detail["code"] === "WRONG_CREDENTIAL_CLASS") return "This credential cannot access terminal agents. Sign in with `aether auth login`."; if (error.status === 401) return "Sign in again with `aether auth login`, then retry."; - if (error.status === 403) return "Managed agents or agent DMs are not enabled for this account, or this token is not a CLI token. Check the Agents page and `aether auth login`."; - if (error.status === 404) return "Agent not found or this server has not deployed terminal agents yet. Refresh `aether agent list`."; + if (error.status === 403 && detail["code"] === "FEATURE_DISABLED") return "Managed agents or agent DMs are not enabled for this account. Check the Agents page."; + if (error.status === 403) return "Cloud refused this agent request (HTTP 403). Run `aether doctor --live` for current account readiness."; + if (error.status === 404) return "Agent not found in this account. Refresh `aether agent list`."; if (error.status === 409) return "This agent changed elsewhere. Refresh it and reapply your edit; no automatic overwrite was attempted."; if (error.status === 503) return "The agent service is unavailable. Check the account before retrying a change."; return `Cloud refused the request (HTTP ${error.status}). Check the agent settings on the web.`; diff --git a/test/managed_agents.test.ts b/test/managed_agents.test.ts index 998d339e..310fec3f 100644 --- a/test/managed_agents.test.ts +++ b/test/managed_agents.test.ts @@ -198,8 +198,11 @@ test("untrusted labels are sanitized and runtime unavailable stays visible", () const rows = renderManagedAgents([{ ...agent, config: { identity: { display_name: "bad\x1b]52;c;secret\x07name" } } }]); assert.equal(rows.includes("\x1b]52"), false); assert.match(rows, /unavailable/); - assert.match(managedAgentError(new HttpError(403, "aek_secret", { token: "aek_secret" })), /not enabled/); + assert.match(managedAgentError(new HttpError(403, "aek_secret", { token: "aek_secret" })), /Cloud refused/); assert.equal(managedAgentError(new HttpError(403, "aek_secret")).includes("aek_secret"), false); + assert.match(managedAgentError(new HttpError(403, "ignored", { detail: { code: "WRONG_CREDENTIAL_CLASS" } })), /credential cannot access/); + assert.match(managedAgentError(new HttpError(403, "ignored", { detail: { code: "FEATURE_DISABLED" } })), /not enabled for this account/); + assert.match(managedAgentError(new HttpError(404, "ignored")), /not found in this account/); }); for (const columns of [40, 60, 80, 120]) { From e2c89e3a1435ddc4f2bc84fc33bec8d5998ba0e3 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:50:20 -0400 Subject: [PATCH 4/4] fix(online): avoid assuming cause of unknown readiness refusal --- src/core/managed_agents.ts | 2 +- test/managed_agents.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/managed_agents.ts b/src/core/managed_agents.ts index 02f6882e..a2afbd08 100644 --- a/src/core/managed_agents.ts +++ b/src/core/managed_agents.ts @@ -80,7 +80,7 @@ export async function probeManagedReadiness(api: ApiClient, signal?: AbortSignal const detail = body["detail"] && typeof body["detail"] === "object" ? body["detail"] as Record : {}; if (detail["code"] === "WRONG_CREDENTIAL_CLASS") return unavailable("WRONG_CREDENTIAL_CLASS", "This credential cannot access terminal agents.", "Sign in with `aether auth login`."); if (error.status === 401) return unavailable("AUTH_REQUIRED", "The CLI account credential is invalid or expired.", "Sign in with `aether auth login`."); - if (error.status === 403) return unavailable("ACCOUNT_DISABLED", "Terminal agents are disabled for this account.", "Ask the account administrator to enable managed agents."); + if (error.status === 403) return unavailable("READINESS_REFUSED", "Cloud refused account readiness.", "Check account sign-in and retry, or contact the account administrator."); if (error.status === 404) return unavailable("INCOMPATIBLE_CONTRACT", "Cloud has not deployed the terminal readiness contract.", "Update the terminal and Cloud adapter."); } return unavailable("TEMPORARILY_UNAVAILABLE", "Terminal agent readiness could not be checked.", "Retry when Cloud is available."); diff --git a/test/managed_agents.test.ts b/test/managed_agents.test.ts index 310fec3f..b2e257be 100644 --- a/test/managed_agents.test.ts +++ b/test/managed_agents.test.ts @@ -250,6 +250,7 @@ test("readiness classifies entitlement, credential, contract and outage without [json(readiness("disabled")), "ACCOUNT_DISABLED"], [json(readiness("enabled", "disabled")), "DM_DISABLED"], [json({ detail: { code: "WRONG_CREDENTIAL_CLASS", secret: "aek_private" } }, 403), "WRONG_CREDENTIAL_CLASS"], + [json({ detail: "aek_private" }, 403), "READINESS_REFUSED"], [json({ detail: "not deployed" }, 404), "INCOMPATIBLE_CONTRACT"], [json({ ...readiness(), required_contract: "aether.managed-agents/9" }), "INCOMPATIBLE_CONTRACT"], [json({ detail: "aek_private" }, 503), "TEMPORARILY_UNAVAILABLE"],