Skip to content
Merged
7 changes: 6 additions & 1 deletion apps/controller/src/console/agents/access-reasons.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/controller/src/console/agents/access.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
setStatusTone,
statusBlock,
validateOnSubmit,
withRequestId,
} from "../dom.mjs";
import {
type AgentAccessContext,
Expand Down Expand Up @@ -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);
}
}

Expand Down
8 changes: 3 additions & 5 deletions apps/controller/src/console/agents/collection.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
),
]
: []),
Expand Down Expand Up @@ -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");
Expand Down
44 changes: 37 additions & 7 deletions apps/controller/src/console/agents/create.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -62,8 +64,10 @@ import {
NAMESPACE_MISSING,
NAMESPACE_RECHECK_MS,
namespaceCreation,
namespaceListNeed,
namespacePath,
preAdmissionRefusal,
readFailure,
rejectionMessage,
} from "./list.mjs";

Expand Down Expand Up @@ -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()}`;
}
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 4 additions & 5 deletions apps/controller/src/console/agents/deletion.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
setHidden,
setText,
statusBlock,
requestIdLine,
} from "../dom.mjs";
import {
type AgentLifecycleContext,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
),
]
: []),
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions apps/controller/src/console/agents/deployment-evidence.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
message,
neutralDeploymentOutcome,
outcomeLabel,
readFailureReason,
} from "./list.mjs";
import { pluginWarningText } from "./plugins.mjs";

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -368,6 +372,7 @@ export function createDeploymentStatusPanel(
polling = poll;
error = null;
overviewUnavailable = false;
overviewError = null;
render();
try {
const [statusResult, agentResult, revisionsResult] = await Promise.allSettled([
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}`,
),
]
: []),
Expand Down
18 changes: 10 additions & 8 deletions apps/controller/src/console/agents/detail.mts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
setText,
statusBlock,
validateFields,
requestIdLine,
requestIdText,
} from "../dom.mjs";
import {
configuredHarnessId,
Expand Down Expand Up @@ -66,6 +68,7 @@ import {
secretOperateRefusal,
channelBindingRefusal,
agentSaveDenial,
describeFailure,
message,
rejectionMessage,
assertReadableConfiguration,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.";
Expand Down Expand Up @@ -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.
Expand All @@ -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",
);
Expand Down
6 changes: 3 additions & 3 deletions apps/controller/src/console/agents/harness-auth.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading