Agent Command Forwarding
Agent command forwarding lets the Hub push a call down to a registered agent over the reverse tunnel that the agent opened when it registered. Because the agent dialled out to the Hub, the Hub never opens a fresh connection back — it reuses the agent's live registration channel. The dashboard Agents view drives this: restart an agent, trigger and poll a self-update, change status/access, and read an agent's own connectors.
Every forwarded call is authorized on two independent sides — once by the Hub, once by the agent — and the responses come back over the same tunnel.
Two forwarding mechanisms
AgentsRegister exposes two ways to reach an agent. They differ in whether the agent's reply is correlated back to the caller.
ForwardMessage(id, {protocol, taskId}) | ConnectorHub.ForwardRequest | |
|---|---|---|
| Shape | Fire-and-forget | Request–response |
| Response correlation | None — the agent's reply is dropped (no taskId match) | taskId-correlated — the agent's reply is routed back |
| Hub-side per-method auth | No | Yes (IsAuthorizedFor) |
| Failure visibility | Returns a delivery ack only → looks like success even when the agent refused | The agent's accept/reject rejects/resolves the caller promise |
ForwardMessage returns a delivery ack, not an execution ack. If the agent rejects the command (its auth gate refuses the method), that rejection is silently dropped and the Hub looks like it succeeded. Route any command that an agent can decline — restart, self-update, status changes — through ForwardRequest so the refusal surfaces. Reserve ForwardMessage for genuinely fire-and-forget signals.
Two-sided authorization
Hub-side
ConnectorHub.createForwardRequest authorizes the wrapped call with IsAuthorizedFor(decoded.name, $data.token):
- The caller's session token travels automatically in the LCP protocol —
LiveContentWrapper.toProtocol()defaultsprotocol.tokentoAuthHttpResolver.getToken(). A forward connector therefore does not need to setToken; only writingclient.Token = ""breaks it (it overrides the default with an anonymous token → denied). - The built-in Admin group is granted
"*", which pattern-matches every method, so an admin session token is authorized for any forwarded method. - The outer
ConnectorHub.ForwardRequestmethod itself is grantedFOR_ALL_USERS, andgetAuthorizedMethodsappends theFOR_ALL_USERSset unconditionally — so reaching the forwarder never requires a valid session. The wrapped inner method is where the real check happens.
Agent-side
Once the Hub forwards the call, the agent runs its own authorization. An agent that enables an auth gate (for example a PIN gate on its local GUI) checks the forwarded method against its own whitelist; with the gate open (no PIN configured) everything passes. Machine-to-machine and Hub-forwarded methods must be listed there — for example RuntimeHandler.Exit for a remote restart, or the self-update connector methods.
The agent-side whitelist lives in the agent's own build. Editing it takes effect only once that specific agent is rebuilt and redeployed — editing the source and building locally does nothing for an agent already running elsewhere. When a forwarded command succeeds on one agent and fails "not authorized" on another, check that both run the same build (the Agents view shows each agent's buildTime).
Building a forward client from the Hub GUI
A per-agent forward client is a FORWARD_CLIENT BaseConnector. The EnvironmentConnector is the reference implementation; a minimal one looks like this:
class AgentForwardConnector extends BaseConnector {
private readonly targetNamespace : string;
constructor($namespace : string, $reconnect : boolean | number, $configUrl : string) {
super($reconnect, $configUrl);
this.targetNamespace = $namespace;
}
public Exit($code : number) : Promise<any> {
return this.asyncInvoke("Exit", $code);
}
protected getServerNamespaces() : any {
const namespaces : any = {};
namespaces[WebServiceClientType.HUB_CONNECTOR] = "Io.Oidis.Hub.Utils.EnvironmentHelper";
namespaces[WebServiceClientType.FORWARD_CLIENT] = this.targetNamespace;
return namespaces;
}
}
Wire it to a specific agent, then invoke:
const client : AgentForwardConnector = new AgentForwardConnector(namespace, true, configUrl);
client.setAgent(agent.id, configUrl);
client.setForwardingMethod("Io.Oidis.Hub.Utils.ConnectorHub.ForwardRequest");
await client.Exit(130); // rejects if the Hub or the agent refuses
getClientType() to FORWARD_CLIENTBaseConnector's constructor runs init() before the subclass assigns targetNamespace and before setAgent() switches the connector to a FORWARD_CLIENT. If getClientType() forced FORWARD_CLIENT, that first init() would resolve the not-yet-set namespace and throw "ForwardClient is not defined as WebServiceClient in getServerNamespaces". Instead let the base auto-detect the Hub client type on the first init and give it a real static HUB_CONNECTOR namespace, so a client is built (that first client is a throwaway). setAgent() — which reads getId() on the existing client, then sets clientType = FORWARD_CLIENT and re-runs init() — rebuilds it as the forwarding client against targetNamespace, which is set by then. This mirrors EnvironmentConnector.
Restarting an agent
Restart forwards RuntimeHandler.Exit, which calls Loader.Exit → onCloseHandler — the server is stopped and process.exit runs, so the agent exits and its supervisor restarts it (it then re-registers). Routing this through ForwardRequest rather than a fire-and-forget message is what makes a refusal — Hub- or agent-side — come back as a rejection the operator sees, while a successful restart resolves cleanly.
Handling a forwarded 401
The shared response formatter in WebServiceClientFactory turns a NOT_AUTHORIZED response into a full-page redirect to the not-authorized error page (which logs the user out) — correct for a genuine session expiry, but wrong for a forwarded call, where the 401 means the agent refused, not the user. The formatter distinguishes them by the response owner:
const forwarded : boolean = !ObjectValidator.IsEmptyOrNull($owner) && $owner.IsTypeOf(ForwardingClient);
if (!forwarded && Loader.getInstance().getEnvironmentArgs().HtmlOutputAllowed()) {
// genuine session-expiry 401 on the user's own client -> redirect to login
} else {
// forwarded 401 (or non-browser runtime) -> surface locally as a caught rejection
}
IsTypeOf is an exact class-name match and nothing subclasses ForwardingClient, so a 401 on any ordinary connector (a WebSocketsClient/AjaxClient owner) still logs the user out exactly as before.
Two things guarantee the local-surface path is always the right one for a forwarded call. First, the outer ForwardRequest gate is FOR_ALL_USERS, so an expired token passes it — it does not 401 there. Second, when the inner method-level check fails, createForwardRequest replies with an OnError (a Request.Exception/ERROR), which already surfaces locally — not a NOT_AUTHORIZED. The only NOT_AUTHORIZED that reaches a browser ForwardingClient is an agent-not-authorized reply (or the agent's own refusal), which is about the agent, never the user's session.
See also: Dashboard and Tools Menu | Connectors and API | External Connectors | SSO and Authentication