feat(gateway): add SDK environment discovery RPCs (#74867) thanks @ai-hpc

Co-authored-by: ai-hpc <183861985+ai-hpc@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
This commit is contained in:
NVIDIAN
2026-05-05 06:59:03 -07:00
committed by GitHub
parent 9f4a3932ed
commit 63de304102
21 changed files with 631 additions and 38 deletions

View File

@@ -1144,6 +1144,49 @@ Docs: https://docs.openclaw.ai
- Mattermost: refresh current native slash command registrations before accepting callbacks so stale tokens from deleted or regenerated commands stop being accepted without a gateway restart while failed validations stay briefly cached and lookup starts are rate-limited per command, gate each callback against the resolved command's own startup token so a token leaked for one slash command cannot poison another command's failure cache, redact slash validation lookup errors, and add a body read timeout to the multi-account routing path so slow callback senders cannot tie up the dispatcher. Thanks @feynman-hou and @eleqtrizit.
- Security/dotenv: block `COMSPEC` in workspace `.env` so a malicious repo cannot redirect Windows `cmd.exe` resolution, and lock in case-insensitive workspace-`.env` regression coverage for the full Windows shell trust-root family (`COMSPEC`, `PROGRAMFILES`, `PROGRAMW6432`, `SYSTEMROOT`, `WINDIR`). (#74460) Thanks @mmaps.
- Gateway/install: drop stale version-manager and package-manager PATH entries preserved from old service files during `gateway install --force` and doctor repair, so the repair path no longer recreates `gateway-path-nonminimal` warnings. Fixes #75220. (#75440) Thanks @leonaIee, @renaudcerrato, and @aaajiao.
## 2026.4.29
### Highlights
- Messaging and automation get active-run steering by default, visible-reply enforcement, spawned subagent routing metadata, and opt-in follow-up commitments for heartbeat-delivered reminders. Thanks @vincentkoc, @scoootscooob, @samzong, and @vignesh07.
- Memory grows into a people-aware wiki with provenance views, per-conversation Active Memory filters, partial recall on timeout, and bounded REM preview diagnostics. Thanks @vincentkoc, @quengh, @joeykrug, and @samzong.
- Provider/model coverage expands with NVIDIA onboarding/catalogs plus faster manifest-backed model/auth paths, Bedrock Opus 4.7 thinking parity, and safer Codex/OpenAI-compatible replay and streaming behavior. Thanks @eleqtrizit, @shakkernerd, @prasad-yashdeep, @woodhouse-bot, and @LyHug.
- Gateway and packaged-plugin reliability focuses on slow-host startup, reusable model catalogs, event-loop readiness diagnostics, runtime-dependency repair, stale-session recovery, and version-scoped update caches. Thanks @lpendeavors, @DerFlash, @vincentkoc, @pashpashpash, and @jhsmith409.
- Channel fixes cluster around Slack Block Kit limits, Telegram proxy/webhook/polling/send resilience, Discord startup/rate-limit handling, WhatsApp delivery/liveness, and Microsoft Teams/Matrix/Feishu edge cases. Thanks @slackapi, @SymbolStar, @djgeorg3, @TinyTb, @dseravalli, @nklock, and @alex-xuweilong.
- Security and operations add OpenGrep scanning, sharper GHSA triage policy, safer exec/pairing/owner-scope handling, Docker/onboarding automation, and web-fetch IPv6 ULA opt-in for trusted proxy stacks. Thanks @jesse-merhi, @pgondhi987, @mmaps, @jinjimz, and @jeffrey701.
### Changes
- Security/tools: configured tool sections (`tools.exec`, `tools.fs`) no longer implicitly widen restrictive profiles (`messaging`, `minimal`). Users who need those tools under a restricted profile must add explicit `alsoAllow` entries; a startup warning identifies affected configs. Fixes #47487. Thanks @amknight.
- Gateway/SDK: add SDK-facing artifact list/get/download RPCs and App SDK helpers with transcript provenance and download-source guardrails. Refs #74706. Thanks @tmimmanuel.
- Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
- Messages/queue: make `steer` drain all pending Pi steering messages at the next model boundary, keep legacy one-at-a-time steering as `queue`, and add a dedicated steering queue docs page. Thanks @vincentkoc.
- Messages/queue: default active-run queueing to `steer` with a 500ms followup fallback debounce, and document the queue modes, precedence, and drop policies on the command queue page. Thanks @vincentkoc.
- Messages: add global `messages.visibleReplies` so operators can require visible output to go through `message(action=send)` for any source chat, while `messages.groupChat.visibleReplies` stays available as the group/channel override. Thanks @scoootscooob.
- Gateway/events: surface `spawnedBy` on subagent chat and agent broadcast payloads so clients can route child session events without an extra session lookup. (#63244) Thanks @samzong.
- Gateway/SDK: add read-only `environments.list` and `environments.status` RPCs so app clients can discover Gateway-local and node environment candidates without enabling provisioning. (#74708) Thanks @BunsDev.
- Memory/wiki: add agent-facing people wiki metadata, canonical aliases, person cards, relationship graphs, privacy/provenance reports, evidence-kind drilldown, and search modes for person lookup, question routing, source evidence, and raw claims. Thanks @vincentkoc.
- Active Memory: add optional per-conversation `allowedChatIds` and `deniedChatIds` filters so operators can enable recall only for selected direct, group, or channel conversations while keeping broad sessions skipped. (#67977) Thanks @quengh.
- Active Memory: return bounded partial recall summaries when the hidden memory sub-agent times out, including the default temporary-transcript path, so useful recovered context is not discarded. (#73219) Thanks @joeykrug.
- Gateway/memory: add a read-only `doctor.memory.remHarness` RPC so operator clients can preview bounded REM dreaming output without running mutation paths. (#66673) Thanks @samzong.
- Providers/NVIDIA: add the NVIDIA provider with API-key onboarding, setup docs, static catalog metadata, and literal model-ref picker support so NVIDIA hosted models can be selected with their provider prefix intact. (#71204) Thanks @eleqtrizit.
- Models: suppress explicitly configured openai-codex/gpt-5.4-mini inline entries so a stale models config written by `openclaw doctor --fix` cannot bypass the manifest capability block and cause repeated assistant-turn failures when the runtime switches to that model on ChatGPT-backed Codex accounts. Conditional suppressions (e.g. qwen Coding Plan endpoint guards) remain bypassable by explicit user configuration. (#74451) Thanks @0xCyda, @hclsys, and @Marvae.
- Added SQLite-backed plugin state store (`api.runtime.state.openKeyedStore`) for restart-safe keyed registries with TTL, eviction, and automatic plugin isolation. Thanks @amknight.
- Plugin SDK: mark remaining legacy alias exports and diffs tool/config aliases with deprecation metadata, and add a guard so future legacy alias comments require `@deprecated` tags. Thanks @vincentkoc.
- CLI/QR/dependencies: internalize small terminal progress and QR wrapper helpers while keeping the real QR encoder dependency direct, reducing the default runtime dependency graph without changing QR output behavior. Thanks @vincentkoc.
- Dependencies: refresh workspace runtime, plugin, and tooling packages, including ACP, Pi, AWS SDK, TypeBox, pnpm, oxlint, oxfmt, jsdom, pdfjs, ciao, and tokenjuice, while keeping patched ACP behavior and lint gates current. Thanks @mariozechner.
- Gateway/dev: run `pnpm gateway:watch` through a named tmux session by default, with `gateway:watch:raw` and `OPENCLAW_GATEWAY_WATCH_TMUX=0` for foreground mode, so repeated starts respawn an inspectable watcher without trapping the invoking agent shell. Thanks @vincentkoc.
- Gateway/diagnostics: emit an opt-in startup diagnostics timeline that records gateway lifecycle and plugin-load phases behind a config flag, so slow-start diagnosis no longer requires bespoke instrumentation. Thanks @shakkernerd.
- Control UI/i18n: extend the locale registry with new Persian (fa), Dutch (nl), Vietnamese (vi), Italian (it), Arabic (ar), and Thai (th) entries and ship `fa`, `nl`, `vi`, and `zh-TW` docs glossaries, so the docs translation pipeline and the Control UI language picker stay aligned across surfaces. Thanks @vincentkoc.
- Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
- Channels/Yuanbao: update plugin GitHub location to YuanbaoTeam/yuanbao-openclaw-plugin and add "yuanbao" alias to channel catalog. (#74253) Thanks @loongfay.
- Docker setup: add `OPENCLAW_SKIP_ONBOARDING` so automated Docker installs can skip the interactive onboarding step while still applying gateway defaults. (#55518) Thanks @jinjimz.
- Security policy: classify media/base64 decode and format-conversion overhead after configured acceptance limits as performance-only for GHSA triage unless a report demonstrates a limit bypass, crash, exhaustion, data exposure, or another boundary bypass. (#74311)
- Security/OpenGrep: add a precise OpenGrep rulepack, source-rule compiler, provenance metadata check, and PR/full scan workflows that validate first-party code and rulepack-only changes while uploading SARIF to GitHub Code Scanning. (#69483) Thanks @jesse-merhi.
### Fixes
- Voice Call: resolve SecretRef-backed Twilio auth tokens and realtime/streaming provider API keys before initializing call providers, so SecretRef-backed voice-call credentials reach runtime as strings. (#73632) Thanks @VACInc.
- Security/outbound: strip re-formed HTML tags during plain-text sanitization so nested tag fragments cannot leave a CodeQL-detected `<script>` sequence behind. Thanks @vincentkoc.
- Security/secrets: compare credential bytes with padded timing-safe buffers instead of hashing candidate passwords before equality checks. Thanks @vincentkoc.

View File

@@ -13,6 +13,14 @@ public enum ErrorCode: String, Codable, Sendable {
case unavailable = "UNAVAILABLE"
}
public enum EnvironmentStatus: String, Codable, Sendable {
case available = "available"
case unavailable = "unavailable"
case starting = "starting"
case stopping = "stopping"
case error = "error"
}
public enum NodePresenceAliveReason: String, Codable, Sendable {
case background = "background"
case silentPush = "silent_push"
@@ -380,6 +388,96 @@ public struct ErrorShape: Codable, Sendable {
}
}
public struct EnvironmentSummary: Codable, Sendable {
public let id: String
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let capabilities: [String]?
public init(
id: String,
type: String,
label: String?,
status: EnvironmentStatus,
capabilities: [String]?)
{
self.id = id
self.type = type
self.label = label
self.status = status
self.capabilities = capabilities
}
private enum CodingKeys: String, CodingKey {
case id
case type
case label
case status
case capabilities
}
}
public struct EnvironmentsListParams: Codable, Sendable {}
public struct EnvironmentsListResult: Codable, Sendable {
public let environments: [EnvironmentSummary]
public init(
environments: [EnvironmentSummary])
{
self.environments = environments
}
private enum CodingKeys: String, CodingKey {
case environments
}
}
public struct EnvironmentsStatusParams: Codable, Sendable {
public let environmentid: String
public init(
environmentid: String)
{
self.environmentid = environmentid
}
private enum CodingKeys: String, CodingKey {
case environmentid = "environmentId"
}
}
public struct EnvironmentsStatusResult: Codable, Sendable {
public let id: String
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let capabilities: [String]?
public init(
id: String,
type: String,
label: String?,
status: EnvironmentStatus,
capabilities: [String]?)
{
self.id = id
self.type = type
self.label = label
self.status = status
self.capabilities = capabilities
}
private enum CodingKeys: String, CodingKey {
case id
case type
case label
case status
case capabilities
}
}
public struct AgentEvent: Codable, Sendable {
public let runid: String
public let seq: Int

View File

@@ -13,6 +13,14 @@ public enum ErrorCode: String, Codable, Sendable {
case unavailable = "UNAVAILABLE"
}
public enum EnvironmentStatus: String, Codable, Sendable {
case available = "available"
case unavailable = "unavailable"
case starting = "starting"
case stopping = "stopping"
case error = "error"
}
public enum NodePresenceAliveReason: String, Codable, Sendable {
case background = "background"
case silentPush = "silent_push"
@@ -380,6 +388,96 @@ public struct ErrorShape: Codable, Sendable {
}
}
public struct EnvironmentSummary: Codable, Sendable {
public let id: String
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let capabilities: [String]?
public init(
id: String,
type: String,
label: String?,
status: EnvironmentStatus,
capabilities: [String]?)
{
self.id = id
self.type = type
self.label = label
self.status = status
self.capabilities = capabilities
}
private enum CodingKeys: String, CodingKey {
case id
case type
case label
case status
case capabilities
}
}
public struct EnvironmentsListParams: Codable, Sendable {}
public struct EnvironmentsListResult: Codable, Sendable {
public let environments: [EnvironmentSummary]
public init(
environments: [EnvironmentSummary])
{
self.environments = environments
}
private enum CodingKeys: String, CodingKey {
case environments
}
}
public struct EnvironmentsStatusParams: Codable, Sendable {
public let environmentid: String
public init(
environmentid: String)
{
self.environmentid = environmentid
}
private enum CodingKeys: String, CodingKey {
case environmentid = "environmentId"
}
}
public struct EnvironmentsStatusResult: Codable, Sendable {
public let id: String
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let capabilities: [String]?
public init(
id: String,
type: String,
label: String?,
status: EnvironmentStatus,
capabilities: [String]?)
{
self.id = id
self.type = type
self.label = label
self.status = status
self.capabilities = capabilities
}
private enum CodingKeys: String, CodingKey {
case id
case type
case label
case status
case capabilities
}
}
public struct AgentEvent: Codable, Sendable {
public let runid: String
public let seq: Int

View File

@@ -25,24 +25,25 @@ resources.
`@openclaw/sdk` ships with:
| Surface | Status | What it does |
| ------------------------- | ------ | -------------------------------------------------------------------------- |
| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. |
| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. |
| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. |
| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. |
| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. |
| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. |
| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. |
| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. |
| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. |
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
| `oc.tools` | Ready | Lists, scopes, and invokes Gateway tools through the policy pipeline. |
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
| Surface | Status | What it does |
| ------------------------- | ------- | --------------------------------------------------------------------------------- |
| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. |
| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. |
| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. |
| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. |
| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. |
| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. |
| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. |
| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. |
| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. |
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
| `oc.tools` | Ready | Lists, scopes, and invokes Gateway tools through the policy pipeline. |
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
| `oc.environments` | Partial | Lists Gateway-local and node environment candidates; create/delete are not wired. |
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
The SDK also exports the core types used by those surfaces:
`AgentRunParams`, `RunResult`, `RunStatus`, `OpenClawEvent`,
@@ -253,6 +254,13 @@ const approvals = await oc.approvals.list();
await oc.approvals.respond("approval-id", { decision: "approve" });
```
Environment helpers expose read-only Gateway-local and node discovery:
```typescript
const { environments } = await oc.environments.list();
await oc.environments.status(environments[0].id);
```
## Explicitly Unsupported Today
The SDK includes names for the product model we want, but it does not silently
@@ -264,9 +272,7 @@ await oc.tasks.list();
await oc.tasks.get("task-id");
await oc.tasks.cancel("task-id");
await oc.environments.list();
await oc.environments.create({});
await oc.environments.status("environment-id");
await oc.environments.delete("environment-id");
```

View File

@@ -392,6 +392,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring.
- `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent.
- `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.
- `environments.list` and `environments.status` expose read-only Gateway-local and node environment discovery for SDK clients.
- `agent.identity.get` returns the effective assistant identity for an agent or session.
- `agent.wait` waits for a run to finish and returns the terminal snapshot when available.

View File

@@ -58,18 +58,18 @@ oc.models.list();
oc.models.status(); // Gateway models.authStatus
oc.tools.list();
oc.tools.invoke(...); // future API: current SDK throws unsupported
oc.tools.invoke("tool-name", { sessionKey, idempotencyKey });
oc.artifacts.list({ runId }); // future API: current SDK throws unsupported
oc.artifacts.get(artifactId); // future API: current SDK throws unsupported
oc.artifacts.download(artifactId); // future API: current SDK throws unsupported
oc.artifacts.list({ runId });
oc.artifacts.get(artifactId, { runId });
oc.artifacts.download(artifactId, { runId });
oc.approvals.list();
oc.approvals.respond(approvalId, ...);
oc.environments.list(); // future API: current SDK throws unsupported
oc.environments.list();
oc.environments.create(...); // future API: current SDK throws unsupported
oc.environments.status(environmentId); // future API: current SDK throws unsupported
oc.environments.status(environmentId);
oc.environments.delete(environmentId); // future API: current SDK throws unsupported
```

View File

@@ -8,6 +8,8 @@ import type {
ArtifactsDownloadResult,
ArtifactsGetResult,
ArtifactsListResult,
EnvironmentSummary,
EnvironmentsListResult,
GatewayEvent,
GatewayRequestOptions,
OpenClawEvent,
@@ -819,9 +821,8 @@ export class EnvironmentsNamespace extends RpcNamespace {
super(client, "environments");
}
async list(params?: unknown): Promise<unknown> {
void params;
return unsupportedGatewayApi("oc.environments.list");
async list(params?: unknown): Promise<EnvironmentsListResult> {
return await this.call("list", params ?? {});
}
async create(params?: unknown): Promise<unknown> {
@@ -829,9 +830,8 @@ export class EnvironmentsNamespace extends RpcNamespace {
return unsupportedGatewayApi("oc.environments.create");
}
async status(environmentId: string): Promise<unknown> {
void environmentId;
return unsupportedGatewayApi("oc.environments.status");
async status(environmentId: string): Promise<EnvironmentSummary> {
return await this.call("status", { environmentId });
}
async delete(environmentId: string): Promise<unknown> {

View File

@@ -335,15 +335,9 @@ describe("OpenClaw SDK", () => {
await expect(oc.tasks.cancel("task_123")).rejects.toThrow(
"oc.tasks.cancel is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.environments.list()).rejects.toThrow(
"oc.environments.list is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.environments.create({ provider: "testbox" })).rejects.toThrow(
"oc.environments.create is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.environments.status("environment_123")).rejects.toThrow(
"oc.environments.status is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.environments.delete("environment_123")).rejects.toThrow(
"oc.environments.delete is not supported by the current OpenClaw Gateway yet",
);
@@ -379,6 +373,36 @@ describe("OpenClaw SDK", () => {
]);
});
it("lists and reads environment status through current Gateway methods", async () => {
const gatewayEnvironment = {
id: "gateway",
type: "local",
label: "Gateway local",
status: "available",
capabilities: ["agent.run"],
};
const transport = new FakeTransport({
"environments.list": { environments: [gatewayEnvironment] },
"environments.status": gatewayEnvironment,
});
const oc = new OpenClaw({ transport });
await expect(oc.environments.list()).resolves.toEqual({
environments: [gatewayEnvironment],
});
await expect(oc.environments.status("gateway")).resolves.toEqual(gatewayEnvironment);
await expect(oc.environments.create({ provider: "testbox" })).rejects.toThrow(
"oc.environments.create is not supported by the current OpenClaw Gateway yet",
);
await expect(oc.environments.delete("gateway")).rejects.toThrow(
"oc.environments.delete is not supported by the current OpenClaw Gateway yet",
);
expect(transport.calls).toEqual([
{ method: "environments.list", params: {}, options: undefined },
{ method: "environments.status", params: { environmentId: "gateway" }, options: undefined },
]);
});
it("cancels runs and checks model auth status through current Gateway methods", async () => {
const transport = new FakeTransport({
agent: { status: "accepted", runId: "run_without_session" },

View File

@@ -27,6 +27,8 @@ export type {
ArtifactsListResult,
ConnectableOpenClawTransport,
EnvironmentSelection,
EnvironmentSummary,
EnvironmentsListResult,
GatewayEvent,
GatewayRequestOptions,
JsonObject,

View File

@@ -40,6 +40,18 @@ export type EnvironmentSelection =
| { type: "managed"; provider: string; repo?: string; ref?: string }
| { type: "ephemeral"; provider: string; repo?: string; ref?: string };
export type EnvironmentSummary = {
id: string;
type: "local" | "gateway" | "node" | "managed" | "ephemeral" | (string & {});
label?: string;
status: "available" | "unavailable" | "starting" | "stopping" | "error";
capabilities?: string[];
};
export type EnvironmentsListResult = {
environments: EnvironmentSummary[];
};
export type WorkspaceSelection = {
cwd?: string;
repo?: string;

View File

@@ -36,6 +36,8 @@ describe("method scope resolution", () => {
["tools.invoke", ["operator.write"]],
["sessions.messages.subscribe", ["operator.read"]],
["sessions.messages.unsubscribe", ["operator.read"]],
["environments.list", ["operator.read"]],
["environments.status", ["operator.read"]],
["diagnostics.stability", ["operator.read"]],
["node.pair.approve", ["operator.pairing"]],
["poll", ["operator.write"]],

View File

@@ -117,6 +117,8 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"last-heartbeat",
"node.list",
"node.describe",
"environments.list",
"environments.status",
"chat.history",
"config.get",
"config.schema.lookup",

View File

@@ -171,6 +171,18 @@ import {
type PluginsUiDescriptorsParams,
PluginsUiDescriptorsParamsSchema,
ErrorCodes,
type EnvironmentSummary,
EnvironmentSummarySchema,
type EnvironmentsListParams,
EnvironmentsListParamsSchema,
type EnvironmentsListResult,
EnvironmentsListResultSchema,
type EnvironmentsStatusParams,
EnvironmentsStatusParamsSchema,
type EnvironmentsStatusResult,
EnvironmentsStatusResultSchema,
type EnvironmentStatus,
EnvironmentStatusSchema,
type ErrorShape,
ErrorShapeSchema,
type EventFrame,
@@ -411,6 +423,12 @@ export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
);
export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRenameParamsSchema);
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
export const validateEnvironmentsListParams = ajv.compile<EnvironmentsListParams>(
EnvironmentsListParamsSchema,
);
export const validateEnvironmentsStatusParams = ajv.compile<EnvironmentsStatusParams>(
EnvironmentsStatusParamsSchema,
);
export const validateNodePendingAckParams = ajv.compile<NodePendingAckParams>(
NodePendingAckParamsSchema,
);
@@ -670,6 +688,12 @@ export {
PresenceEntrySchema,
SnapshotSchema,
ErrorShapeSchema,
EnvironmentStatusSchema,
EnvironmentSummarySchema,
EnvironmentsListParamsSchema,
EnvironmentsListResultSchema,
EnvironmentsStatusParamsSchema,
EnvironmentsStatusResultSchema,
StateVersionSchema,
AgentEventSchema,
MessageActionParamsSchema,
@@ -915,6 +939,12 @@ export type {
SkillsDetailResult,
SkillsInstallParams,
SkillsUpdateParams,
EnvironmentStatus,
EnvironmentSummary,
EnvironmentsListParams,
EnvironmentsListResult,
EnvironmentsStatusParams,
EnvironmentsStatusResult,
NodePairRejectParams,
NodePairRemoveParams,
NodePairVerifyParams,

View File

@@ -6,6 +6,7 @@ export * from "./schema/commands.js";
export * from "./schema/config.js";
export * from "./schema/cron.js";
export * from "./schema/error-codes.js";
export * from "./schema/environments.js";
export * from "./schema/exec-approvals.js";
export * from "./schema/devices.js";
export * from "./schema/frames.js";

View File

@@ -0,0 +1,37 @@
import { Type } from "typebox";
import { NonEmptyString } from "./primitives.js";
export const EnvironmentStatusSchema = Type.String({
enum: ["available", "unavailable", "starting", "stopping", "error"],
});
function createEnvironmentSummarySchema() {
return Type.Object(
{
id: NonEmptyString,
type: NonEmptyString,
label: Type.Optional(NonEmptyString),
status: EnvironmentStatusSchema,
capabilities: Type.Optional(Type.Array(NonEmptyString)),
},
{ additionalProperties: false },
);
}
export const EnvironmentSummarySchema = createEnvironmentSummarySchema();
export const EnvironmentsListParamsSchema = Type.Object({}, { additionalProperties: false });
export const EnvironmentsListResultSchema = Type.Object(
{
environments: Type.Array(EnvironmentSummarySchema),
},
{ additionalProperties: false },
);
export const EnvironmentsStatusParamsSchema = Type.Object(
{ environmentId: NonEmptyString },
{ additionalProperties: false },
);
export const EnvironmentsStatusResultSchema = createEnvironmentSummarySchema();

View File

@@ -120,6 +120,14 @@ import {
DeviceTokenRevokeParamsSchema,
DeviceTokenRotateParamsSchema,
} from "./devices.js";
import {
EnvironmentSummarySchema,
EnvironmentsListParamsSchema,
EnvironmentsListResultSchema,
EnvironmentsStatusParamsSchema,
EnvironmentsStatusResultSchema,
EnvironmentStatusSchema,
} from "./environments.js";
import {
ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
@@ -240,6 +248,12 @@ export const ProtocolSchemas = {
StateVersion: StateVersionSchema,
Snapshot: SnapshotSchema,
ErrorShape: ErrorShapeSchema,
EnvironmentStatus: EnvironmentStatusSchema,
EnvironmentSummary: EnvironmentSummarySchema,
EnvironmentsListParams: EnvironmentsListParamsSchema,
EnvironmentsListResult: EnvironmentsListResultSchema,
EnvironmentsStatusParams: EnvironmentsStatusParamsSchema,
EnvironmentsStatusResult: EnvironmentsStatusResultSchema,
AgentEvent: AgentEventSchema,
MessageActionParams: MessageActionParamsSchema,
SendParams: SendParamsSchema,

View File

@@ -14,6 +14,12 @@ export type Snapshot = SchemaType<"Snapshot">;
export type PresenceEntry = SchemaType<"PresenceEntry">;
export type ErrorShape = SchemaType<"ErrorShape">;
export type StateVersion = SchemaType<"StateVersion">;
export type EnvironmentStatus = SchemaType<"EnvironmentStatus">;
export type EnvironmentSummary = SchemaType<"EnvironmentSummary">;
export type EnvironmentsListParams = SchemaType<"EnvironmentsListParams">;
export type EnvironmentsListResult = SchemaType<"EnvironmentsListResult">;
export type EnvironmentsStatusParams = SchemaType<"EnvironmentsStatusParams">;
export type EnvironmentsStatusResult = SchemaType<"EnvironmentsStatusResult">;
export type AgentEvent = SchemaType<"AgentEvent">;
export type AgentIdentityParams = SchemaType<"AgentIdentityParams">;
export type AgentIdentityResult = SchemaType<"AgentIdentityResult">;

View File

@@ -70,6 +70,8 @@ const BASE_METHODS = [
"tools.catalog",
"tools.effective",
"tools.invoke",
"environments.list",
"environments.status",
"agents.list",
"agents.create",
"agents.update",

View File

@@ -20,6 +20,7 @@ import { cronHandlers } from "./server-methods/cron.js";
import { deviceHandlers } from "./server-methods/devices.js";
import { diagnosticsHandlers } from "./server-methods/diagnostics.js";
import { doctorHandlers } from "./server-methods/doctor.js";
import { environmentsHandlers } from "./server-methods/environments.js";
import { execApprovalsHandlers } from "./server-methods/exec-approvals.js";
import { healthHandlers } from "./server-methods/health.js";
import { logsHandlers } from "./server-methods/logs.js";
@@ -96,6 +97,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...deviceHandlers,
...diagnosticsHandlers,
...doctorHandlers,
...environmentsHandlers,
...execApprovalsHandlers,
...webHandlers,
...modelsHandlers,

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listDevicePairing } from "../../infra/device-pairing.js";
import { listNodePairing } from "../../infra/node-pairing.js";
import { ErrorCodes } from "../protocol/index.js";
import { environmentsHandlers } from "./environments.js";
vi.mock("../../infra/device-pairing.js", () => ({
listDevicePairing: vi.fn(),
}));
vi.mock("../../infra/node-pairing.js", () => ({
listNodePairing: vi.fn(),
}));
function mockContext() {
return {
nodeRegistry: {
listConnected: () => [
{
nodeId: "node-live",
connId: "conn-live",
displayName: "Live Node",
platform: "ios",
caps: ["camera"],
commands: ["system.run"],
connectedAtMs: 123,
},
],
},
};
}
async function callEnvironmentMethod(
method: "environments.list" | "environments.status",
params: unknown,
) {
const respond = vi.fn();
await environmentsHandlers[method]?.({
params: params as Record<string, unknown>,
respond,
context: mockContext(),
} as never);
return respond.mock.calls[0];
}
beforeEach(() => {
vi.mocked(listDevicePairing).mockResolvedValue({ paired: [] } as never);
vi.mocked(listNodePairing).mockResolvedValue({
paired: [
{
nodeId: "node-offline",
displayName: "Offline Node",
caps: ["screen"],
commands: ["camera.snap"],
},
],
} as never);
});
describe("environment gateway methods", () => {
it("lists the gateway and node environment candidates", async () => {
const [ok, payload] = await callEnvironmentMethod("environments.list", {});
expect(ok).toBe(true);
expect(payload).toEqual({
environments: [
{
id: "gateway",
type: "local",
label: "Gateway local",
status: "available",
capabilities: ["agent.run", "sessions", "tools", "workspace"],
},
{
id: "node:node-live",
type: "node",
label: "Live Node",
status: "available",
capabilities: ["camera", "system.run"],
},
{
id: "node:node-offline",
type: "node",
label: "Offline Node",
status: "unavailable",
capabilities: ["camera.snap", "screen"],
},
],
});
});
it("returns status for one environment", async () => {
const [ok, payload] = await callEnvironmentMethod("environments.status", {
environmentId: "node:node-live",
});
expect(ok).toBe(true);
expect(payload).toEqual({
id: "node:node-live",
type: "node",
label: "Live Node",
status: "available",
capabilities: ["camera", "system.run"],
});
});
it("rejects unknown environment ids", async () => {
const [ok, , error] = await callEnvironmentMethod("environments.status", {
environmentId: "missing",
});
expect(ok).toBe(false);
expect(error).toEqual({
code: ErrorCodes.INVALID_REQUEST,
message: "unknown environmentId",
});
});
});

View File

@@ -0,0 +1,95 @@
import { listDevicePairing } from "../../infra/device-pairing.js";
import { listNodePairing } from "../../infra/node-pairing.js";
import type { NodeListNode } from "../../shared/node-list-types.js";
import { createKnownNodeCatalog, listKnownNodes } from "../node-catalog.js";
import {
type EnvironmentSummary,
ErrorCodes,
errorShape,
validateEnvironmentsListParams,
validateEnvironmentsStatusParams,
} from "../protocol/index.js";
import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
const GATEWAY_ENVIRONMENT: EnvironmentSummary = {
id: "gateway",
type: "local",
label: "Gateway local",
status: "available",
capabilities: ["agent.run", "sessions", "tools", "workspace"],
};
function uniqueSortedStrings(...items: Array<readonly string[] | undefined>): string[] {
const values = new Set<string>();
for (const item of items) {
for (const value of item ?? []) {
const trimmed = value.trim();
if (trimmed) {
values.add(trimmed);
}
}
}
return [...values].toSorted((left, right) => left.localeCompare(right));
}
function summarizeNodeEnvironment(node: NodeListNode): EnvironmentSummary {
const capabilities = uniqueSortedStrings(node.caps, node.commands);
return {
id: `node:${node.nodeId}`,
type: "node",
label: node.displayName ?? node.nodeId,
status: node.connected ? "available" : "unavailable",
...(capabilities.length > 0 ? { capabilities } : {}),
};
}
function listEnvironmentSummaries(nodes: readonly NodeListNode[]): EnvironmentSummary[] {
return [GATEWAY_ENVIRONMENT, ...nodes.map(summarizeNodeEnvironment)];
}
async function listEnvironments(context: GatewayRequestContext) {
const [devicePairing, nodePairing] = await Promise.all([listDevicePairing(), listNodePairing()]);
const catalog = createKnownNodeCatalog({
pairedDevices: devicePairing.paired,
pairedNodes: nodePairing.paired,
connectedNodes: context.nodeRegistry.listConnected(),
});
return listEnvironmentSummaries(listKnownNodes(catalog));
}
export const environmentsHandlers: GatewayRequestHandlers = {
"environments.list": async ({ params, respond, context }) => {
if (!validateEnvironmentsListParams(params)) {
respondInvalidParams({
respond,
method: "environments.list",
validator: validateEnvironmentsListParams,
});
return;
}
await respondUnavailableOnThrow(respond, async () => {
respond(true, { environments: await listEnvironments(context) }, undefined);
});
},
"environments.status": async ({ params, respond, context }) => {
if (!validateEnvironmentsStatusParams(params)) {
respondInvalidParams({
respond,
method: "environments.status",
validator: validateEnvironmentsStatusParams,
});
return;
}
await respondUnavailableOnThrow(respond, async () => {
const environment = (await listEnvironments(context)).find(
(entry) => entry.id === params.environmentId,
);
if (!environment) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown environmentId"));
return;
}
respond(true, environment, undefined);
});
},
};