mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
fix: load cloud profiles without worker inventory (#151783)
Co-authored-by: Peter Steinberger <58493+steipete@users.noreply.github.com>
This commit is contained in:
parent
c4cc9fb44d
commit
5def0cc7d0
11 changed files with 169 additions and 39 deletions
|
|
@ -6866,15 +6866,19 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
|
|||
|
||||
public struct EnvironmentsListParams: Codable, Sendable {
|
||||
public let runtimeid: String?
|
||||
public let projection: String?
|
||||
|
||||
public init(
|
||||
runtimeid: String? = nil)
|
||||
runtimeid: String? = nil,
|
||||
projection: String? = nil)
|
||||
{
|
||||
self.runtimeid = runtimeid
|
||||
self.projection = projection
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runtimeid = "runtimeId"
|
||||
case projection
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ RPC method families for Talk and TTS, secrets, config, update, and wizard flows,
|
|||
- `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side. Set `messageRole: "assistant"` on list, get, and download to select assistant-delivered artifacts, including delivery mirrors, and exclude uploaded inputs and raw tool observations. Omit the filter to keep all-role discovery. Combine it with `runId` for a particular run; a run ID alone does not identify generated output. Older Gateways reject the new filter; clients must surface that rejection instead of retrying without it.
|
||||
- `artifacts.list` also accepts `type: "image"`, optional `limit` (1–4, default 4), and an opaque `cursor` for recent image discovery. Each page reads at most 32 transcript messages and 256 KiB, newest first; `nextCursor` continues into older messages even when a sparse page has no images. Image summaries include `image.url` from structured content, canonical uploaded media facts, or rendered Markdown references. Nonmanaged images use `preview_` ids, `source: "session-transcript-preview"`, and unsupported downloads: these are preview references, not identifiers for `artifacts.get` or `artifacts.download`. The Control UI uses the existing authenticated media routes and session media policy for local sources and the artifact download owner for managed media. Discovery never restores cold transcripts or rebuilds their projections; open the session to restore unavailable previews. `omittedOversized` reports skipped records; open the session to read them. Cursors expire after 15 minutes, belong to the originating connection, agent, session, and query, and reject transcript resets. Requests without `type` preserve the complete artifact listing; `limit` and `cursor` require the image filter.
|
||||
- `environments.list` and `environments.status` (`operator.read`) remain available without cloud-worker profiles and preserve gateway-local and node environment discovery. `environments.list` also accepts an optional `runtimeId` from callers with `operator.write`. That request adds one Gateway-owned `requiredNodeCommand` result to each connected node when the runtime requires a node command. Its closed state is `invocable`, `pending-approval`, `undeclared`, or `unauthorized`; it never exposes the node's full pending declaration. Node environments include the durable `sessionHost` identity used to keep a known offline host visible, while current connected inventory is authoritative over that history. Missing identity means false. Exact bounded `{ total, available }` worker slots are live-only and omitted offline; worker-turn admission consumes a slot, while node-backed remote-exec does not. Configured profile summaries expose their bounded, canonically ordered `executionModes` array plus the existing singular `executionMode` primary/default display projection. Current clients select profiles only by membership in `executionModes`. Configured cloud workers and durable records left by earlier profiles add `worker` metadata with the configured `profileId`, `providerId`, optional `leaseId`, `state`, `ageMs`, optional `idleMs`, and `attachedSessionIds`. Worker lifecycle states are `requested`, `provisioning`, `bootstrapping`, `ready`, `attached`, `idle`, `draining`, `destroying`, `destroyed`, `failed`, and `orphaned`. A connected node may also include `workerBundle: { status: "installed", version }` or `workerBundle: { status: "missing" }`. This optional observation is reconnect-scoped and reports validation of one Gateway-retained bundle; it is not launch authority. The public result never exposes the bundle hash, Gateway namespace, node filesystem path, receipt, or protocol-feature details.
|
||||
- `environments.list` with `{ projection: "profiles" }` reads only the configured profile catalog, including provider-authored machine and operating-system choices. It returns `environments: []` and omits `profiles` when none are advertised. Worker and paired-device inventory is not read, so inventory failures do not prevent profile discovery. Omitting `projection` keeps the full inventory behavior. Including `runtimeId` still requires `operator.write`, even with the profile projection.
|
||||
- `environments.create` (`{ profileId, idempotencyKey }`) provisions an environment from a configured plugin provider profile; retries with the same key reuse the durable operation. Direct creation without a session does not select an execution mode, so the provider uses its intentional default; Crabbox prepares `worker-turn`. `environments.destroy` (`{ environmentId }`) requests idempotent teardown of a durable worker environment. Both require `operator.admin`, are control-plane writes, and return the same environment summary shape used by status responses.
|
||||
- `environments.prepare` (`{ profileId, projectPath }`, `operator.admin`) admits a project build without a session and returns `{ environmentId, preparationKey, reused }`. It is a control-plane write gated on provider startup. The project must be a local Git checkout; the configured provider must support project preparation. A matching unconsumed build or reserve is reused. Known errors retain `details.code`: `profile_not_found`, `invalid_profile`, and `invalid_project` map to `INVALID_REQUEST`; `capacity` maps to `UNAVAILABLE`. Other failures return a generic `UNAVAILABLE` without provider details. Prepared summaries expose only `preparation: { purpose: "reserve" | "build", key }`. Use `environments.destroy` to cancel. See [Build on demand](/gateway/cloud-workers/warm-images#ready-workers) for pool policy and setup authorization.
|
||||
- `worker.desktop.observe` (`{ environmentId, control? }`, `operator.admin`) starts or reuses the environment's desktop forward and returns `{ transport, wsPath, expiresAtMs, control, vncPassword? }`. `wsPath` carries a single-use 60-second token for the Gateway's desktop observer WebSocket; reconnecting requires a fresh observe call. Environments with an observable desktop advertise `worker.desktop: true` in `environments.list`. The method is advertised only when the `cloudWorkers.desktop` lab is enabled. See [Cloud workers](/gateway/cloud-workers#desktop-interactive).
|
||||
|
|
|
|||
|
|
@ -287,6 +287,11 @@ describe("worker environment protocol schemas", () => {
|
|||
|
||||
expect(validateEnvironmentsListParams({})).toBe(true);
|
||||
expect(validateEnvironmentsListParams({ runtimeId: "codex" })).toBe(true);
|
||||
expect(validateEnvironmentsListParams({ projection: "profiles" })).toBe(true);
|
||||
expect(validateEnvironmentsListParams({ runtimeId: "codex", projection: "profiles" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(validateEnvironmentsListParams({ projection: "unknown" })).toBe(false);
|
||||
expect(validateEnvironmentsListParams({ runtimeId: "" })).toBe(false);
|
||||
expect(validateEnvironmentsListParams({ runtimeId: "x".repeat(129) })).toBe(false);
|
||||
expect(validateEnvironmentsListParams({ runtimeId: "codex", command: "runtime.exec" })).toBe(
|
||||
|
|
|
|||
|
|
@ -152,9 +152,10 @@ export const EnvironmentSummarySchema = closedObject({
|
|||
requiredNodeCommand: Type.Optional(RequiredNodeCommandSchema),
|
||||
});
|
||||
|
||||
/** Optional runtime scope for listing known environments. */
|
||||
/** Optional runtime scope or profile-only projection for environment discovery. */
|
||||
export const EnvironmentsListParamsSchema = closedObject({
|
||||
runtimeId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
||||
projection: Type.Optional(Type.Literal("profiles")),
|
||||
});
|
||||
|
||||
/** Provider-authored machine choice for one configured worker profile. */
|
||||
|
|
@ -204,7 +205,7 @@ const WorkerEnvironmentProfileSummarySchema = closedObject({
|
|||
),
|
||||
});
|
||||
|
||||
/** List response containing all gateway-visible environment summaries. */
|
||||
/** Profile-only requests leave environments empty without reading inventory. */
|
||||
export const EnvironmentsListResultSchema = closedObject({
|
||||
environments: Type.Array(EnvironmentSummarySchema),
|
||||
profiles: Type.Optional(Type.Array(WorkerEnvironmentProfileSummarySchema)),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ it("pages cloud profile summaries and returns the selected OS/machine catalog",
|
|||
profileId: "profile-32",
|
||||
});
|
||||
expect(selected.details).toEqual({ profile: profiles[32] });
|
||||
expect(callGateway).toHaveBeenCalledWith({ method: "environments.list", params: {} });
|
||||
expect(callGateway).toHaveBeenCalledWith({
|
||||
method: "environments.list",
|
||||
params: { projection: "profiles" },
|
||||
});
|
||||
const missing = await tool.execute("missing", {
|
||||
action: "cloud_profiles",
|
||||
profileId: "removed",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export async function listSessionCloudProfiles(
|
|||
) {
|
||||
const catalog = await request<EnvironmentsListResult>({
|
||||
method: "environments.list",
|
||||
params: {},
|
||||
params: { projection: "profiles" },
|
||||
});
|
||||
const profiles = catalog.profiles ?? [];
|
||||
const profileId = normalizeOptionalString(readToolStringParam(params, "profileId"));
|
||||
|
|
|
|||
|
|
@ -206,32 +206,35 @@ describe("node environment command authority", () => {
|
|||
expect(statusPayload?.invocableCommands ?? []).toEqual(expected);
|
||||
});
|
||||
|
||||
it("requires write scope only for runtime-specific command state", async () => {
|
||||
const context = {
|
||||
logGateway: { warn: vi.fn() },
|
||||
getRuntimeConfig: () => ({}),
|
||||
nodeRegistry: { listConnectedForPairingStates: () => [] },
|
||||
};
|
||||
const readOnlyRespond = vi.fn();
|
||||
await environmentsHandlers["environments.list"]?.({
|
||||
params: { runtimeId: "codex" },
|
||||
respond: readOnlyRespond,
|
||||
client: { connect: { scopes: ["operator.read"] } },
|
||||
context,
|
||||
} as never);
|
||||
expect(readOnlyRespond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "FORBIDDEN", message: "missing scope: operator.write" }),
|
||||
);
|
||||
it.each([{}, { projection: "profiles" }])(
|
||||
"preserves runtime write scope for %j",
|
||||
async (params) => {
|
||||
const context = {
|
||||
logGateway: { warn: vi.fn() },
|
||||
getRuntimeConfig: () => ({}),
|
||||
nodeRegistry: { listConnectedForPairingStates: () => [] },
|
||||
};
|
||||
const readOnlyRespond = vi.fn();
|
||||
await environmentsHandlers["environments.list"]?.({
|
||||
params: { ...params, runtimeId: "codex" },
|
||||
respond: readOnlyRespond,
|
||||
client: { connect: { scopes: ["operator.read"] } },
|
||||
context,
|
||||
} as never);
|
||||
expect(readOnlyRespond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "FORBIDDEN", message: "missing scope: operator.write" }),
|
||||
);
|
||||
|
||||
const inventoryRespond = vi.fn();
|
||||
await environmentsHandlers["environments.list"]?.({
|
||||
params: {},
|
||||
respond: inventoryRespond,
|
||||
client: { connect: { scopes: ["operator.read"] } },
|
||||
context,
|
||||
} as never);
|
||||
expect(inventoryRespond.mock.calls.at(0)?.[0]).toBe(true);
|
||||
});
|
||||
const inventoryRespond = vi.fn();
|
||||
await environmentsHandlers["environments.list"]?.({
|
||||
params,
|
||||
respond: inventoryRespond,
|
||||
client: { connect: { scopes: ["operator.read"] } },
|
||||
context,
|
||||
} as never);
|
||||
expect(inventoryRespond.mock.calls.at(0)?.[0]).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -381,7 +381,9 @@ describe("environment gateway methods", () => {
|
|||
const listOperatingSystems = vi.fn(async (profileId: string) =>
|
||||
profileId === "aws" ? systems : [systems[0]!],
|
||||
);
|
||||
const list = vi.fn(() => []);
|
||||
const service = workerService({
|
||||
list,
|
||||
listMachineOptions,
|
||||
listOperatingSystems,
|
||||
supportsExecutionMode: vi.fn(
|
||||
|
|
@ -412,9 +414,108 @@ describe("environment gateway methods", () => {
|
|||
expect(listOperatingSystems.mock.calls).toEqual([["aws"], ["zeta"]]);
|
||||
expect(profiles[1]).not.toHaveProperty("machines");
|
||||
expect(profiles[1]).not.toHaveProperty("operatingSystems");
|
||||
|
||||
list.mockClear();
|
||||
vi.mocked(listDevicePairing).mockClear();
|
||||
const projected = await callEnvironmentMethod(
|
||||
"environments.list",
|
||||
{ projection: "profiles" },
|
||||
{ service },
|
||||
);
|
||||
expect(projected).toEqual([true, { environments: [], profiles }, undefined]);
|
||||
expect(service.list).not.toHaveBeenCalled();
|
||||
expect(listDevicePairing).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["worker", "pairing"])(
|
||||
"isolates profile discovery from %s inventory failures without hiding default errors",
|
||||
async (unavailable) => {
|
||||
const list = vi.fn(() => {
|
||||
if (unavailable === "worker") {
|
||||
throw new Error("private worker inventory failure");
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const service = workerService({ list });
|
||||
if (unavailable === "pairing") {
|
||||
vi.mocked(listDevicePairing).mockRejectedValue(new Error("pairing inventory unavailable"));
|
||||
}
|
||||
const full = await callEnvironmentMethod("environments.list", {}, { service });
|
||||
expect(full).toEqual([
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
code: ErrorCodes.UNAVAILABLE,
|
||||
message:
|
||||
unavailable === "worker"
|
||||
? "Error: environment inventory unavailable"
|
||||
: "Error: pairing inventory unavailable",
|
||||
},
|
||||
]);
|
||||
expect(service.listMachineOptions).not.toHaveBeenCalled();
|
||||
list.mockClear();
|
||||
vi.mocked(listDevicePairing).mockClear();
|
||||
|
||||
const projected = await callEnvironmentMethod(
|
||||
"environments.list",
|
||||
{ projection: "profiles" },
|
||||
{ service },
|
||||
);
|
||||
expect(projected).toEqual([
|
||||
true,
|
||||
{
|
||||
environments: [],
|
||||
profiles: [
|
||||
{ id: "aws", providerId: "crabbox" },
|
||||
{ id: "zeta", providerId: "static-ssh" },
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
]);
|
||||
expect(service.list).not.toHaveBeenCalled();
|
||||
expect(listDevicePairing).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps profile summaries when their machine catalog fails", async () => {
|
||||
const service = workerService({
|
||||
supportsExecutionMode: vi.fn((_profileId, mode) => mode === "remote-exec"),
|
||||
listMachineOptions: vi.fn(async () => {
|
||||
throw new Error("provider unavailable");
|
||||
}),
|
||||
});
|
||||
const context = mockContext(service);
|
||||
const respond = vi.fn();
|
||||
await environmentsHandlers["environments.list"]?.({
|
||||
params: { projection: "profiles" },
|
||||
respond,
|
||||
context,
|
||||
} as never);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
environments: [],
|
||||
profiles: [
|
||||
{
|
||||
id: "aws",
|
||||
providerId: "crabbox",
|
||||
executionMode: "remote-exec",
|
||||
executionModes: ["remote-exec"],
|
||||
},
|
||||
{
|
||||
id: "zeta",
|
||||
providerId: "static-ssh",
|
||||
executionMode: "remote-exec",
|
||||
executionModes: ["remote-exec"],
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(context.logGateway.warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("projects trust from recorded worker isolation without guessing unknown leases", () => {
|
||||
expect(summarizeWorkerEnvironment(workerRecord({ sharedHost: true }), NOW).trust).toBe(
|
||||
"persistent",
|
||||
|
|
|
|||
|
|
@ -301,12 +301,15 @@ export const environmentsHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const workers = listWorkerEnvironments(context);
|
||||
const environments = await listGatewayEnvironments(context, workers, params.runtimeId);
|
||||
const summarizedAtMs = Date.now();
|
||||
environments.push(
|
||||
...workers.map((record) => summarizeWorkerEnvironment(record, summarizedAtMs)),
|
||||
);
|
||||
let environments: EnvironmentSummary[] = [];
|
||||
if (params.projection !== "profiles") {
|
||||
const workers = listWorkerEnvironments(context);
|
||||
environments = await listGatewayEnvironments(context, workers, params.runtimeId);
|
||||
const summarizedAtMs = Date.now();
|
||||
environments.push(
|
||||
...workers.map((record) => summarizeWorkerEnvironment(record, summarizedAtMs)),
|
||||
);
|
||||
}
|
||||
const profiles = await listWorkerProfilesWithMachines(context);
|
||||
respond(true, { environments, ...(profiles.length > 0 ? { profiles } : {}) }, undefined);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@ describe("Cloud Workers mutation requests", () => {
|
|||
};
|
||||
}
|
||||
if (method === "environments.list") {
|
||||
if (!isRecord(params) || params.projection !== "profiles") {
|
||||
throw new Error("environment inventory unavailable");
|
||||
}
|
||||
return { environments: [], profiles: [{ id: "pending", operatingSystems: systems }] };
|
||||
}
|
||||
if (method !== "config.patch" || !validateConfigPatchParams(params)) {
|
||||
|
|
@ -154,6 +157,10 @@ describe("Cloud Workers mutation requests", () => {
|
|||
);
|
||||
expect(profiles?.querySelectorAll(".settings-row code")).toHaveLength(2);
|
||||
});
|
||||
await waitForFast(() =>
|
||||
expect(request).toHaveBeenCalledWith("environments.list", { projection: "profiles" }),
|
||||
);
|
||||
expect(page.textContent).not.toContain("environment inventory unavailable");
|
||||
const row = expectDefined(
|
||||
[...page.querySelectorAll(".settings-row")].find(
|
||||
(entry) => entry.querySelector("code")?.textContent === "pending",
|
||||
|
|
|
|||
|
|
@ -116,7 +116,9 @@ class CloudWorkersPage extends OpenClawLightDomElement {
|
|||
this.catalogLoading = true;
|
||||
this.catalogError = null;
|
||||
try {
|
||||
const result = await scope.client.request<EnvironmentsListResult>("environments.list", {});
|
||||
const result = await scope.client.request<EnvironmentsListResult>("environments.list", {
|
||||
projection: "profiles",
|
||||
});
|
||||
if (!this.gateway.isCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue