diff --git a/apps/controller/src/console/agents/access-reasons.mts b/apps/controller/src/console/agents/access-reasons.mts index 51ecd7d1ac..eaa4d059ed 100644 --- a/apps/controller/src/console/agents/access-reasons.mts +++ b/apps/controller/src/console/agents/access-reasons.mts @@ -107,7 +107,12 @@ export const LOG_TEXT_DENIED = `Log text requires ${LOG_TEXT_GRANT} on this Agen // Runtime status authorizes Agent operate and read plus AgentRevision read on the version. export const RUNTIME_STATUS_DENIED = `Runtime status requires Agent operate on this Agent and AgentRevision read on this version. Log text needs ${LOG_TEXT_GRANT}. ${askForGrant("the access you need")}`; -// Saving a workspace file and running diagnostics authorize Agent operate. +// Reading a workspace file authorizes Agent read; saving one and running diagnostics, Agent +// operate. +export const WORKSPACE_READ_DENIED = notIncluded( + "reading this Agent's workspace files", + "Agent read on it", +); export const WORKSPACE_SAVE_DENIED = notIncluded( "saving this Agent's workspace files", "Agent operate on it", diff --git a/apps/controller/src/console/agents/access.mts b/apps/controller/src/console/agents/access.mts index 164fd33bb3..3812a3f2e3 100644 --- a/apps/controller/src/console/agents/access.mts +++ b/apps/controller/src/console/agents/access.mts @@ -7,6 +7,7 @@ import { setStatusTone, statusBlock, validateOnSubmit, + withRequestId, } from "../dom.mjs"; import { type AgentAccessContext, @@ -268,7 +269,7 @@ export function renderAgentAccess( : message(error, mutation); state.error += " Refresh sharing to inspect current policy before another change."; if (requestId) { - state.error += ` Request ID: ${requestId}`; + state.error = withRequestId(state.error, requestId); } } diff --git a/apps/controller/src/console/agents/collection.mts b/apps/controller/src/console/agents/collection.mts index 69f04cda37..bcde039752 100644 --- a/apps/controller/src/console/agents/collection.mts +++ b/apps/controller/src/console/agents/collection.mts @@ -9,7 +9,7 @@ import type { } from "@openclaw-enterprise/contracts"; import type { Static } from "typebox"; import { type RequestOptions, requestFailure } from "../api-client.mjs"; -import { button, element, setText, statusBlock } from "../dom.mjs"; +import { button, element, setText, statusBlock, requestIdLine } from "../dom.mjs"; import type { createDraftStore } from "../drafts.mjs"; import { DELETE_CONFIRMATION, stopConfirmation, keptResources } from "./lifecycle-confirmation.mjs"; import { @@ -451,9 +451,7 @@ export function renderAgentList(context: AgentListContext) { "div", { className: "status", "data-tone": "danger", role: "alert" }, element("p", { className: "status-body" }, error), - requestId - ? element("p", { className: "request-id" }, `Request ID: ${requestId}`) - : null, + requestId ? requestIdLine(requestId) : null, ), ] : []), @@ -563,7 +561,7 @@ export function renderAgentList(context: AgentListContext) { notice: stopped.status === "deleting" ? `${item.name} is being deleted.` - : `Stop requested for ${item.name}. OCC will not start it again until you deploy a new version. Runtime shutdown completion is not exposed in Console.`, + : `Stop requested for ${item.name}. It will not start again until you deploy a new version. The console does not show when its runtime finishes shutting down.`, }); } catch (error) { reportFailure(item, error, "stop"); diff --git a/apps/controller/src/console/agents/create.mts b/apps/controller/src/console/agents/create.mts index 84c0dbc1d0..d1bddcf5c2 100644 --- a/apps/controller/src/console/agents/create.mts +++ b/apps/controller/src/console/agents/create.mts @@ -31,11 +31,13 @@ import { setText, statusBlock, validateFields, + requestIdText, + withRequestId, } from "../dom.mjs"; import { WORKSPACE_DEFAULTS, WORKSPACE_DEFAULTS_ID } from "../workspace-defaults.mjs"; import { configuredHarnessId, harnessAuthDescription } from "./harness-auth.mjs"; import { type DeviceLogin, createDeviceLogin } from "./device-login.mjs"; -import { AGENT_CREATE_DENIED, askAdministratorTo } from "./access-reasons.mjs"; +import { AGENT_CREATE_DENIED, askAdministratorTo, notIncluded } from "./access-reasons.mjs"; import { createRepositoryFields } from "./repositories.mjs"; import { ensureCredentialOperateBinding, secretOperateAnswer } from "./credential-access.mjs"; import { @@ -62,8 +64,10 @@ import { NAMESPACE_MISSING, NAMESPACE_RECHECK_MS, namespaceCreation, + namespaceListNeed, namespacePath, preAdmissionRefusal, + readFailure, rejectionMessage, } from "./list.mjs"; @@ -339,6 +343,26 @@ function credentialRegistrationResult( return { id, state }; } +// Why removing a failed credential registration failed and the next step (finding 2538): a +// refusal names its grant (DELETE authorizes credential source delete on the source), and only +// an unanswered request suggests the Credential Gateway was unavailable. +function credentialRemovalFailure(cause: unknown) { + const { status } = requestFailure(cause); + if (status === 403) { + return notIncluded("removing this credential registration", "credential source delete on it"); + } + if (status === 404) { + return "This credential registration is no longer available. Select Check credential registration."; + } + if (status === 400 || status === 409 || status === 429) { + return `The failed credential registration was not removed. ${describeFailure(cause, { + mutation: true, + next: "Select Check credential registration, then try again.", + })}`; + } + return "The failed credential registration could not be removed: the Credential Gateway did not confirm it. Retry removal when the Credential Gateway is available."; +} + function createClientRequestId() { return `req_${crypto.randomUUID()}`; } @@ -1811,7 +1835,12 @@ function renderAgentForm( } } catch (error) { if (context.isCurrent()) { - feedback.textContent = message(error); + // The list checks Namespace read, like the other Namespace lists (finding 2538). + feedback.textContent = readFailure( + "Credential registrations", + error, + namespaceListNeed("credential sources"), + ); } } finally { if (context.isCurrent()) { @@ -1843,7 +1872,7 @@ function renderAgentForm( "Failed credential registration removed. Select Create Agent to retry with the saved model Secret."; } catch (error) { if (context.isCurrent()) { - feedback.textContent = `${message(error)} Retry removal when the Credential Gateway is available; the model Secret remains saved.`; + feedback.textContent = `${credentialRemovalFailure(error)} The model Secret remains saved.`; } } finally { if (context.isCurrent()) { @@ -3092,7 +3121,7 @@ function renderAgentForm( } else { detail = followableRefusal(createRefusal(cause, mutationStarted)); } - const feedbackText = detail + (error.requestId ? ` Request ID: ${error.requestId}` : ""); + const feedbackText = withRequestId(detail, error.requestId); // A job that ended keeps its final status (none once it failed: the error line says so); // any other progress line spoke for the request that just ended (finding 1490). if (!terminal) { @@ -3386,7 +3415,7 @@ function renderAgentForm( refusedInConfiguration = showChannelBindingRefusal(error); feedback.textContent = refusedInConfiguration ? "" - : detail + (failure.requestId ? ` Request ID: ${failure.requestId}` : ""); + : withRequestId(detail, failure.requestId); // While the pressed control is still disabled, as after a save. focusFeedbackLine(feedback); if (nameTaken) { @@ -3656,7 +3685,8 @@ function renderAgentForm( context.onExpired(); return; } - const registrationProblem = error.status && error.status >= 400 ? ` ${message(cause)}` : ""; + const registrationProblem = + error.status && error.status >= 400 ? ` ${describeFailure(cause, { mutation: true })}` : ""; // A refused grant on a saved Agent shows this text only to an Installation administrator (a // member sees MEMBER_GRANT_REFUSED below), so its denial names nobody to ask (finding 2113). const detail = credentialRegistration @@ -3716,7 +3746,7 @@ function renderAgentForm( ? "" : (memberGrantRefused ? MEMBER_GRANT_REFUSED : detail) + guidance + - (error.requestId ? ` Request ID: ${error.requestId}` : ""); + (error.requestId ? ` ${requestIdText(error.requestId)}` : ""); focusFeedbackLine(feedback); // A create that raced another Agent to the name (the preflight checked it). if (!savedAgent && error.status === 409 && error.serverMessage === AGENT_NAME_CONFLICT) { diff --git a/apps/controller/src/console/agents/deletion.mts b/apps/controller/src/console/agents/deletion.mts index a11f7ed341..50c8336b02 100644 --- a/apps/controller/src/console/agents/deletion.mts +++ b/apps/controller/src/console/agents/deletion.mts @@ -7,6 +7,7 @@ import { setHidden, setText, statusBlock, + requestIdLine, } from "../dom.mjs"; import { type AgentLifecycleContext, @@ -15,7 +16,7 @@ import { keptResources, } from "./lifecycle-confirmation.mjs"; import { AGENT_DELETE_DENIED, askForGrant } from "./access-reasons.mjs"; -import { describeFailure } from "./list.mjs"; +import { AGENT_STATUS_READ_NEED, describeFailure } from "./list.mjs"; // Deletion finishes in the background; poll until the Agent is gone, then return to the list. export const DELETION_POLL_MS = 3000; @@ -112,9 +113,7 @@ export function createAgentDeletion( "div", { className: "status", "data-tone": "danger", role: "alert" }, element("p", { className: "status-body" }, state.error.text), - state.error.requestId - ? element("p", { className: "request-id" }, `Request ID: ${state.error.requestId}`) - : null, + state.error.requestId ? requestIdLine(state.error.requestId) : null, ), ] : []), @@ -231,7 +230,7 @@ export function createAgentDeletion( clearTimeout(pollTimer); } else { state.error = { - text: `Could not refresh deletion status. ${describeFailure(cause)}`, + text: `Could not refresh deletion status. ${describeFailure(cause, { need: AGENT_STATUS_READ_NEED })}`, requestId: error.requestId, }; // Stop polling on errors; the reader can retry with Refresh deletion status. diff --git a/apps/controller/src/console/agents/deployment-evidence.mts b/apps/controller/src/console/agents/deployment-evidence.mts index 4817eaa753..aa12326d2f 100644 --- a/apps/controller/src/console/agents/deployment-evidence.mts +++ b/apps/controller/src/console/agents/deployment-evidence.mts @@ -16,6 +16,7 @@ import { message, neutralDeploymentOutcome, outcomeLabel, + readFailureReason, } from "./list.mjs"; import { pluginWarningText } from "./plugins.mjs"; @@ -74,9 +75,10 @@ function runtimeFailureCauseText(cause: RuntimeFailureCause) { if (cause.kind === "PROBE_STATUS" && (detail === "rate_limit" || detail === "billing")) { return PROBE_STATUS_DETAIL_TEXT[detail] + retry; } + // A kind this console does not know gets a sentence, not its raw code (finding 2538). const label = Object.hasOwn(RUNTIME_FAILURE_CAUSE_LABELS, cause.kind) ? RUNTIME_FAILURE_CAUSE_LABELS[cause.kind] - : cause.kind; + : "The runtime check failed"; if (!detail) { return label; } @@ -323,6 +325,8 @@ export function createDeploymentStatusPanel( let record: Deployment | null = null; let error: unknown = null; let overviewUnavailable = false; + // The refused or failed overview read, when one was, so its text says why (finding 2538). + let overviewError: unknown = null; // Tallest the details have been since a deploy was seen pending; see reserveHeight(). let reservedHeight: number | null = null; const heightObserver = @@ -368,6 +372,7 @@ export function createDeploymentStatusPanel( polling = poll; error = null; overviewUnavailable = false; + overviewError = null; render(); try { const [statusResult, agentResult, revisionsResult] = await Promise.allSettled([ @@ -408,6 +413,10 @@ export function createDeploymentStatusPanel( } } else { overviewUnavailable = true; + overviewError = + [agentResult, revisionsResult].find( + (result): result is PromiseRejectedResult => result.status === "rejected", + )?.reason ?? null; } } } catch (cause) { @@ -551,7 +560,11 @@ export function createDeploymentStatusPanel( statusBlock( "danger", null, - "Current version and version history could not be refreshed. Use the page Refresh control to retry.", + `Current version and version history could not be refreshed. ${ + overviewError === null + ? "Use the page Refresh control to retry." + : readFailureReason(overviewError) + }`, ), ] : []), diff --git a/apps/controller/src/console/agents/detail.mts b/apps/controller/src/console/agents/detail.mts index ee80164116..de8b55bff4 100644 --- a/apps/controller/src/console/agents/detail.mts +++ b/apps/controller/src/console/agents/detail.mts @@ -28,6 +28,8 @@ import { setText, statusBlock, validateFields, + requestIdLine, + requestIdText, } from "../dom.mjs"; import { configuredHarnessId, @@ -66,6 +68,7 @@ import { secretOperateRefusal, channelBindingRefusal, agentSaveDenial, + describeFailure, message, rejectionMessage, assertReadableConfiguration, @@ -249,7 +252,7 @@ function deployRefusedBeforeAdmission(error: unknown) { function deployFailureText(cause: unknown, submitted: boolean) { const error = requestFailure(cause); if (error.code === "RUNTIME_CREDENTIALS_CLUSTER_RBAC") { - return "Deployment refused: the cluster denied OCC access to this Agent's connection credentials. Ask a platform operator to grant the documented tenant RoleBindings, then deploy again."; + return "Deployment refused: the cluster denied the control plane access to this Agent's connection credentials. Ask an operator to grant the control plane the Kubernetes RoleBindings this Namespace needs, then deploy again."; } const refusal = submitted ? preAdmissionRefusal(error) : undefined; if (refusal !== undefined) { @@ -338,9 +341,7 @@ function errorPanel( "This tab remembers the earlier denial. Retry checks your access again.", ) : null, - error.requestId - ? element("p", { className: "request-id" }, `Request ID: ${error.requestId}`) - : null, + error.requestId ? requestIdLine(error.requestId) : null, button("Retry", retry), ); } @@ -1004,7 +1005,7 @@ export async function renderAgentDetail( // (finding 2367); a Sandbox note takes its place. const unchecked = tone === undefined && !deleting && currentRuntimeState === "running" && currentRevisionId - ? "OCC sends no test request." + ? "OpenClaw Enterprise sends no test request." : ""; if (!expected) { setText(liveServingNote, unchecked); @@ -2994,7 +2995,7 @@ export async function renderAgentDetail( // A rejected operator-managed credential is fixed on the runtime host, not by Deploy. deployStatus.textContent = credentialRejected() ? "The last deployment's model credential was rejected; ask an operator to replace it on the runtime host before deploying again." - : "Configured on the runtime host; not validated by OCC. Gateway readiness does not confirm model access."; + : "Configured on the runtime host; OpenClaw Enterprise does not check it. Gateway readiness does not confirm model access."; } else { const gate = credentials?.deployGateMessage() ?? "Credential readiness could not be confirmed."; @@ -4566,7 +4567,7 @@ export async function renderAgentDetail( context.onExpired(); } else if (savedAuthentication) { const { status, requestId } = requestFailure(error); - const requestNote = requestId ? ` Request ID: ${requestId}` : ""; + const requestNote = requestId ? ` ${requestIdText(requestId)}` : ""; if (!grantRequested) { // A retry rereads the Agent before any grant request: its failure is about that // read, not the grant, and Retry stays for when the Agent reads again. @@ -4581,7 +4582,8 @@ export async function renderAgentDetail( grantProblem = grantProblemFor(status) ?? "unconfirmed"; say( grantText(grantProblem) + - (grantProblem === "refused" ? "" : ` ${message(error)}`) + + // The grant request's own reason, never a form's "entered values". + (grantProblem === "refused" ? "" : ` ${describeFailure(error)}`) + requestNote, "warning", ); diff --git a/apps/controller/src/console/agents/harness-auth.mts b/apps/controller/src/console/agents/harness-auth.mts index a51e1e6137..e74a580ea3 100644 --- a/apps/controller/src/console/agents/harness-auth.mts +++ b/apps/controller/src/console/agents/harness-auth.mts @@ -218,12 +218,12 @@ export function createHarnessAuthFields( const runtimeHint = element( "p", { className: "hint" }, - "Configured on the runtime host; not validated by OCC.", + "Configured on the runtime host; OpenClaw Enterprise does not check it.", ); const validationHint = element( "p", { className: "hint" }, - "Managed sources are checked during deployment. Operator-managed credentials are not validated by OCC. Selection does not establish provider login or model readiness.", + "Managed sources are checked during deployment. OpenClaw Enterprise does not check operator-managed credentials. Selection does not establish provider login or model readiness.", ); const section = element( "fieldset", @@ -353,7 +353,7 @@ export function createHarnessAuthFields( (method.value !== "api_key" && method.value !== "codex_pat") || !selectedSecretSource?.id ) { - throw new Error("Choose an OCC Secret."); + throw new Error("Choose a Secret."); } return method.value === "api_key" ? { method: "api_key", source: selectedSecretSource } diff --git a/apps/controller/src/console/agents/list.mts b/apps/controller/src/console/agents/list.mts index 80497c12cf..423459235f 100644 --- a/apps/controller/src/console/agents/list.mts +++ b/apps/controller/src/console/agents/list.mts @@ -36,8 +36,9 @@ export function link(label: string, target: string, context: AgentNavigation) { // A Namespace that is still provisioning (or failed to) refuses Agent, Configuration and // deployment writes with NAMESPACE_NOT_READY; "conflicts with the saved state" would send the // operator to the wrong place. +// Every view that meets NAMESPACE_NOT_READY shows this one sentence (finding 2538). export const NAMESPACE_NOT_READY_MESSAGE = - "This Namespace is not ready yet. Check its status on the Namespaces page: a provisioning Namespace becomes ready when its Kubernetes setup completes (on Kubernetes installs, after an operator grants the tenant RoleBindings)."; + "This Namespace is not ready yet. Check its status on the Namespaces page: a provisioning Namespace becomes ready when its Kubernetes setup completes. If it stays provisioning, ask an operator to finish that setup."; // Why a Namespace can't take new Agents, by status. Neither becomes ready by waiting. const NOT_READY_REASONS: Partial> = { @@ -115,6 +116,12 @@ export const namespaceListNeed = (items: string): AccessNeed => ({ grant: "Namespace read", }); +// The Stop and Delete pages reread the Agent for its status, which authorizes Agent read. +export const AGENT_STATUS_READ_NEED: AccessNeed = { + task: "reading this Agent's status", + grant: "Agent read on it", +}; + // A 403 that names no grant. message() adds who grants access; administratorMessage() does not. const PLAIN_ACCESS_DENIED = "Access denied. You do not have permission for this operation."; @@ -425,11 +432,13 @@ export function describeFailure( form = false, next, conflict = "The request conflicts with the current state.", + need, }: { mutation?: boolean; form?: boolean; next?: string; conflict?: string; + need?: AccessNeed; } = {}, ): string { const error = requestFailure(cause); @@ -457,9 +466,37 @@ export function describeFailure( if (error.status === 429) { return tooManyRequests(cause); } - return message(cause, mutation); + return message(cause, mutation, need); +} + +// An outage names who acts if it continues, as sign-in's 503 does. +const READ_OUTAGE = + "The service did not answer. Try again; if it continues, ask an operator to check the API."; + +// Why a read failed and what to do next, by status, for the views whose catch once said +// "unavailable, retry" for a denial and an outage alike (finding 2538): a 403 names the grant +// (`need`) and who grants it, a 429 how long to wait, and an outage who to ask if it continues. +export function readFailureReason(cause: unknown, need?: AccessNeed): string { + const error = requestFailure(cause); + if (error.status === 403) { + return message(cause, false, need); + } + if (error.status === 404) { + return "It is no longer available. Refresh the page."; + } + if (error.status === 400 || error.status === 409 || error.status === 429) { + return describeFailure(cause); + } + const { name } = responseFields(cause); + return name === "TypeError" || name === "TimeoutError" + ? "The request was interrupted. Try again." + : READ_OUTAGE; } +/** " could not be read. " for a failed read (readFailureReason). */ +export const readFailure = (subject: string, cause: unknown, need?: AccessNeed) => + `${subject} could not be read. ${readFailureReason(cause, need)}`; + // A channel credential value that Configuration save or provisioning refuses: a reference without // a Secret binding, a `${` the runtime does not substitute, or a value the setting "holds" that is // not one whole reference or a SecretRef it accepts, an inline credential included (findings 2319, diff --git a/apps/controller/src/console/agents/logs-view.mts b/apps/controller/src/console/agents/logs-view.mts index 496608eaa5..489b391593 100644 --- a/apps/controller/src/console/agents/logs-view.mts +++ b/apps/controller/src/console/agents/logs-view.mts @@ -238,7 +238,8 @@ export function recordRow(record: RuntimeLogRecord) { {}, record.reason === "stream_replaced" && record.stream?.source === "sandbox" ? "Sandbox recreated" - : (GAP_LABELS[record.reason] ?? record.reason), + : // A reason this console does not know gets a label, not its raw code (finding 2538). + (GAP_LABELS[record.reason] ?? "Lines missing"), ), element("span", {}, ` ${record.remedy}`), ); diff --git a/apps/controller/src/console/agents/logs.mts b/apps/controller/src/console/agents/logs.mts index 54a183c46a..5839f52fc9 100644 --- a/apps/controller/src/console/agents/logs.mts +++ b/apps/controller/src/console/agents/logs.mts @@ -15,6 +15,7 @@ import { replaceChildrenIfChanged, setText, statusBlock, + withRequestId, } from "../dom.mjs"; import { LOG_TEXT_DENIED, RUNTIME_STATUS_DENIED } from "./access-reasons.mjs"; import { displayDate, namespacePath, retryAfterText } from "./list.mjs"; @@ -151,10 +152,6 @@ function runtimeErrorText( return "Runtime status or logs are unavailable. Try again shortly."; } -function withRequestId(text: string, error: RequestFailure) { - return error.requestId ? `${text} Request ID: ${error.requestId}` : text; -} - /** * Runtime status needs Agent `operate`; log text needs only `read_logs`. Without status * the picker offers every source and names no Pod: OCC reads the source's current Pod @@ -485,13 +482,13 @@ export function renderAgentLogs( showLogError( error.status === 403 && revokedLogViews.has(deniedKey) ? LOGS_ACCESS_CHANGED - : withRequestId(runtimeErrorText(error, "logs", source.id, manualRead), error), + : withRequestId(runtimeErrorText(error, "logs", source.id, manualRead), error.requestId), ); if (manualRead && error.status === 429 && error.retryAfterSeconds !== undefined) { // A later error or a successful read replaces the text and cancels this (finding 1715). // A view held for Back is updated too, so it never returns with a wait that has passed. retryHintTimer = setTimeout(() => { - showLogError(withRequestId("Too many requests. You can try again now.", error)); + showLogError(withRequestId("Too many requests. You can try again now.", error.requestId)); }, error.retryAfterSeconds * 1000); } if (error.status !== 403) { @@ -735,7 +732,7 @@ export function renderAgentLogs( showStripStatus( error.status === 403 && revokedStatusViews.has(statusKey) ? STATUS_ACCESS_CHANGED - : withRequestId(runtimeErrorText(error, "status"), error), + : withRequestId(runtimeErrorText(error, "status"), error.requestId), ); if (error.status === 403) { offerLogsWithoutStatus(); diff --git a/apps/controller/src/console/agents/plugin-discovery.mts b/apps/controller/src/console/agents/plugin-discovery.mts index 2b58172f90..7637b4e76e 100644 --- a/apps/controller/src/console/agents/plugin-discovery.mts +++ b/apps/controller/src/console/agents/plugin-discovery.mts @@ -7,7 +7,8 @@ import { type PluginFieldsOptions, createPluginFields, } from "./plugin-fields.mjs"; -import { namespacePath } from "./list.mjs"; +import { withRequestId } from "../dom.mjs"; +import { describeFailure, namespacePath } from "./list.mjs"; export interface PluginDiscoveryContext { namespaceId: string; @@ -58,7 +59,12 @@ function discoveryError( "The plugin service returned an unsupported response. Retry or contact your operator.", }; const reason = error.code ? reasons[error.code] : undefined; - return `${reason ?? "Plugins could not be loaded. Check the credential and retry."}${error.requestId ? ` Request: ${error.requestId}` : ""}`; + // A rate limit the API itself answered says how long to wait (finding 2538). + const fallback = + error.status === 429 + ? describeFailure(error) + : "Plugins could not be loaded. Check the credential and retry."; + return withRequestId(reason ?? fallback, error.requestId); } export function createPluginDiscovery({ diff --git a/apps/controller/src/console/agents/plugin-fields.mts b/apps/controller/src/console/agents/plugin-fields.mts index 6166a2e2b4..31556d9b8c 100644 --- a/apps/controller/src/console/agents/plugin-fields.mts +++ b/apps/controller/src/console/agents/plugin-fields.mts @@ -188,6 +188,7 @@ export function createPluginFields({ }: PluginFieldsOptions) { let disabled = false; let configuredOnly = false; + let capabilitiesUnread = false; const tabs = [newTabState(), newTabState()] as const; let renderedTab: PluginTabState | null = null; let renderedPlugin: string | null = null; @@ -688,7 +689,7 @@ export function createPluginFields({ // does not make screen readers announce the same words again (finding 1890). setText( policyStatus, - capabilities + capabilities || capabilitiesUnread ? "" : "This installation does not support plugin policy editing. You can browse plugins; existing settings are preserved.", ); @@ -1222,8 +1223,11 @@ export function createPluginFields({ search.value = ""; render(); }, - setCapabilities(value: PluginPolicyCapabilities | null) { + // `unread` says the capabilities read failed, so nothing is known about Installation support; + // the owner shows why (finding 2538). + setCapabilities(value: PluginPolicyCapabilities | null, unread = false) { capabilities = value; + capabilitiesUnread = value === null && unread; render(); }, setCatalog(value: PluginFieldCatalog) { diff --git a/apps/controller/src/console/agents/plugins.mts b/apps/controller/src/console/agents/plugins.mts index fe3d3d30cd..3d73b5ff83 100644 --- a/apps/controller/src/console/agents/plugins.mts +++ b/apps/controller/src/console/agents/plugins.mts @@ -24,6 +24,7 @@ import { SAVE_REFUSALS, assertReadableConfiguration, describeFailure, + readFailure, rejectionMessage, } from "./list.mjs"; import { configuredHarnessId } from "./harness-auth.mjs"; @@ -79,6 +80,12 @@ const PLUGINS_READ_ONLY = export const API_KEY_PLUGIN_MESSAGE = "Codex plugins need a ChatGPT login. This Agent uses an API key, so each selected plugin is disabled when it deploys (PLUGIN_AUTH_REQUIRED). Change its Harness authentication under Credentials to use plugins."; +// The policy capabilities read authorizes Agent update on the Agent. +const PLUGIN_CAPABILITIES_NEED = { + task: "reading this Agent's plugin policy controls", + grant: "Agent update on it", +}; + const PLUGIN_WARNING_EXPLANATIONS = Object.freeze({ PLUGIN_AUTH_REQUIRED: "was disabled for this startup because it is not authenticated: Codex plugins need a ChatGPT login rather than an API key, and some also need their app connected to that account.", @@ -117,11 +124,14 @@ function approverSummary( ]; } -/** One sentence per deployment startup warning, keeping its code for lookup. */ +/** + * One sentence per deployment startup warning, keeping its code for lookup. A code this console + * does not know still gets a sentence and a next step, not the code alone (finding 2538). + */ export function pluginWarningText(warning: PluginDeploymentWarning) { const explanation = Object.hasOwn(PLUGIN_WARNING_EXPLANATIONS, warning.code) ? PLUGIN_WARNING_EXPLANATIONS[warning.code] - : "reported a startup warning."; + : "reported a startup warning this console does not describe. Check the Agent's runtime logs."; return `${warning.pluginId} ${explanation} (${warning.code})`; } @@ -185,6 +195,7 @@ export function renderAgentPlugins( input.value = retained?.text ?? initialText; let catalogCredential: PluginCapabilities["discoveryCredential"] | null = null; let catalogCapabilityChecked = false; + // The failed capabilities read's reason, shown once under the policy controls. let catalogCapabilityError = false; const hasBoundCredential = agent.harnessAuth?.method === "credential_source" || @@ -222,7 +233,7 @@ export function renderAgentPlugins( : !catalogCapabilityChecked ? "Checking plugin catalog availability…" : catalogCapabilityError - ? "Could not check plugin catalog availability. Refresh this page or edit existing plugin selections." + ? "Plugin browsing needs the plugin capabilities, which could not be read. Refresh this page, or edit existing plugin selections." : "Hosted plugin browsing requires a saved ChatGPT login or Service Accounts token Secret. Select it under Credentials, or edit existing plugin selections.", saveHint: "Changes are saved when you choose Save plugin selections.", deniedMessage: @@ -517,9 +528,14 @@ export function renderAgentPlugins( } else { catalogCapabilityError = true; catalogCapabilityChecked = true; + discovery.fields.setCapabilities(null, true); discovery.update(); - capabilitiesStatus.textContent = - "Plugin policy controls could not be loaded. Plugin selections JSON remains editable."; + // A refusal names the grant this read checks, an outage who to ask (finding 2538). + capabilitiesStatus.textContent = `${readFailure( + "Plugin policy controls", + cause, + PLUGIN_CAPABILITIES_NEED, + )} Plugin selections JSON remains editable.`; } }, ); diff --git a/apps/controller/src/console/agents/secret-picker.mts b/apps/controller/src/console/agents/secret-picker.mts index 511dc2116a..fa6a9d24c5 100644 --- a/apps/controller/src/console/agents/secret-picker.mts +++ b/apps/controller/src/console/agents/secret-picker.mts @@ -24,6 +24,7 @@ import { } from "./access-reasons.mjs"; import { describeFailure, + NAMESPACE_NOT_READY_MESSAGE, explainedConflict, message, namespaceListNeed, @@ -864,8 +865,7 @@ export function createSecretReferenceField({ feedback.textContent = "Secret creation outcome could not be confirmed. Refresh before trying again."; } else if (status === 409 && code === "NAMESPACE_NOT_READY") { - feedback.textContent = - "This Namespace is not ready, so it cannot store Secrets yet. Check its status on the Namespaces page: a provisioning Namespace becomes ready when its Kubernetes setup completes (on Kubernetes installs, after an operator grants the tenant RoleBindings)."; + feedback.textContent = NAMESPACE_NOT_READY_MESSAGE; } else if (status === 409 && explainedConflict(cause) !== undefined) { // A typed conflict names the state that refused the create. feedback.textContent = describeFailure(cause, { mutation: true }); diff --git a/apps/controller/src/console/agents/stop.mts b/apps/controller/src/console/agents/stop.mts index f7b5e3cc75..f9242769a6 100644 --- a/apps/controller/src/console/agents/stop.mts +++ b/apps/controller/src/console/agents/stop.mts @@ -1,6 +1,6 @@ import type { AgentRead } from "@openclaw-enterprise/contracts"; import { requestFailure, responseFields } from "../api-client.mjs"; -import { button, element, setHidden, setText, statusBlock } from "../dom.mjs"; +import { button, element, setHidden, setText, statusBlock, requestIdLine } from "../dom.mjs"; import { type AgentLifecycleContext, type LifecycleError, @@ -8,7 +8,7 @@ import { stopConfirmation, } from "./lifecycle-confirmation.mjs"; import { AGENT_STOP_DENIED, askForGrant } from "./access-reasons.mjs"; -import { describeFailure, shortId } from "./list.mjs"; +import { AGENT_STATUS_READ_NEED, describeFailure, shortId } from "./list.mjs"; export function createAgentStop( context: AgentLifecycleContext, @@ -116,9 +116,9 @@ export function createAgentStop( null, // Initial drafts and completed stops both have no selected revision. state.agent.activeRevisionId - ? "Stop requested. OCC will not start this Agent again until you deploy a new version. Runtime shutdown completion is not exposed in Console." + ? "Stop requested. This Agent will not start again until you deploy a new version. The console does not show when its runtime finishes shutting down." : state.cancelledFirstDeployment - ? "Stop requested. The deployment in progress is cancelled, and OCC will not start this Agent until you deploy a new version." + ? "Stop requested. The deployment in progress is cancelled, and this Agent will not start until you deploy a new version." : "No version is selected. Deploy a new version to start this Agent.", ), ] @@ -140,9 +140,7 @@ export function createAgentStop( "div", { className: "status", "data-tone": "danger", role: "alert" }, element("p", { className: "status-body" }, state.error.text), - state.error.requestId - ? element("p", { className: "request-id" }, `Request ID: ${state.error.requestId}`) - : null, + state.error.requestId ? requestIdLine(state.error.requestId) : null, ), ] : []), @@ -260,7 +258,7 @@ export function createAgentStop( context.navigate("agents"); } else { state.error = { - text: `Could not refresh stop status. ${describeFailure(cause)}`, + text: `Could not refresh stop status. ${describeFailure(cause, { need: AGENT_STATUS_READ_NEED })}`, requestId: error.requestId, }; } diff --git a/apps/controller/src/console/agents/workspace.mts b/apps/controller/src/console/agents/workspace.mts index af0e0bdc82..ce056bd905 100644 --- a/apps/controller/src/console/agents/workspace.mts +++ b/apps/controller/src/console/agents/workspace.mts @@ -1,8 +1,21 @@ import type { AgentRead } from "@openclaw-enterprise/contracts"; -import { type createApiClient, requestFailure, responseFields } from "../api-client.mjs"; -import { element, button, setFieldError, statusBlock, validateFields } from "../dom.mjs"; +import { + type createApiClient, + namedDenial, + requestFailure, + responseFields, +} from "../api-client.mjs"; +import { + element, + button, + setFieldError, + statusBlock, + validateFields, + withRequestId, +} from "../dom.mjs"; import type { createDraftStore } from "../drafts.mjs"; -import { askForGrant, WORKSPACE_SAVE_DENIED } from "./access-reasons.mjs"; +import { askForGrant, WORKSPACE_READ_DENIED, WORKSPACE_SAVE_DENIED } from "./access-reasons.mjs"; +import { describeFailure } from "./list.mjs"; const filenames = ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md"]; // Shown when the caller's permission answer denies Agent operate, which saving a file needs. @@ -43,13 +56,28 @@ export function workspaceFileProblem(name: string, content: string) { return ""; } -function fileError(error: FileFailure, writing: boolean) { - let text = "Workspace access is unavailable. Check the Agent gateway and try reloading."; +const WORKSPACE_GATEWAY_UNAVAILABLE = + "Workspace files could not be reached: the Agent's gateway did not answer. Check that the Agent is running, then reload this file. If it continues, ask an operator to check the gateway."; +const WORKSPACE_API_UNREACHABLE = + "The request did not reach the API. Check your connection, then reload this file."; + +function fileError(error: FileFailure, writing: boolean, failure: unknown) { + // A failure without its own text below says why by status (finding 2538): a rate limit how + // long to wait, a conflict the API's reason, a request that never reached the API that, and + // anything else that the gateway did not answer. + const { name } = responseFields(failure); + let text = + error.status === 409 || error.status === 429 + ? describeFailure(error) + : error.status === undefined && (name === "TypeError" || name === "TimeoutError") + ? WORKSPACE_API_UNREACHABLE + : WORKSPACE_GATEWAY_UNAVAILABLE; if (error.status === 403) { - // Saving a file is an Agent operate action (finding 1540). + // Saving a file is an Agent operate action (finding 1540); reading one is Agent read, unless + // the API names another grant. text = writing ? `You do not have permission to save this file. ${askForGrant("Agent operate on it")}` - : "Access denied. You do not have permission for this file operation."; + : (namedDenial(error) ?? WORKSPACE_READ_DENIED); } if (error.status === 404) { text = "File unavailable or missing. Check Agent access before creating it."; @@ -62,7 +90,7 @@ function fileError(error: FileFailure, writing: boolean) { text = "Outcome unknown. Your write may have succeeded. Reload this file and review its current contents before saving again."; } - return text + (error.requestId ? ` Request ID: ${error.requestId}` : ""); + return withRequestId(text, error.requestId); } // Only this answer confirms the file itself is absent. A resource or scope 404 does not establish @@ -290,7 +318,7 @@ export function renderWorkspaceFiles( loaded = false; } status.textContent = canCreate ? missingText() : ""; - errorNotice.textContent = canCreate ? "" : fileError(cause, false); + errorNotice.textContent = canCreate ? "" : fileError(cause, false, failure); } finally { if (context.isCurrent()) { pending = false; @@ -361,7 +389,7 @@ export function renderWorkspaceFiles( } outcomeUnknown = writeOutcomeUnknown(cause.status); status.textContent = ""; - errorNotice.textContent = fileError(cause, true); + errorNotice.textContent = fileError(cause, true, failure); } finally { if (context.isCurrent()) { pending = false; diff --git a/apps/controller/src/console/channels/shared-ui.mts b/apps/controller/src/console/channels/shared-ui.mts index 5580f740df..9bb5630056 100644 --- a/apps/controller/src/console/channels/shared-ui.mts +++ b/apps/controller/src/console/channels/shared-ui.mts @@ -16,6 +16,7 @@ import { focusSaveOutcome, statusBlock, validateOnSubmit, + withRequestId, } from "../dom.mjs"; import { renderSecretReference } from "../agents/secret-picker.mjs"; import { carryList, carryValue } from "../carry.mjs"; @@ -239,7 +240,7 @@ function withPlugin(values: ChannelValues, provider: ChannelProvider) { function errorText(error: Record) { const message = typeof error.message === "string" ? error.message : "Channel settings could not be saved."; - return message + (typeof error.requestId === "string" ? ` Request ID: ${error.requestId}` : ""); + return withRequestId(message, typeof error.requestId === "string" ? error.requestId : undefined); } function renderCard(section: HTMLElement, state: ChannelState, provider: ChannelProvider) { diff --git a/apps/controller/src/console/configuration-fields.mts b/apps/controller/src/console/configuration-fields.mts index 8c40f55aae..bb9d57c052 100644 --- a/apps/controller/src/console/configuration-fields.mts +++ b/apps/controller/src/console/configuration-fields.mts @@ -20,6 +20,24 @@ type FieldErrors = Readonly; const pointer = (key: string) => key.replaceAll("~", "~0").replaceAll("/", "~1"); +// The dotted setting path for a JSON pointer, as the API's own refusals name a setting +// ("Configuration setting network.allow[0].host"), so no copy shows a pointer (finding 2538). +function settingPath(path: string) { + return path + .split("/") + .slice(1) + .map((key) => key.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce( + (dotted, key) => + /^\d+$/.test(key) ? `${dotted}[${key}]` : dotted ? `${dotted}.${key}` : key, + "", + ); +} + +// A field error's text: the API's sentence, or one that names no internal code when it sent none. +const detailText = (detail: FieldErrors[number]) => + detail.message ?? "This value does not match the setting's schema."; + function initialValue(schema: FieldSchema | boolean): unknown { if (typeof schema === "boolean") { return ""; @@ -264,9 +282,7 @@ export function configurationFields( ); } for (const detail of details.filter((item) => item.path === path)) { - box.append( - element("p", { role: "alert", className: "error" }, detail.message ?? detail.code), - ); + box.append(element("p", { role: "alert", className: "error" }, detailText(detail))); } return box; } @@ -292,7 +308,13 @@ export function configurationErrors(details: FieldErrors) { "ul", { role: "alert", className: "error" }, ...details.map((detail) => - element("li", {}, `${detail.path || "Configuration"}: ${detail.message ?? detail.code}`), + element( + "li", + {}, + detail.path + ? `Configuration setting ${settingPath(detail.path)}: ${detailText(detail)}` + : detailText(detail), + ), ), ); } diff --git a/apps/controller/src/console/dom.mts b/apps/controller/src/console/dom.mts index 7c7fe708fb..8388dbb4cc 100644 --- a/apps/controller/src/console/dom.mts +++ b/apps/controller/src/console/dom.mts @@ -763,6 +763,14 @@ export function statusBlock( ); } +// One way to show a request ID (finding 2538): the words "Request ID:" and the ID, on their own +// line under a failure that has room for one, otherwise after its one line of text. +export const requestIdText = (requestId: string) => `Request ID: ${requestId}`; +export const requestIdLine = (requestId: string) => + element("p", { className: "request-id" }, requestIdText(requestId)); +export const withRequestId = (text: string, requestId: string | undefined) => + requestId ? `${text} ${requestIdText(requestId)}` : text; + /** * Gives a feedback line that switches between a quiet hint and a refusal its look: a status * block in `tone`, or `fallback` (the hint class) when `tone` is null. It replaces the whole diff --git a/apps/controller/src/console/driver-configurations.mts b/apps/controller/src/console/driver-configurations.mts index 0c705145ac..250fe70ea3 100644 --- a/apps/controller/src/console/driver-configurations.mts +++ b/apps/controller/src/console/driver-configurations.mts @@ -1,6 +1,18 @@ import type { DriverConfigurationDescriptor } from "@openclaw-enterprise/contracts"; import { button, element } from "./dom.mjs"; import { renderAgentSandboxPolicy } from "./agents/sandbox-policy.mjs"; +import { readFailure } from "./agents/list.mjs"; + +// The Agent's contributions authorize Agent read on it; the Installation's schemas, Installation +// administer. A failed read says which, or how long to wait (finding 2538). +const AGENT_CONFIGURATIONS_NEED = { + task: "reading this Agent's Driver configurations", + grant: "Agent read on it", +}; +const INSTALLATION_SCHEMAS_NEED = { + task: "reading the Installation's Driver schemas", + grant: "Installation administer", +}; type ConfigurationContext = Parameters[0]; type AgentOptions = Omit[1], "descriptor">; @@ -51,11 +63,15 @@ export function renderAgentDriverConfigurations( : element("p", {}, "This configuration owner is not supported by this console."), ); } - } catch { + } catch (cause) { if (active.isCurrent()) { section.replaceChildren( renderAgentSandboxPolicy(active, options), - element("p", { role: "alert" }, "Driver configurations could not be read."), + element( + "p", + { role: "alert" }, + readFailure("Driver configurations", cause, AGENT_CONFIGURATIONS_NEED), + ), button("Retry discovery", () => void load()), ); } @@ -103,13 +119,14 @@ export function renderInstallationConfigurations(context: ConfigurationContext) section.append(element("p", {}, "No Driver configuration schemas are available.")); } }) - .catch(() => { + .catch((cause: unknown) => { if (context.isCurrent()) { section.replaceChildren( + element("h2", {}, "Installation Driver configuration"), element( "p", { role: "alert" }, - "Installation Driver schemas require Installation administration and an available control plane.", + readFailure("Installation Driver schemas", cause, INSTALLATION_SCHEMAS_NEED), ), ); } diff --git a/apps/controller/src/console/namespaces.mts b/apps/controller/src/console/namespaces.mts index 9b34bd3b6e..e1ee84d168 100644 --- a/apps/controller/src/console/namespaces.mts +++ b/apps/controller/src/console/namespaces.mts @@ -1,6 +1,14 @@ import type { Namespace } from "@openclaw-enterprise/contracts"; import { type createApiClient, requestFailure, responseFields } from "./api-client.mjs"; -import { button, dismissOnBackdrop, element, field, statusBlock, validateFields } from "./dom.mjs"; +import { + button, + dismissOnBackdrop, + element, + field, + statusBlock, + validateFields, + requestIdLine, +} from "./dom.mjs"; import type { createNavigation } from "./navigation.mjs"; import { describeFailure, explainedConflict } from "./agents/list.mjs"; import { @@ -355,9 +363,7 @@ export function renderNamespaces( // A refusal the reader can fix here, a taken name above all, does not need one (finding // 2102). const requestId = - (unknown || forbidden) && error.requestId - ? [element("p", { className: "request-id" }, `Request ID: ${error.requestId}`)] - : []; + (unknown || forbidden) && error.requestId ? [requestIdLine(error.requestId)] : []; if (error.status === 400) { // The name is the only field: mark it invalid with the refusal linked to it, until // it is edited (finding 2046). diff --git a/apps/controller/src/console/runtime-images.mts b/apps/controller/src/console/runtime-images.mts index 47d67badb6..01c4ed5e1e 100644 --- a/apps/controller/src/console/runtime-images.mts +++ b/apps/controller/src/console/runtime-images.mts @@ -1,7 +1,15 @@ import type { Agent, RuntimeImage } from "@openclaw-enterprise/contracts"; import { element } from "./dom.mjs"; +import { type AccessNeed, namespaceListNeed, readFailure } from "./agents/list.mjs"; import type { NamespacePageContext } from "./namespaces.mjs"; +// Each Agent's images authorize Agent read on it; the Agent list, Namespace read. A failed read +// says which, or how long to wait, instead of one "unavailable, retry" (finding 2538). +const RUNTIME_IMAGES_NEED: AccessNeed = { + task: "reading this Agent's runtime images", + grant: "Agent read on it", +}; + type RuntimeImagesContext = Pick & { namespaceId: string | null; }; @@ -83,17 +91,17 @@ export async function renderRuntimeImages( details.append(list); } loading.replaceWith(details); - } catch { + } catch (cause) { if (current()) { - loading.textContent = "Runtime image metadata unavailable. Refresh to retry."; + loading.textContent = readFailure("Runtime image metadata", cause, RUNTIME_IMAGES_NEED); } } } } await Promise.all(Array.from({ length: Math.min(3, agents.length) }, worker)); - } catch { + } catch (cause) { if (current()) { - content.textContent = "Runtime image metadata unavailable. Refresh to retry."; + content.textContent = readFailure("Agents", cause, namespaceListNeed("Agents")); } } } diff --git a/apps/controller/src/console/secrets.mts b/apps/controller/src/console/secrets.mts index 088d08e84c..ccfc656411 100644 --- a/apps/controller/src/console/secrets.mts +++ b/apps/controller/src/console/secrets.mts @@ -12,9 +12,22 @@ import { SECRET_READ_DENIED, SECRET_UPDATE_DENIED, } from "./agents/access-reasons.mjs"; -import { describeFailure, explainedConflict } from "./agents/list.mjs"; +import { + describeFailure, + explainedConflict, + NAMESPACE_NOT_READY_MESSAGE, + readFailureReason, +} from "./agents/list.mjs"; import { requestFailure, responseFields } from "./api-client.mjs"; -import { button, dismissOnBackdrop, element, field, statusBlock, validateFields } from "./dom.mjs"; +import { + button, + dismissOnBackdrop, + element, + field, + statusBlock, + validateFields, + requestIdLine, +} from "./dom.mjs"; import { namespaceNameProblem, type NamespacePageContext } from "./namespaces.mjs"; import type { createNavigation } from "./navigation.mjs"; @@ -347,9 +360,7 @@ export function renderSecrets( } current.feedback.replaceChildren(statusBlock(tone, null, description)); if (error.requestId) { - current.feedback.append( - element("p", { className: "request-id" }, `Request ID: ${error.requestId}`), - ); + current.feedback.append(requestIdLine(error.requestId)); } } @@ -624,7 +635,7 @@ export function renderSecrets( } else if (status === 409) { description = code === "NAMESPACE_NOT_READY" - ? "This Namespace is not ready for Secret changes. Close and Refresh to check its current state." + ? NAMESPACE_NOT_READY_MESSAGE : describeFailure(cause, { mutation: true, conflict: "The Secret cannot be saved in its current state.", @@ -760,10 +771,13 @@ export function renderSecrets( ), ); } else { + // A refused or failed read says which, instead of guessing that access changed. showError( current, error, - "Could not confirm deletion. Your access may have changed. Close and Refresh the list.", + `Could not confirm deletion. ${ + error.status === 403 ? SECRET_READ_DENIED : readFailureReason(cause) + }`, ); } } finally { diff --git a/apps/controller/src/console/shell.mts b/apps/controller/src/console/shell.mts index 4fd534c0f7..00f8103457 100644 --- a/apps/controller/src/console/shell.mts +++ b/apps/controller/src/console/shell.mts @@ -1,6 +1,6 @@ import type { BackendSummary, Namespace } from "@openclaw-enterprise/contracts"; import { displayDate } from "./agents/list.mjs"; -import { button, describeDisabled, element, type statusBlock } from "./dom.mjs"; +import { button, describeDisabled, element, type statusBlock, requestIdLine } from "./dom.mjs"; import type { createNavigation, pages } from "./navigation.mjs"; interface Account { @@ -70,7 +70,7 @@ export function panel( { className: "state-panel", role: "status" }, element("h2", {}, title), element("p", {}, description), - requestId ? element("p", { className: "request-id" }, `Request ID: ${requestId}`) : null, + requestId ? requestIdLine(requestId) : null, action ? button(actionLabel, action) : null, ), ); @@ -647,7 +647,9 @@ export function createShell({ "span", { className: "occ-version", - title: knownRevision ? `OCC commit ${revision}` : "OCC build revision unavailable", + title: knownRevision + ? `Control plane commit ${revision}` + : "Control plane build revision unavailable", }, knownRevision ? revision.slice(0, 8) : "dev", ) diff --git a/docs/reference/console.md b/docs/reference/console.md index afb7254d52..6df1d35044 100644 --- a/docs/reference/console.md +++ b/docs/reference/console.md @@ -180,8 +180,11 @@ update to choose another credential, or, for a Secret-backed credential, Secret Secret to replace its value. It also says who gives them. A refusal the API explains, such as an invalid field, a name already in use or a lifecycle change, shows the API's sentence; a rate limit says how long to wait. -Other backend error text is not rendered; include any displayed request -ID when reporting an API failure. A current protected `401` clears private +The console also shows named dependency refusals, recorded deployment and policy +outcomes, and field errors, with each setting as a dotted path. A generic answer, +such as an unnamed outage, gets the console's own sentence: what failed, and who to +ask if it continues. Include the displayed **Request ID** when you report an API +failure. A current protected `401` clears private content and closes open channel editors and harness authentication controls. Interrupted response bodies and request deadlines keep the interrupted-read recovery path. View cancellation does not show a new failure, and a failed diff --git a/tests/browser/console-agent-deploy-again.test.mjs b/tests/browser/console-agent-deploy-again.test.mjs index fcddf4c826..7e37326bf6 100644 --- a/tests/browser/console-agent-deploy-again.test.mjs +++ b/tests/browser/console-agent-deploy-again.test.mjs @@ -26,7 +26,11 @@ import { } from "./console-deployment-activity-helpers.mjs"; import { replyWithDifferentAgentId } from "./console-agents-test-support.mjs"; import { createReadyConsoleNamespace } from "./console-ready-namespace.mjs"; -import { GENERIC_DENIAL } from "../helpers/refusal-text.mjs"; +import { + GENERIC_DENIAL, + OVERVIEW_REFRESH_FAILED, + READ_OUTAGE_REASON, +} from "../helpers/refusal-text.mjs"; // Failed-version recovery's Deploy again: the API's DEPENDENCY_UNAVAILABLE message says to deploy // again, and the link opens the draft at its Deploy new version. That button is built only from a @@ -594,11 +598,7 @@ test("A completed deployment whose Agent reread fails is labelled Deployed, not record.status = "succeeded"; record.agentFail = true; await page.clock.runFor(DEPLOYMENT_POLL_MS); - await page - .getByText( - "Current version and version history could not be refreshed. Use the page Refresh control to retry.", - ) - .waitFor(); + await page.getByText(`${OVERVIEW_REFRESH_FAILED} ${READ_OUTAGE_REASON}`).waitFor(); await summary.getByText("v2 · Completed", { exact: true }).waitFor(); await page .getByText( @@ -743,11 +743,7 @@ test("A failed reread leaves the newest readable version Historical under a hidd await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); agentFail = true; await page.getByRole("button", { name: "Refresh deployment" }).click(); - await page - .getByText( - "Current version and version history could not be refreshed. Use the page Refresh control to retry.", - ) - .waitFor(); + await page.getByText(`${OVERVIEW_REFRESH_FAILED} ${READ_OUTAGE_REASON}`).waitFor(); await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); assert.equal(await page.getByText(/^Deployed version/).count(), 0); }); @@ -762,9 +758,7 @@ test("A failure read by a refresh that cannot reread the Agent keeps its warning await page.getByRole("button", { name: "Refresh deployment" }).click(); const recovery = page.getByRole("region", { name: "Deployment needs attention" }); await recovery.waitFor(); - const warning = page.getByText( - "Current version and version history could not be refreshed. Use the page Refresh control to retry.", - ); + const warning = page.getByText(`${OVERVIEW_REFRESH_FAILED} ${READ_OUTAGE_REASON}`); // The panel renders the warning and the card folds the details in one task, so once the // warning is in the page the details' state is settled. await warning.waitFor({ state: "attached" }); diff --git a/tests/browser/console-agent-detail-2.test.mjs b/tests/browser/console-agent-detail-2.test.mjs index 7dfc87eee0..f03d397dbb 100644 --- a/tests/browser/console-agent-detail-2.test.mjs +++ b/tests/browser/console-agent-detail-2.test.mjs @@ -67,10 +67,14 @@ import { RESOURCE_CONFLICT, RESOURCE_NOT_FOUND, SANDBOX_POLICY_READ_DENIED, + STOP_REQUESTED, + WORKSPACE_API_UNREACHABLE, WORKSPACE_FILES_UNAVAILABLE, + WORKSPACE_UNREACHABLE, askForGrant, notIncluded, deleteRefused, + tooManyRequestsIn, } from "../helpers/refusal-text.mjs"; // The Console Browser lane runs the two Agent detail test files in parallel. @@ -2424,7 +2428,7 @@ test("Agent sharing grants existing people exact discovery and native access, th .getByRole("dialog") .getByRole("button", { name: "Stop Agent", exact: true }) .click(); - await recipient.getByText("Stop requested. OCC will not start", { exact: false }).waitFor(); + await recipient.getByText(STOP_REQUESTED, { exact: true }).waitFor(); // The stale administrator list holds the specific sharing binding, not the new operate grant. await panel.getByRole("button", { name: "Remove binding", exact: true }).click(); @@ -4004,17 +4008,39 @@ test("explicit missing-file readback settles an uncertain workspace create witho (permission) => !(permission.action === "read" && permission.resourceKind === "agent"), ); await reload.click(); - await form.getByText("Access denied.", { exact: false }).waitFor(); + // The API names the read grant its own denial checked (finding 2534). + await form.getByText(/^Not authorized: this needs Agent read on Agent /).waitFor(); assert.equal(await save.isDisabled(), true); assert.equal(writeRequests().length, 1); administrator.permissions = permissions; unavailable = true; await reload.click(); - await form.getByText("Workspace access is unavailable.", { exact: false }).waitFor(); + await form.getByText(WORKSPACE_UNREACHABLE, { exact: false }).waitFor(); assert.equal(await editor.isDisabled(), true); assert.equal(await save.isDisabled(), true); assert.equal(writeRequests().length, 1); unavailable = false; + // A rate limit says how long to wait instead of blaming the gateway (finding 2538). + await page.route( + fixture.origin + path, + (route) => + route.request().method() === "GET" + ? route.fulfill({ ...proxyErrorResponse(429), headers: { "retry-after": "30" } }) + : route.fallback(), + { times: 1 }, + ); + await reload.click(); + await form.getByText(tooManyRequestsIn(30), { exact: false }).waitFor(); + // A request that never reached the API says so, not that the gateway is down. + await page.route( + fixture.origin + path, + (route) => (route.request().method() === "GET" ? route.abort() : route.fallback()), + { times: 1 }, + ); + await reload.click(); + await form.getByText(WORKSPACE_API_UNREACHABLE, { exact: true }).waitFor(); + assert.equal(await save.isDisabled(), true); + assert.equal(writeRequests().length, 1); const reply = page.waitForResponse( (response) => response.url() === fixture.origin + path && response.request().method() === "GET", ); @@ -4048,7 +4074,7 @@ test("explicit missing-file readback settles an uncertain workspace create witho assert.equal(await save.isDisabled(), true); assert.equal(await readFile(join(root, "USER.md"), "utf8"), "# Deliberate replacement create\n"); await reload.click(); - await form.getByText("Workspace access is unavailable.", { exact: false }).waitFor(); + await form.getByText(WORKSPACE_UNREACHABLE, { exact: false }).waitFor(); assert.equal(await save.isDisabled(), true); await reload.click(); await form.getByText("USER.md loaded.", { exact: true }).waitFor(); diff --git a/tests/browser/console-agent-detail.test.mjs b/tests/browser/console-agent-detail.test.mjs index 3026beb708..5cf0661213 100644 --- a/tests/browser/console-agent-detail.test.mjs +++ b/tests/browser/console-agent-detail.test.mjs @@ -76,7 +76,9 @@ import { callerDenied, CONFIGURATION_EDITS_PENDING, CONFIGURATION_UPDATE_DENIED, + CONSOLE_NAMESPACE_NOT_READY, CONFIGURATION_SAVE_DENIED, + GENERIC_DENIAL, NAMESPACE_NOT_READY, NEW_SECRET_OPERATE_DENIED, NEW_SECRET_OPERATE_REMEDY, @@ -87,10 +89,12 @@ import { SECRET_NAME_CONFLICT, SECRET_OPERATE_DENIED, STOP_CONFIRMATION, + STOP_REQUESTED, STOP_RUNNING_VERSION, WORKSPACE_FILES_UNAVAILABLE, credentialCheckedAtDeploy, namespaceListDenied, + notIncluded, secretOperateRefused, stopRefused, } from "../helpers/refusal-text.mjs"; @@ -157,7 +161,10 @@ test("Agent detail separates the current version, viewed version, and latest dep await overview.nth(1).getByText("v2 · Queued").waitFor(); await overview.nth(2).getByText("Not verified").waitFor(); // A running version's unverified serving says why (finding 2367). - await overview.nth(2).getByText("OCC sends no test request.", { exact: true }).waitFor(); + await overview + .nth(2) + .getByText("OpenClaw Enterprise sends no test request.", { exact: true }) + .waitFor(); const activity = await expectDeploymentOutcome(page, "Queued"); await activity.getByText("Most recent visible deployment · v2").waitFor(); await activity.getByText("Waiting for a worker claim.").waitFor(); @@ -2484,7 +2491,7 @@ test("Agent credential Secret picker searches, validates, and preserves duplicat await page.route(`**/namespaces/${namespace.id}/secrets`, failSecretCreate); await dialog.getByLabel("Name", { exact: true }).fill("Namespace not ready picker Secret"); await dialog.getByRole("button", { name: "Create Secret", exact: true }).click(); - await dialog.getByRole("alert").filter({ hasText: "cannot store Secrets yet" }).waitFor(); + await dialog.getByRole("alert").filter({ hasText: CONSOLE_NAMESPACE_NOT_READY }).waitFor(); assert.equal( await dialog.getByLabel("Name", { exact: true }).inputValue(), "Namespace not ready picker Secret", @@ -3060,7 +3067,7 @@ test("Agent stop confirmation uses the real API, preserves Agent state, and depl assert.equal(stoppedBody.data.activeRevisionId, active.revision.id); await page.getByRole("status").getByText("Stop requested.").waitFor(); - await page.getByText(/Runtime shutdown completion is not exposed in Console/).waitFor(); + await page.getByText(STOP_REQUESTED, { exact: true }).waitFor(); // The card names the version as the page does, and the overview reports the request // (finding 1899). const stopRegion = page.getByRole("region", { name: "Stop Agent", exact: true }); @@ -3460,6 +3467,21 @@ test("Agent stop uncertainty requires refresh before another stop request", asyn await page.getByText("Could not refresh stop status.").waitFor(); assert.deepEqual(await stopControls.allTextContents(), ["Refresh stop status"]); + // A refused status read names the Agent read it checks, not a bare denial (finding 2538). + await page.route( + fixture.origin + agentPath, + (route) => route.fulfill(apiErrorResponse(403, "FORBIDDEN", GENERIC_DENIAL)), + { times: 1 }, + ); + await page.getByRole("button", { name: "Refresh stop status" }).click(); + await page + .getByText( + `Could not refresh stop status. ${notIncluded("reading this Agent's status", "Agent read on it")}`, + { exact: true }, + ) + .waitFor(); + assert.deepEqual(await stopControls.allTextContents(), ["Refresh stop status"]); + // Even this Agent's active status cannot tell us whether it is requested to run or stop. // Omit only the desired state from the controller's real, committed Stop readback. await page.route( diff --git a/tests/browser/console-agent-draft-guards.test.mjs b/tests/browser/console-agent-draft-guards.test.mjs index a113ea4ccf..b5825f1128 100644 --- a/tests/browser/console-agent-draft-guards.test.mjs +++ b/tests/browser/console-agent-draft-guards.test.mjs @@ -113,6 +113,15 @@ test("Agent policy ceiling rejection is announced in view and preserves unsaved ); assert.equal(displayed.role, "alert"); assert.equal(displayed.focused, true); + // The list names the setting as a dotted path, as the API's refusals do, never a JSON pointer + // (finding 2538). + await page + .getByText( + "Configuration setting additions[0]: The grant exceeds the Installation delegation ceiling.", + { exact: true }, + ) + .waitFor(); + assert.equal(await page.getByText(/^\/additions/).count(), 0); assert.equal( await page.getByRole("textbox", { name: "Destination hostname", exact: true }).inputValue(), "outside.example.org", diff --git a/tests/browser/console-agent-overview.test.mjs b/tests/browser/console-agent-overview.test.mjs index d18c276441..ed63128eb2 100644 --- a/tests/browser/console-agent-overview.test.mjs +++ b/tests/browser/console-agent-overview.test.mjs @@ -497,7 +497,7 @@ test("Stop stays enabled and says it cancels a first deployment that is queued", await dialog.getByRole("button", { name: "Stop Agent", exact: true }).click(); await stopRegion .getByText( - "Stop requested. The deployment in progress is cancelled, and OCC will not start this Agent until you deploy a new version.", + "Stop requested. The deployment in progress is cancelled, and this Agent will not start until you deploy a new version.", { exact: true }, ) .waitFor(); @@ -768,7 +768,9 @@ test("Live serving shows a lost Sandbox and its next step without moving the pag delete sandboxDriver.observeHarness; try { const { page } = await openOverview(consoleViewports[0]); - await liveServing(page).getByText("OCC sends no test request.", { exact: true }).waitFor(); + await liveServing(page) + .getByText("OpenClaw Enterprise sends no test request.", { exact: true }) + .waitFor(); assert.equal(await liveServing(page).locator(".badge").textContent(), "Not verified"); } finally { sandboxDriver.observeHarness = observeHarness; diff --git a/tests/browser/console-agent-presets.test.ts b/tests/browser/console-agent-presets.test.ts index 5e609143c9..a572fccb2c 100644 --- a/tests/browser/console-agent-presets.test.ts +++ b/tests/browser/console-agent-presets.test.ts @@ -1472,7 +1472,12 @@ test("Create Agent discovers hosted plugins with a transient PAT through the sel } finally { detailRelease.resolve(); } - await dialog.getByText(/credential was rejected or cannot access plugins/).waitFor(); + // The request ID uses the one label every console failure uses (finding 2538). + await dialog + .getByText( + /credential was rejected or cannot access plugins\. Check its permissions\. Request ID: req_/, + ) + .waitFor(); assert.equal(await heading.evaluate((node) => node === node.ownerDocument.activeElement), true); assert.equal((await dialog.textContent())?.includes("private upstream response"), false); failTools = false; diff --git a/tests/browser/console-agents-2.test.ts b/tests/browser/console-agents-2.test.ts index c9e921735d..2db2f18f8a 100644 --- a/tests/browser/console-agents-2.test.ts +++ b/tests/browser/console-agents-2.test.ts @@ -65,6 +65,7 @@ import { } from "./console-agents-test-support.mjs"; import { AGENT_NAME_CONFLICT, + CONSOLE_NAMESPACE_NOT_READY, GENERIC_DENIAL, NAMESPACE_NOT_READY, RESOURCE_CONFLICT, @@ -2194,8 +2195,7 @@ test("Agent creation shows the API's duplicate-name conflict, a not-ready Namesp await page .getByRole("alert") .filter({ - hasText: - "This Namespace is not ready yet. Check its status on the Namespaces page: a provisioning Namespace becomes ready when its Kubernetes setup completes (on Kubernetes installs, after an operator grants the tenant RoleBindings). Request ID: req_00000000-0000-4000-8000-000000000410", + hasText: `${CONSOLE_NAMESPACE_NOT_READY} Request ID: req_00000000-0000-4000-8000-000000000410`, }) .waitFor(); assert.equal(await page.getByText(/conflicts with the saved state/).count(), 0); diff --git a/tests/browser/console-create-agent-provisioning-400.test.mjs b/tests/browser/console-create-agent-provisioning-400.test.mjs index 54ea998ff8..d16f69df6b 100644 --- a/tests/browser/console-create-agent-provisioning-400.test.mjs +++ b/tests/browser/console-create-agent-provisioning-400.test.mjs @@ -45,6 +45,7 @@ import { createReadyConsoleNamespace } from "./console-ready-namespace.mjs"; import { AGENT_NAME_CONFLICT, CHANNEL_BINDING_FORM_REMEDY, + CONSOLE_NAMESPACE_NOT_READY, CODEX_PLACEMENT_REFUSAL, DEDICATED_PLACEMENT_REQUIREMENT, DEPENDENCY_UNAVAILABLE, @@ -55,6 +56,7 @@ import { unboundReferenceRefusal, POD_QUOTA_FULL, codexApiKeyRefusal, + notIncluded, } from "../helpers/refusal-text.mjs"; // Every test opens its own context in one Chromium per file. @@ -803,6 +805,25 @@ test("Credential registration removal preserves the safety fence and retries the (response) => response.url() === fixture.origin + sourcePath && response.request().method() === "DELETE", ); + // A refused removal names the grant DELETE checks and who grants it, not a bare denial + // followed by an outage hint (finding 2538). + await page.route( + fixture.origin + sourcePath, + (route) => + route.request().method() === "DELETE" + ? route.fulfill(apiErrorResponse(403, "FORBIDDEN", GENERIC_DENIAL)) + : route.fallback(), + { times: 1 }, + ); + const refused = deleted(); + await remove.click(); + assert.equal((await refused).status(), 403); + await page + .getByText( + `${notIncluded("removing this credential registration", "credential source delete on it")} The model Secret remains saved.`, + { exact: true }, + ) + .waitFor(); const first = deleted(); await remove.click(); assert.equal((await first).status(), 503); @@ -841,7 +862,8 @@ test("Credential registration removal preserves the safety fence and retries the assert.notEqual(calls[1].sourceId, sourceId); assert.deepEqual(calls[0].input, calls[1].input); assert.deepEqual(removals, [sourceId, sourceId, sourceId]); // Initial abandonment, then both DELETEs. - assert.equal(pathRequests(requests, "DELETE", sourcePath).length, 2); + // The refused DELETE (answered in the page), then the two that reached the API. + assert.equal(pathRequests(requests, "DELETE", sourcePath).length, 3); assert.deepEqual( pathRequests(requests, "POST", `/namespaces/${namespace.id}/credential-sources`)[1].body .secrets, @@ -1009,7 +1031,7 @@ test("Dedicated Agent provisioning names a Namespace that is not ready", async ( await feedback.waitFor(); assert.equal( await feedback.textContent(), - "This Namespace is not ready yet. Check its status on the Namespaces page: a provisioning Namespace becomes ready when its Kubernetes setup completes (on Kubernetes installs, after an operator grants the tenant RoleBindings). Request ID: req_00000000-0000-4000-8000-000000000409", + `${CONSOLE_NAMESPACE_NOT_READY} Request ID: req_00000000-0000-4000-8000-000000000409`, ); }); diff --git a/tests/browser/console-form-fields.test.ts b/tests/browser/console-form-fields.test.ts index 4668e8ec66..ba4beb73c0 100644 --- a/tests/browser/console-form-fields.test.ts +++ b/tests/browser/console-form-fields.test.ts @@ -5,6 +5,7 @@ import type { Locator, Page } from "playwright"; import { apiErrorResponse, proxyErrorResponse } from "../helpers/api-error.mjs"; import { + CONSOLE_NAMESPACE_NOT_READY, RESOURCE_CONFLICT, SECRET_CREATE_DENIED, SECRET_NAME_CONFLICT, @@ -607,10 +608,7 @@ for (const viewport of consoleViewports) { // The Secret API never answers 429: a rate limit in front of it (an ingress or proxy) does, // with its own page and no API envelope. const cases = [ - [ - apiErrorResponse(409, "NAMESPACE_NOT_READY", "refused"), - "This Namespace is not ready, so it cannot store Secrets yet.", - ], + [apiErrorResponse(409, "NAMESPACE_NOT_READY", "refused"), CONSOLE_NAMESPACE_NOT_READY], [apiErrorResponse(403, "FORBIDDEN", "refused"), SECRET_CREATE_DENIED], [proxyErrorResponse(429), "Too many requests. Wait before trying again."], ] as const; diff --git a/tests/browser/console-runtime-credentials.test.mjs b/tests/browser/console-runtime-credentials.test.mjs index 3575aeb8eb..6cc898540e 100644 --- a/tests/browser/console-runtime-credentials.test.mjs +++ b/tests/browser/console-runtime-credentials.test.mjs @@ -146,7 +146,10 @@ test("draft Agent offers deployment without a generated-credential step", async await deploy.click(); await page .getByRole("alert") - .filter({ hasText: /cluster denied OCC access.*tenant RoleBindings/ }) + .filter({ + hasText: + "Deployment refused: the cluster denied the control plane access to this Agent's connection credentials. Ask an operator to grant the control plane the Kubernetes RoleBindings this Namespace needs, then deploy again.", + }) .waitFor(); await waitForEnabled(deploy); await page.unroute(deployUrl); @@ -902,7 +905,9 @@ test("operator-managed console binding saves and deploys without a managed crede await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, "draft", "credentials")); await page.getByLabel("Authentication source").selectOption("runtime"); await page - .getByText("Configured on the runtime host; not validated by OCC.", { exact: true }) + .getByText("Configured on the runtime host; OpenClaw Enterprise does not check it.", { + exact: true, + }) .waitFor(); assert.equal(await page.getByLabel("API key Secret").isVisible(), false); const save = page.waitForResponse( diff --git a/tests/browser/console-secrets.test.ts b/tests/browser/console-secrets.test.ts index 952609b66f..4564c002fe 100644 --- a/tests/browser/console-secrets.test.ts +++ b/tests/browser/console-secrets.test.ts @@ -19,6 +19,7 @@ import type { } from "../../packages/contracts/src/api/resources.ts"; import { apiErrorResponse, proxyErrorResponse } from "../helpers/api-error.mjs"; import { + CONSOLE_NAMESPACE_NOT_READY, RESOURCE_NOT_FOUND, SECRET_CREATE_DENIED, SECRET_NAME_CONFLICT, @@ -533,10 +534,7 @@ test("a Secret name conflict allows correction, but a Namespace that stops being const { error }: ErrorResponse = await response.json(); assert.equal(error.code, "NAMESPACE_NOT_READY"); // Both writes were refused because the Namespace changed, not because the name is taken. - await form - .getByRole("alert") - .filter({ hasText: /Namespace.*not ready.*Close and Refresh/ }) - .waitFor(); + await form.getByRole("alert").filter({ hasText: CONSOLE_NAMESPACE_NOT_READY }).waitFor(); const value = form.getByLabel("Value", { exact: true }); assert.equal(await value.inputValue(), ""); assert.equal(await value.isDisabled(), true, "a Namespace conflict needs a fresh read"); diff --git a/tests/helpers/refusal-text.mjs b/tests/helpers/refusal-text.mjs index ef9bc0c7b6..ac5f5e54b8 100644 --- a/tests/helpers/refusal-text.mjs +++ b/tests/helpers/refusal-text.mjs @@ -263,6 +263,24 @@ export const tooManyRequestsIn = (seconds) => ? `Too many requests. Try again in ${seconds} ${seconds === 1 ? "second" : "seconds"}.` : `Too many requests. Try again in about ${Math.ceil(seconds / 60)} minutes.`; +// Every console view that meets NAMESPACE_NOT_READY shows this one sentence (finding 2538). +export const CONSOLE_NAMESPACE_NOT_READY = + "This Namespace is not ready yet. Check its status on the Namespaces page: a provisioning Namespace becomes ready when its Kubernetes setup completes. If it stays provisioning, ask an operator to finish that setup."; +// Why a read failed with an outage (agents/list.mts readFailureReason). +export const READ_OUTAGE_REASON = + "The service did not answer. Try again; if it continues, ask an operator to check the API."; +export const OVERVIEW_REFRESH_FAILED = + "Current version and version history could not be refreshed."; +// A workspace file read or write that the gateway did not answer. +export const WORKSPACE_UNREACHABLE = + "Workspace files could not be reached: the Agent's gateway did not answer. Check that the Agent is running, then reload this file. If it continues, ask an operator to check the gateway."; +// A workspace file request that never reached the API (a fetch that failed in the browser). +export const WORKSPACE_API_UNREACHABLE = + "The request did not reach the API. Check your connection, then reload this file."; +// A Stop that the API accepted for an Agent with a running version. +export const STOP_REQUESTED = + "Stop requested. This Agent will not start again until you deploy a new version. The console does not show when its runtime finishes shutting down."; + // The Agent page's Workspace tab without a deployed, reachable gateway. export const WORKSPACE_FILES_UNAVAILABLE = "Workspace files require a deployed Agent with a current version and a reachable gateway.";