Bind gateway approval access to requester metadata [AI] (#81380)

* fix: bind approval access to requester metadata

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing claude review

* addressing ci

* fix: complete root-cause handling

* addressing review-skill

* addressing codex review

* addressing ci

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-14 23:21:34 +05:30
committed by GitHub
parent c9b6b0be0e
commit 386d321634
37 changed files with 2268 additions and 88 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
`pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, or `scripts/committer` until
you have verified pnpm will not reconcile or reinstall dependencies. Use
`node scripts/run-vitest.mjs` for tiny local proof, `node
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
after the relevant remote or node-wrapper proof is already clean.
- For Blacksmith Testbox proof, use Crabbox first. `pnpm crabbox:run -- --provider
blacksmith-testbox --timing-json -- <command...>` warms, claims, syncs, runs,
+1
View File
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Bind gateway approval access to requester metadata [AI]. (#81380) Thanks @pgondhi987.
- Telegram: let isolated polling drain independent topics, DMs, and status/control commands concurrently while preserving same-lane order. (#81849) Thanks @VACInc.
- Doctor/Codex: stop warning that the message tool is unavailable for source-reply paths where OpenClaw grants `message` at runtime, keeping update and doctor output aligned with the OpenAI happy path. Thanks @pashpashpash.
- Build: keep externalized Slack, OpenShell sandbox, and Anthropic Vertex runtime dependency declarations out of the root dist artifact build.
@@ -1,5 +1,8 @@
import {
DEFAULT_TIMING,
type StatusReactionController,
} from "openclaw/plugin-sdk/channel-feedback";
import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-message";
import { DEFAULT_TIMING, type StatusReactionController } from "openclaw/plugin-sdk/channel-feedback";
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import {
+5 -8
View File
@@ -1003,7 +1003,10 @@ export class AcpSessionManager {
sawOutput: sawTurnOutput,
};
backendAttempts.push(backendAttempt);
if (!isFailoverWorthyBackendError(backendAttempt) || !shouldAttemptFailover(backendIdx)) {
if (
!isFailoverWorthyBackendError(backendAttempt) ||
!shouldAttemptFailover(backendIdx)
) {
await recordBackendFailure(acpError);
}
break;
@@ -1014,13 +1017,7 @@ export class AcpSessionManager {
if (activeTurn && this.activeTurnBySession.get(actorKey) === activeTurn) {
this.activeTurnBySession.delete(actorKey);
}
if (
!retryFreshHandle &&
!skipPostTurnCleanup &&
runtime &&
handle &&
meta
) {
if (!retryFreshHandle && !skipPostTurnCleanup && runtime && handle && meta) {
({ handle, meta } = await this.reconcileRuntimeSessionIdentifiers({
cfg: input.cfg,
sessionKey,
+6 -4
View File
@@ -1566,10 +1566,12 @@ describe("AcpSessionManager", () => {
expect(runtimeState.runTurn).toHaveBeenCalledTimes(1);
});
function setupFailoverBackends(params: {
initialBackend?: "primary-backend" | "fallback-backend";
primaryUnavailableError?: Error;
} = {}) {
function setupFailoverBackends(
params: {
initialBackend?: "primary-backend" | "fallback-backend";
primaryUnavailableError?: Error;
} = {},
) {
const primaryRuntime = createRuntime();
const fallbackRuntime = createRuntime();
const sessionKey = "agent:codex:acp:session-1";
+1 -1
View File
@@ -2,12 +2,12 @@ import { normalizeConfiguredMcpServers } from "../../config/mcp-config-normalize
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { BundleMcpConfig, BundleMcpServerConfig } from "../../plugins/bundle-mcp.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import { buildCodexMcpServersConfig } from "../codex-mcp-config.js";
import {
applyCommonServerConfig,
decodeHeaderEnvPlaceholder,
normalizeStringRecord,
} from "./bundle-mcp-adapter-shared.js";
import { buildCodexMcpServersConfig } from "../codex-mcp-config.js";
import { serializeTomlInlineValue } from "./toml-inline.js";
// Mutable JSON shape structurally compatible with the bundled Codex
+7 -5
View File
@@ -669,11 +669,13 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
expect(plugin?.meta.label).toBe("@example/openclaw-external-chat");
expect(plugin?.meta.blurb).toBe("");
expect(plugin?.configSchema).toBeUndefined();
expect(plugin?.config.listAccountIds({
channels: {
"external-chat": { token: "configured" },
},
} as never)).toEqual(["default"]);
expect(
plugin?.config.listAccountIds({
channels: {
"external-chat": { token: "configured" },
},
} as never),
).toEqual(["default"]);
expect(fs.existsSync(setupMarker)).toBe(false);
expect(fs.existsSync(fullMarker)).toBe(false);
});
+46 -2
View File
@@ -737,6 +737,7 @@ describe("GatewayClient connect auth payload", () => {
bootstrapToken?: string;
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
};
};
};
@@ -811,6 +812,7 @@ describe("GatewayClient connect auth payload", () => {
ws: MockWebSocket,
connectId: string | undefined,
details: Record<string, unknown>,
message = "unauthorized",
) {
ws.emitMessage(
JSON.stringify({
@@ -819,7 +821,7 @@ describe("GatewayClient connect auth payload", () => {
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
message,
details,
},
}),
@@ -844,8 +846,14 @@ describe("GatewayClient connect auth payload", () => {
firstWs: MockWebSocket;
connectId: string | undefined;
failureDetails: Record<string, unknown>;
failureMessage?: string;
}) {
emitConnectFailure(params.firstWs, params.connectId, params.failureDetails);
emitConnectFailure(
params.firstWs,
params.connectId,
params.failureDetails,
params.failureMessage,
);
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
const ws = getLatestWs();
ws.emitOpen();
@@ -887,6 +895,42 @@ describe("GatewayClient connect auth payload", () => {
client.stop();
});
it("retries without approval runtime token when a gateway rejects the auth field", async () => {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
token: "shared-token",
approvalRuntimeToken: "runtime-token",
deviceIdentity: null,
});
const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
expectRecordFields(
firstConnect.params?.auth ?? {},
{
token: "shared-token",
approvalRuntimeToken: "runtime-token",
},
"initial connect auth",
);
const retriedAuth = await expectRetriedConnectAuth({
firstWs: ws1,
connectId: firstConnect.id,
failureDetails: {},
failureMessage:
"invalid connect params: at /auth: unexpected property 'approvalRuntimeToken'",
});
expectRecordFields(
retriedAuth,
{
token: "shared-token",
},
"retried connect auth",
);
expect(retriedAuth.approvalRuntimeToken).toBeUndefined();
client.stop();
});
it("waits for socket open before sending connect after an early challenge", () => {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
+48 -1
View File
@@ -74,6 +74,7 @@ type SelectedConnectAuth = {
authBootstrapToken?: string;
authDeviceToken?: string;
authPassword?: string;
authApprovalRuntimeToken?: string;
signatureToken?: string;
resolvedDeviceToken?: string;
storedToken?: string;
@@ -130,6 +131,7 @@ export type GatewayClientOptions = {
bootstrapToken?: string;
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
instanceId?: string;
clientName?: GatewayClientName;
clientDisplayName?: string;
@@ -221,6 +223,8 @@ export class GatewayClient {
private reconnectTimer: NodeJS.Timeout | null = null;
private pendingDeviceTokenRetry = false;
private deviceTokenRetryBudgetUsed = false;
private approvalRuntimeTokenCompatibilityDisabled = false;
private approvalRuntimeTokenRetryBudgetUsed = false;
private pendingStartupReconnectDelayMs: number | null = null;
private pendingConnectErrorDetailCode: string | null = null;
private pendingConnectErrorDetails: unknown = null;
@@ -506,6 +510,7 @@ export class GatewayClient {
authBootstrapToken,
authDeviceToken,
authPassword,
authApprovalRuntimeToken,
signatureToken,
resolvedDeviceToken,
storedToken,
@@ -516,12 +521,17 @@ export class GatewayClient {
this.pendingDeviceTokenRetry = false;
}
const auth =
authToken || authBootstrapToken || authPassword || resolvedDeviceToken
authToken ||
authBootstrapToken ||
authPassword ||
resolvedDeviceToken ||
authApprovalRuntimeToken
? {
token: authToken,
bootstrapToken: authBootstrapToken,
deviceToken: authDeviceToken ?? resolvedDeviceToken,
password: authPassword,
approvalRuntimeToken: authApprovalRuntimeToken,
}
: undefined;
const signedAtMs = Date.now();
@@ -646,6 +656,19 @@ export class GatewayClient {
this.ws?.close(1013, "gateway starting");
return;
}
if (
this.shouldRetryWithoutApprovalRuntimeToken({
error: err,
authApprovalRuntimeToken,
})
) {
this.approvalRuntimeTokenCompatibilityDisabled = true;
this.approvalRuntimeTokenRetryBudgetUsed = true;
this.backoffMs = Math.min(this.backoffMs, 250);
logDebug("gateway rejected approval runtime auth field; retrying without it");
this.ws?.close(1008, "connect retry");
return;
}
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
const msg = `gateway connect failed: ${String(err)}`;
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE || isGatewayClientStoppedError(err)) {
@@ -763,6 +786,26 @@ export class GatewayClient {
);
}
private shouldRetryWithoutApprovalRuntimeToken(params: {
error: unknown;
authApprovalRuntimeToken?: string;
}): boolean {
if (this.approvalRuntimeTokenRetryBudgetUsed) {
return false;
}
if (!params.authApprovalRuntimeToken) {
return false;
}
if (!(params.error instanceof GatewayClientRequestError)) {
return false;
}
if (params.error.gatewayCode !== "INVALID_REQUEST") {
return false;
}
const message = normalizeLowercaseStringOrEmpty(params.error.message);
return message.includes("invalid connect params") && message.includes("approvalruntimetoken");
}
private isTrustedDeviceRetryEndpoint(): boolean {
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
try {
@@ -787,6 +830,9 @@ export class GatewayClient {
const explicitBootstrapToken = normalizeOptionalString(this.opts.bootstrapToken);
const explicitDeviceToken = normalizeOptionalString(this.opts.deviceToken);
const authPassword = normalizeOptionalString(this.opts.password);
const authApprovalRuntimeToken = this.approvalRuntimeTokenCompatibilityDisabled
? undefined
: normalizeOptionalString(this.opts.approvalRuntimeToken);
const storedAuth = this.loadStoredDeviceAuth(role);
const storedToken = storedAuth?.token ?? null;
const storedScopes = storedAuth?.scopes;
@@ -819,6 +865,7 @@ export class GatewayClient {
authBootstrapToken,
authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
authPassword,
authApprovalRuntimeToken,
signatureToken: authToken ?? authBootstrapToken ?? undefined,
resolvedDeviceToken,
storedToken: storedToken ?? undefined,
@@ -171,6 +171,31 @@ describe("createExecApprovalIosPushDelivery", () => {
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
});
it("does not target iOS devices rejected by the approval visibility filter", async () => {
mockPairedIosOperator(["operator.approvals", "operator.read"]);
const isTargetVisible = vi.fn(() => false);
const delivery = createExecApprovalIosPushDelivery({ log: {} });
const accepted = await delivery.handleRequested(
{
id: "approval-filtered",
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
createdAtMs: 1,
expiresAtMs: 2,
},
{ isTargetVisible },
);
expect(accepted).toBe(false);
expect(isTargetVisible).toHaveBeenCalledWith({
deviceId: "ios-device-1",
scopes: ["operator.approvals", "operator.read"],
});
expect(loadApnsRegistrationMock).not.toHaveBeenCalled();
expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled();
});
it("does not treat iOS as a live approval route when every push fails", async () => {
const warn = vi.fn();
mockPairedIosOperator(["operator.approvals", "operator.read"]);
+32 -5
View File
@@ -31,6 +31,11 @@ type GatewayLikeLogger = {
error?: (message: string) => void;
};
type ApprovalPushTarget = {
deviceId: string;
scopes: readonly string[];
};
type DeliveryTarget = {
nodeId: string;
registration: ApnsRegistration;
@@ -102,12 +107,26 @@ async function loadRegisteredTargets(params: {
async function resolvePairedTargets(params: {
requireApprovalScope: boolean;
isTargetVisible?: (target: ApprovalPushTarget) => boolean;
}): Promise<DeliveryTarget[]> {
const pairing = await listDevicePairing();
const deviceIds = pairing.paired
.filter((device) =>
shouldTargetDevice({ device, requireApprovalScope: params.requireApprovalScope }),
)
.filter((device) => {
if (!shouldTargetDevice({ device, requireApprovalScope: params.requireApprovalScope })) {
return false;
}
const operatorToken = resolveActiveOperatorToken(device);
if (
params.isTargetVisible &&
!params.isTargetVisible({
deviceId: device.deviceId,
scopes: operatorToken?.scopes ?? [],
})
) {
return false;
}
return true;
})
.map((device) => device.deviceId);
return await loadRegisteredTargets({ deviceIds });
}
@@ -115,11 +134,15 @@ async function resolvePairedTargets(params: {
async function resolveDeliveryPlan(params: {
requireApprovalScope: boolean;
explicitNodeIds?: readonly string[];
isTargetVisible?: (target: ApprovalPushTarget) => boolean;
log: GatewayLikeLogger;
}): Promise<DeliveryPlan> {
const targets = params.explicitNodeIds?.length
? await loadRegisteredTargets({ deviceIds: params.explicitNodeIds })
: await resolvePairedTargets({ requireApprovalScope: params.requireApprovalScope });
: await resolvePairedTargets({
requireApprovalScope: params.requireApprovalScope,
isTargetVisible: params.isTargetVisible,
});
if (targets.length === 0) {
return { targets: [] };
}
@@ -260,10 +283,14 @@ export function createExecApprovalIosPushDelivery(params: { log: GatewayLikeLogg
const pendingDeliveryStateById = new Map<string, Promise<ApprovalDeliveryState | null>>();
return {
async handleRequested(request: ExecApprovalRequest): Promise<boolean> {
async handleRequested(
request: ExecApprovalRequest,
opts?: { isTargetVisible?: (target: ApprovalPushTarget) => boolean },
): Promise<boolean> {
const deliveryStatePromise = (async (): Promise<ApprovalDeliveryState | null> => {
const plan = await resolveDeliveryPlan({
requireApprovalScope: true,
isTargetVisible: opts?.isTargetVisible,
log: params.log,
});
if (plan.targets.length === 0) {
+9 -2
View File
@@ -199,7 +199,10 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
lookupApprovalId(
input: string,
opts: { includeResolved?: boolean } = {},
opts: {
includeResolved?: boolean;
filter?: (record: ExecApprovalRecord<TPayload>) => boolean;
} = {},
): ExecApprovalIdLookupResult {
const normalized = input.trim();
if (!normalized) {
@@ -208,7 +211,8 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
const exact = this.pending.get(normalized);
if (exact) {
return opts.includeResolved || exact.record.resolvedAtMs === undefined
return (opts.includeResolved || exact.record.resolvedAtMs === undefined) &&
(opts.filter?.(exact.record) ?? true)
? { kind: "exact", id: normalized }
: { kind: "none" };
}
@@ -219,6 +223,9 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
if (!opts.includeResolved && entry.record.resolvedAtMs !== undefined) {
continue;
}
if (opts.filter && !opts.filter(entry.record)) {
continue;
}
if (normalizeLowercaseStringOrEmpty(id).startsWith(lowerPrefix)) {
matches.push(id);
}
+129 -2
View File
@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginApprovalRequestPayload } from "../infra/plugin-approvals.js";
import type { PluginRegistry } from "../plugins/registry-types.js";
import type { OpenClawPluginNodeInvokePolicyContext } from "../plugins/types.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { applyPluginNodeInvokePolicy } from "./node-invoke-plugin-policy.js";
import type { NodeSession } from "./node-registry.js";
import type { GatewayRequestContext } from "./server-methods/types.js";
import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js";
const registryState = vi.hoisted(() => ({
current: null as PluginRegistry | null,
@@ -26,7 +28,10 @@ function createNodeSession(): NodeSession {
};
}
function createContext() {
function createContext(opts?: {
pluginApprovalManager?: ExecApprovalManager<PluginApprovalRequestPayload>;
getApprovalClientConnIds?: GatewayRequestContext["getApprovalClientConnIds"];
}) {
const invoke = vi.fn(async () => ({
ok: true,
payload: { ok: true, value: 1 },
@@ -38,11 +43,54 @@ function createContext() {
getRuntimeConfig: () => ({}),
nodeRegistry: { invoke },
broadcast: vi.fn(),
broadcastToConnIds: vi.fn(),
pluginApprovalManager: opts?.pluginApprovalManager,
getApprovalClientConnIds: opts?.getApprovalClientConnIds,
} as unknown as GatewayRequestContext,
invoke,
};
}
type ApprovalClientLookup = NonNullable<GatewayRequestContext["getApprovalClientConnIds"]>;
function createApprovalClient(params: {
connId: string;
clientId: string;
deviceId?: string;
}): GatewayClient {
return {
connId: params.connId,
connect: {
client: { id: params.clientId },
device: params.deviceId ? { id: params.deviceId } : undefined,
scopes: ["operator.approvals"],
},
} as GatewayClient;
}
function createApprovalClientLookup(clients: GatewayClient[]): ApprovalClientLookup {
return (opts = {}) =>
new Set(
clients
.filter((client) => {
if (opts.excludeConnId && client.connId === opts.excludeConnId) {
return false;
}
return opts.filter?.(client, opts.record) ?? true;
})
.map((client) => client.connId)
.filter((connId): connId is string => typeof connId === "string" && connId.length > 0),
);
}
function createOperatorClient(): GatewayClient {
return createApprovalClient({
connId: "conn-requester",
clientId: "client-owner",
deviceId: "device-owner",
});
}
describe("applyPluginNodeInvokePolicy", () => {
beforeEach(() => {
registryState.current = null;
@@ -129,6 +177,85 @@ describe("applyPluginNodeInvokePolicy", () => {
});
});
it("binds plugin policy approval requests to the invoking client", async () => {
const manager = new ExecApprovalManager<PluginApprovalRequestPayload>();
const visibleConnIds = new Set(["conn-owner-approval"]);
const getApprovalClientConnIds = createApprovalClientLookup([
createApprovalClient({
connId: "conn-owner-approval",
clientId: "client-owner",
deviceId: "device-owner",
}),
createApprovalClient({
connId: "conn-other-approval",
clientId: "client-other",
deviceId: "device-other",
}),
]);
registryState.current = {
nodeHostCommands: [
{
pluginId: "demo",
command: {
command: "demo.read",
dangerous: true,
handle: async () => "{}",
},
source: "test",
},
],
nodeInvokePolicies: [
{
pluginId: "demo",
policy: {
commands: ["demo.read"],
handle: async (ctx: OpenClawPluginNodeInvokePolicyContext) => {
const approval = await ctx.approvals?.request({
title: "Sensitive action",
description: "Needs approval",
});
return { ok: true, payload: approval ?? null };
},
},
pluginConfig: { enabled: true },
source: "test",
},
],
} as unknown as PluginRegistry;
const { context } = createContext({
pluginApprovalManager: manager,
getApprovalClientConnIds,
});
const resultPromise = applyPluginNodeInvokePolicy({
context,
client: createOperatorClient(),
nodeSession: createNodeSession(),
command: "demo.read",
params: { path: "/tmp/x" },
});
await vi.waitFor(() => {
expect(manager.listPendingRecords()).toHaveLength(1);
});
const [record] = manager.listPendingRecords();
expect(record?.requestedByConnId).toBe("conn-requester");
expect(record?.requestedByDeviceId).toBe("device-owner");
expect(record?.requestedByClientId).toBe("client-owner");
expect(context.broadcast).not.toHaveBeenCalled();
expect(context.broadcastToConnIds).toHaveBeenCalledWith(
"plugin.approval.requested",
expect.objectContaining({ id: record?.id }),
visibleConnIds,
{ dropIfSlow: true },
);
expect(manager.resolve(record.id, "allow-once")).toBe(true);
await expect(resultPromise).resolves.toStrictEqual({
ok: true,
payload: { id: record?.id, decision: "allow-once" },
});
});
it("leaves commands without a dangerous plugin registration to normal allowlist handling", async () => {
registryState.current = {
nodeHostCommands: [],
+26 -3
View File
@@ -10,6 +10,7 @@ import type {
} from "../plugins/types.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { NodeSession } from "./node-registry.js";
import { resolveApprovalRequestRecipientConnIds } from "./server-methods/approval-shared.js";
import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js";
function parseScopes(client: GatewayClient | null): string[] {
@@ -68,6 +69,10 @@ function createApprovalRuntime(params: {
sessionKey: normalizeOptionalString(input.sessionKey) ?? null,
};
const record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`);
record.requestedByConnId = params.client?.connId ?? null;
record.requestedByDeviceId = params.client?.connect?.device?.id ?? null;
record.requestedByClientId = params.client?.connect?.client?.id ?? null;
record.requestedByDeviceTokenAuth = params.client?.isDeviceTokenAuth === true;
const decisionPromise = manager.register(record, timeoutMs);
const requestEvent = {
id: record.id,
@@ -75,11 +80,29 @@ function createApprovalRuntime(params: {
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
};
params.context.broadcast("plugin.approval.requested", requestEvent, {
dropIfSlow: true,
const approvalClientConnIds = resolveApprovalRequestRecipientConnIds({
context: params.context,
record,
excludeConnId: params.client?.connId,
});
if (approvalClientConnIds) {
params.context.broadcastToConnIds(
"plugin.approval.requested",
requestEvent,
approvalClientConnIds,
{
dropIfSlow: true,
},
);
} else {
params.context.broadcast("plugin.approval.requested", requestEvent, {
dropIfSlow: true,
});
}
const hasApprovalClients =
params.context.hasExecApprovalClients?.(params.client?.connId) ?? false;
approvalClientConnIds !== null
? approvalClientConnIds.size > 0
: (params.context.hasExecApprovalClients?.(params.client?.connId) ?? false);
if (!hasApprovalClients) {
manager.expire(record.id, "no-approval-route");
return { id: record.id, decision: null };
@@ -0,0 +1,19 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
let approvalRuntimeToken: string | null = null;
export function getOperatorApprovalRuntimeToken(): string {
approvalRuntimeToken ??= randomBytes(32).toString("base64url");
return approvalRuntimeToken;
}
export function isOperatorApprovalRuntimeToken(value: string | null | undefined): boolean {
const token = value?.trim();
if (!token) {
return false;
}
const expected = getOperatorApprovalRuntimeToken();
const tokenBytes = Buffer.from(token);
const expectedBytes = Buffer.from(expected);
return tokenBytes.length === expectedBytes.length && timingSafeEqual(tokenBytes, expectedBytes);
}
@@ -93,6 +93,7 @@ describe("withOperatorApprovalsGatewayClient", () => {
);
expect(clientState.options?.scopes).toEqual(["operator.approvals"]);
expect(typeof clientState.options?.approvalRuntimeToken).toBe("string");
expect(clientState.options?.deviceIdentity).toBeNull();
expect(clientState.requestSpy).toHaveBeenCalledWith("exec.approval.resolve", {
id: "req-123",
@@ -116,6 +117,19 @@ describe("withOperatorApprovalsGatewayClient", () => {
expect(clientState.options?.deviceIdentity).toBeUndefined();
});
it("omits approval runtime token for explicit gateway URL overrides", async () => {
await withOperatorApprovalsGatewayClient(
{
config: {} as never,
gatewayUrl: "ws://127.0.0.1:18789",
clientDisplayName: "Matrix approval (@owner:example.org)",
},
async () => undefined,
);
expect(clientState.options).not.toHaveProperty("approvalRuntimeToken");
});
it("keeps device identity for loopback approval clients without shared auth", async () => {
bootstrapState.auth = { token: undefined, password: undefined };
+2
View File
@@ -3,6 +3,7 @@ import { isLoopbackIpAddress } from "../shared/net/ip.js";
import { resolveGatewayClientBootstrap } from "./client-bootstrap.js";
import { startGatewayClientWhenEventLoopReady } from "./client-start-readiness.js";
import { GatewayClient, type GatewayClientOptions } from "./client.js";
import { getOperatorApprovalRuntimeToken } from "./operator-approval-runtime-token.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "./protocol/client-info.js";
function isLoopbackGatewayUrl(rawUrl: string): boolean {
@@ -48,6 +49,7 @@ export async function createOperatorApprovalsGatewayClient(
url: bootstrap.url,
token: bootstrap.auth.token,
password: bootstrap.auth.password,
...(params.gatewayUrl ? {} : { approvalRuntimeToken: getOperatorApprovalRuntimeToken() }),
preauthHandshakeTimeoutMs: bootstrap.preauthHandshakeTimeoutMs,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: params.clientDisplayName,
+1
View File
@@ -59,6 +59,7 @@ export const ConnectParamsSchema = Type.Object(
bootstrapToken: Type.Optional(Type.String()),
deviceToken: Type.Optional(Type.String()),
password: Type.Optional(Type.String()),
approvalRuntimeToken: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
File diff suppressed because it is too large Load Diff
+127 -6
View File
@@ -6,6 +6,7 @@ import type {
ExecApprovalManager,
ExecApprovalRecord,
} from "../exec-approval-manager.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../method-scopes.js";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js";
@@ -77,9 +78,64 @@ function resolvePendingApprovalLookupError(params: {
};
}
function normalizeApprovalIdentity(value: string | null | undefined): string | null {
return normalizeOptionalString(value) ?? null;
}
export function isApprovalRecordVisibleToClient<TPayload>(params: {
record: ExecApprovalRecord<TPayload>;
client: GatewayClient | null;
}): boolean {
const scopes = Array.isArray(params.client?.connect?.scopes) ? params.client.connect.scopes : [];
if (scopes.includes(ADMIN_SCOPE)) {
return true;
}
const requestedByDeviceId = normalizeApprovalIdentity(params.record.requestedByDeviceId);
const requestedByClientId = normalizeApprovalIdentity(params.record.requestedByClientId);
const hasApprovalsScope = scopes.includes(APPROVALS_SCOPE);
if (hasApprovalsScope && params.client?.internal?.approvalRuntime === true) {
return true;
}
if (requestedByDeviceId) {
return requestedByDeviceId === normalizeApprovalIdentity(params.client?.connect?.device?.id);
}
const requestedByConnId = normalizeApprovalIdentity(params.record.requestedByConnId);
if (requestedByConnId) {
return requestedByConnId === normalizeApprovalIdentity(params.client?.connId);
}
if (requestedByClientId) {
return false;
}
return true;
}
export function resolveApprovalRequestRecipientConnIds<TPayload>(params: {
context: GatewayRequestContext;
record: ExecApprovalRecord<TPayload>;
excludeConnId?: string;
}): ReadonlySet<string> | null {
return (
params.context.getApprovalClientConnIds?.({
excludeConnId: params.excludeConnId,
record: params.record,
filter: (client) =>
isApprovalRecordVisibleToClient({
record: params.record,
client,
}),
}) ?? null
);
}
export function resolvePendingApprovalRecord<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
client?: GatewayClient | null;
exposeAmbiguousPrefixError?: boolean;
}):
| {
@@ -91,7 +147,13 @@ export function resolvePendingApprovalRecord<TPayload>(params: {
ok: false;
response: PendingApprovalLookupError;
} {
const resolvedId = params.manager.lookupPendingId(params.inputId);
const resolvedId = params.manager.lookupApprovalId(params.inputId, {
filter: (record) =>
isApprovalRecordVisibleToClient({
record,
client: params.client ?? null,
}),
});
if (resolvedId.kind !== "exact" && resolvedId.kind !== "prefix") {
return {
ok: false,
@@ -111,6 +173,7 @@ export function resolvePendingApprovalRecord<TPayload>(params: {
function resolveResolvedApprovalRecord<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
client?: GatewayClient | null;
exposeAmbiguousPrefixError?: boolean;
}):
| {
@@ -122,7 +185,14 @@ function resolveResolvedApprovalRecord<TPayload>(params: {
ok: false;
response: PendingApprovalLookupError;
} {
const resolvedId = params.manager.lookupApprovalId(params.inputId, { includeResolved: true });
const resolvedId = params.manager.lookupApprovalId(params.inputId, {
includeResolved: true,
filter: (record) =>
isApprovalRecordVisibleToClient({
record,
client: params.client ?? null,
}),
});
if (resolvedId.kind !== "exact" && resolvedId.kind !== "prefix") {
return {
ok: false,
@@ -153,6 +223,7 @@ export function respondPendingApprovalLookupError(params: {
export async function handleApprovalWaitDecision<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: unknown;
client?: GatewayClient | null;
respond: RespondFn;
}): Promise<void> {
const id = normalizeOptionalString(params.inputId) ?? "";
@@ -160,6 +231,21 @@ export async function handleApprovalWaitDecision<TPayload>(params: {
params.respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id is required"));
return;
}
const snapshot = params.manager.getSnapshot(id);
if (
!snapshot ||
!isApprovalRecordVisibleToClient({
record: snapshot,
client: params.client ?? null,
})
) {
params.respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"),
);
return;
}
const decisionPromise = params.manager.awaitDecision(id);
if (!decisionPromise) {
params.respond(
@@ -169,7 +255,6 @@ export async function handleApprovalWaitDecision<TPayload>(params: {
);
return;
}
const snapshot = params.manager.getSnapshot(id);
const decision = await decisionPromise;
params.respond(
true,
@@ -202,9 +287,28 @@ export async function handlePendingApprovalRequest<
) => Promise<void> | void;
afterDecisionErrorLabel?: string;
}): Promise<void> {
params.context.broadcast(params.requestEventName, params.requestEvent, { dropIfSlow: true });
const approvalClientConnIds = resolveApprovalRequestRecipientConnIds({
context: params.context,
record: params.record,
excludeConnId: params.clientConnId,
});
if (approvalClientConnIds) {
params.context.broadcastToConnIds(
params.requestEventName,
params.requestEvent,
approvalClientConnIds,
{
dropIfSlow: true,
},
);
} else {
params.context.broadcast(params.requestEventName, params.requestEvent, { dropIfSlow: true });
}
const hasApprovalClients = params.context.hasExecApprovalClients?.(params.clientConnId) ?? false;
const hasApprovalClients =
approvalClientConnIds !== null
? approvalClientConnIds.size > 0
: (params.context.hasExecApprovalClients?.(params.clientConnId) ?? false);
const deliveredResult = params.deliverRequest();
const delivered = isPromiseLike(deliveredResult) ? await deliveredResult : deliveredResult;
const hasTurnSourceRoute =
@@ -298,12 +402,14 @@ export async function handleApprovalResolve<TPayload, TResolvedEvent extends obj
const resolved = resolvePendingApprovalRecord({
manager: params.manager,
inputId: params.inputId,
client: params.client,
exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError,
});
if (!resolved.ok) {
const resolvedRepeat = resolveResolvedApprovalRecord({
manager: params.manager,
inputId: params.inputId,
client: params.client,
exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError,
});
if (resolvedRepeat.ok) {
@@ -353,7 +459,22 @@ export async function handleApprovalResolve<TPayload, TResolvedEvent extends obj
snapshot: resolved.snapshot,
nowMs: Date.now(),
});
params.context.broadcast(params.resolvedEventName, resolvedEvent, { dropIfSlow: true });
const resolvedEventConnIds = resolveApprovalRequestRecipientConnIds({
context: params.context,
record: resolved.snapshot,
});
if (resolvedEventConnIds) {
params.context.broadcastToConnIds(
params.resolvedEventName,
resolvedEvent,
resolvedEventConnIds,
{
dropIfSlow: true,
},
);
} else {
params.context.broadcast(params.resolvedEventName, resolvedEvent, { dropIfSlow: true });
}
const followUps = [
params.forwardResolved
+43 -17
View File
@@ -22,6 +22,7 @@ import {
import { resolveSystemRunApprovalRequestContext } from "../../infra/system-run-approval-context.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import { GATEWAY_CLIENT_IDS } from "../protocol/client-info.js";
import {
ErrorCodes,
errorShape,
@@ -35,10 +36,11 @@ import {
handlePendingApprovalRequest,
handleApprovalResolve,
isApprovalDecision,
isApprovalRecordVisibleToClient,
respondPendingApprovalLookupError,
resolvePendingApprovalRecord,
} from "./approval-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
import type { GatewayClient, GatewayRequestHandlers } from "./types.js";
const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = {
reason: "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE",
@@ -46,7 +48,12 @@ const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = {
const RESERVED_PLUGIN_APPROVAL_ID_PREFIX = "plugin:";
type ExecApprovalIosPushDelivery = {
handleRequested?: (request: ExecApprovalRequest) => Promise<boolean>;
handleRequested?: (
request: ExecApprovalRequest,
opts?: {
isTargetVisible?: (target: { deviceId: string; scopes: readonly string[] }) => boolean;
},
) => Promise<boolean>;
handleResolved?: (resolved: ExecApprovalResolved) => Promise<void>;
handleExpired?: (request: ExecApprovalRequest) => Promise<void>;
};
@@ -85,7 +92,7 @@ export function createExecApprovalHandlers(
opts?: { forwarder?: ExecApprovalForwarder; iosPushDelivery?: ExecApprovalIosPushDelivery },
): GatewayRequestHandlers {
return {
"exec.approval.get": async ({ params, respond }) => {
"exec.approval.get": async ({ params, respond, client }) => {
if (!validateExecApprovalGetParams(params)) {
respond(
false,
@@ -103,6 +110,7 @@ export function createExecApprovalHandlers(
const resolved = resolvePendingApprovalRecord({
manager,
inputId: p.id,
client,
exposeAmbiguousPrefixError: true,
});
if (!resolved.ok) {
@@ -127,15 +135,18 @@ export function createExecApprovalHandlers(
undefined,
);
},
"exec.approval.list": async ({ respond }) => {
"exec.approval.list": async ({ respond, client }) => {
respond(
true,
manager.listPendingRecords().map((record) => ({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})),
manager
.listPendingRecords()
.filter((record) => isApprovalRecordVisibleToClient({ record, client }))
.map((record) => ({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})),
undefined,
);
},
@@ -370,12 +381,26 @@ export function createExecApprovalHandlers(
}
if (opts?.iosPushDelivery?.handleRequested) {
deliveryTasks.push(
opts.iosPushDelivery.handleRequested(requestEvent).catch((err) => {
context.logGateway?.error?.(
`exec approvals: iOS push request failed: ${String(err)}`,
);
return false;
}),
opts.iosPushDelivery
.handleRequested(requestEvent, {
isTargetVisible: (target) =>
isApprovalRecordVisibleToClient({
record,
client: {
connect: {
client: { id: GATEWAY_CLIENT_IDS.IOS_APP },
device: { id: target.deviceId },
scopes: [...target.scopes],
},
} as GatewayClient,
}),
})
.catch((err) => {
context.logGateway?.error?.(
`exec approvals: iOS push request failed: ${String(err)}`,
);
return false;
}),
);
}
if (deliveryTasks.length === 0) {
@@ -397,10 +422,11 @@ export function createExecApprovalHandlers(
afterDecisionErrorLabel: "exec approvals: iOS push expire failed",
});
},
"exec.approval.waitDecision": async ({ params, respond }) => {
"exec.approval.waitDecision": async ({ params, respond, client }) => {
await handleApprovalWaitDecision({
manager,
inputId: (params as { id?: string }).id,
client,
respond,
});
},
@@ -17,6 +17,7 @@ function createMockOptions(
req: { method, params, id: "req-1" },
params,
client: {
connId: "conn-test-client",
connect: {
client: { id: "test-client", displayName: "Test Client" },
},
@@ -201,6 +202,7 @@ describe("createPluginApprovalHandlers", () => {
// Resolve the approval so the handler can complete
const approvalId = acceptedApprovalId(respond as unknown as MockCallSource);
expect(manager.getSnapshot(approvalId)?.requestedByClientId).toBe("test-client");
manager.resolve(approvalId, "allow-once");
await handlerPromise;
@@ -468,6 +470,52 @@ describe("createPluginApprovalHandlers", () => {
manager.resolve(approvalId, "allow-once");
await handlerPromise;
});
it("lists only plugin approvals owned by the caller", async () => {
const handlers = createPluginApprovalHandlers(manager);
const visible = manager.create(
{ title: "Visible", description: "D" },
60_000,
"plugin:visible",
);
visible.requestedByDeviceId = "device-owner";
visible.requestedByConnId = "conn-owner";
visible.requestedByClientId = "client-owner";
void manager.register(visible, 60_000);
const hidden = manager.create({ title: "Hidden", description: "D" }, 60_000, "plugin:hidden");
hidden.requestedByDeviceId = "device-other";
hidden.requestedByConnId = "conn-other";
hidden.requestedByClientId = "client-other";
void manager.register(hidden, 60_000);
const listRespond = vi.fn();
await handlers["plugin.approval.list"](
createMockOptions(
"plugin.approval.list",
{},
{
respond: listRespond,
client: {
connId: "conn-owner",
connect: {
client: { id: "client-owner" },
device: { id: "device-owner" },
},
} as unknown as GatewayRequestHandlerOptions["client"],
},
),
);
expect(responseCall(listRespond as unknown as MockCallSource).ok).toBe(true);
const approvals = requireArray(
responseCall(listRespond as unknown as MockCallSource).result,
"approval list",
);
expect(approvals.map((entry) => requireRecord(entry, "approval").id)).toEqual([
"plugin:visible",
]);
});
});
describe("plugin.approval.waitDecision", () => {
@@ -491,6 +539,36 @@ describe("createPluginApprovalHandlers", () => {
);
});
it("returns not found for approvals hidden from the caller", async () => {
const handlers = createPluginApprovalHandlers(manager);
const record = manager.create({ title: "T", description: "D" }, 60_000);
record.requestedByDeviceId = "device-owner";
record.requestedByConnId = "conn-owner";
record.requestedByClientId = "client-owner";
void manager.register(record, 60_000);
manager.resolve(record.id, "allow-once");
const opts = createMockOptions(
"plugin.approval.waitDecision",
{ id: record.id },
{
client: {
connId: "conn-other",
connect: {
client: { id: "client-other" },
device: { id: "device-other" },
scopes: ["operator.approvals"],
},
} as unknown as GatewayRequestHandlerOptions["client"],
},
);
await handlers["plugin.approval.waitDecision"](opts);
expect(responseCall(opts.respond as unknown as MockCallSource).ok).toBe(false);
expect(responseError(opts.respond as unknown as MockCallSource).message).toContain(
"expired or not found",
);
});
it("returns decision when resolved", async () => {
const handlers = createPluginApprovalHandlers(manager);
const record = manager.create({ title: "T", description: "D" }, 60_000);
@@ -542,6 +620,74 @@ describe("createPluginApprovalHandlers", () => {
expect(resolvedBroadcast.options).toEqual({ dropIfSlow: true });
});
it("resolves only plugin approvals owned by the caller", async () => {
const handlers = createPluginApprovalHandlers(manager);
const visible = manager.create(
{ title: "Visible", description: "D" },
60_000,
"plugin:abcd-visible",
);
visible.requestedByDeviceId = "device-owner";
visible.requestedByConnId = "conn-owner";
visible.requestedByClientId = "client-owner";
void manager.register(visible, 60_000);
const hidden = manager.create(
{ title: "Hidden", description: "D" },
60_000,
"plugin:abcd-hidden",
);
hidden.requestedByDeviceId = "device-other";
hidden.requestedByConnId = "conn-other";
hidden.requestedByClientId = "client-other";
void manager.register(hidden, 60_000);
const ownerClient = {
connId: "conn-owner",
connect: {
client: { id: "client-owner" },
device: { id: "device-owner" },
},
} as unknown as GatewayRequestHandlerOptions["client"];
const resolveRespond = vi.fn();
await handlers["plugin.approval.resolve"](
createMockOptions(
"plugin.approval.resolve",
{
id: "plugin:abcd",
decision: "allow-once",
},
{
respond: resolveRespond,
client: ownerClient,
},
),
);
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(manager.getSnapshot(visible.id)?.decision).toBe("allow-once");
expect(manager.getSnapshot(hidden.id)?.decision).toBeUndefined();
const hiddenRespond = vi.fn();
await handlers["plugin.approval.resolve"](
createMockOptions(
"plugin.approval.resolve",
{
id: hidden.id,
decision: "deny",
},
{
respond: hiddenRespond,
client: ownerClient,
},
),
);
expect(responseCall(hiddenRespond as unknown as MockCallSource).ok).toBe(false);
const error = responseError(hiddenRespond as unknown as MockCallSource);
expect(error.code).toBe("INVALID_REQUEST");
expect(error.message).toBe("unknown or expired approval id");
expect(manager.getSnapshot(hidden.id)?.decision).toBeUndefined();
});
it("rejects decisions outside plugin approval allowed decisions", async () => {
const handlers = createPluginApprovalHandlers(manager);
const record = manager.create(
+17 -8
View File
@@ -21,6 +21,7 @@ import {
handleApprovalWaitDecision,
handlePendingApprovalRequest,
isApprovalDecision,
isApprovalRecordVisibleToClient,
} from "./approval-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -29,15 +30,18 @@ export function createPluginApprovalHandlers(
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
return {
"plugin.approval.list": async ({ respond }) => {
"plugin.approval.list": async ({ respond, client }) => {
respond(
true,
manager.listPendingRecords().map((record) => ({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})),
manager
.listPendingRecords()
.filter((record) => isApprovalRecordVisibleToClient({ record, client }))
.map((record) => ({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})),
undefined,
);
},
@@ -106,6 +110,10 @@ export function createPluginApprovalHandlers(
// Always server-generate the ID — never accept plugin-provided IDs.
// Kind-prefix so /approve routing can distinguish plugin vs exec IDs deterministically.
const record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`);
record.requestedByConnId = client?.connId ?? null;
record.requestedByDeviceId = client?.connect?.device?.id ?? null;
record.requestedByClientId = client?.connect?.client?.id ?? null;
record.requestedByDeviceTokenAuth = client?.isDeviceTokenAuth === true;
let decisionPromise: Promise<ExecApprovalDecision | null>;
try {
@@ -148,10 +156,11 @@ export function createPluginApprovalHandlers(
});
},
"plugin.approval.waitDecision": async ({ params, respond }) => {
"plugin.approval.waitDecision": async ({ params, respond, client }) => {
await handleApprovalWaitDecision({
manager,
inputId: (params as { id?: string }).id,
client,
respond,
});
},
@@ -765,12 +765,13 @@ describe("exec approval handlers", () => {
handlers: ExecApprovalHandlers;
id: string;
respond: ReturnType<typeof vi.fn>;
client?: ExecApprovalGetArgs["client"];
}) {
return params.handlers["exec.approval.get"]({
params: { id: params.id } as ExecApprovalGetArgs["params"],
respond: params.respond as unknown as ExecApprovalGetArgs["respond"],
context: {} as ExecApprovalGetArgs["context"],
client: null,
client: params.client ?? null,
req: { id: "req-get", type: "req", method: "exec.approval.get" },
isWebchatConnect: execApprovalNoop,
});
@@ -779,12 +780,13 @@ describe("exec approval handlers", () => {
async function listExecApprovals(params: {
handlers: ExecApprovalHandlers;
respond: ReturnType<typeof vi.fn>;
client?: ExecApprovalResolveArgs["client"];
}) {
return params.handlers["exec.approval.list"]({
params: {} as never,
respond: params.respond as never,
context: {} as never,
client: null,
client: params.client ?? null,
req: { id: "req-list", type: "req", method: "exec.approval.list" },
isWebchatConnect: execApprovalNoop,
});
@@ -795,6 +797,7 @@ describe("exec approval handlers", () => {
respond: ReturnType<typeof vi.fn>;
context: { broadcast: (event: string, payload: unknown) => void };
params?: Record<string, unknown>;
client?: ExecApprovalRequestArgs["client"];
}) {
const requestParams = {
...defaultExecApprovalRequestParams,
@@ -838,7 +841,7 @@ describe("exec approval handlers", () => {
hasExecApprovalClients: () => true,
...params.context,
}),
client: null,
client: params.client ?? null,
req: { id: "req-1", type: "req", method: "exec.approval.request" },
isWebchatConnect: execApprovalNoop,
});
@@ -850,6 +853,7 @@ describe("exec approval handlers", () => {
decision?: "allow-once" | "allow-always" | "deny";
respond: ReturnType<typeof vi.fn>;
context: { broadcast: (event: string, payload: unknown) => void };
client?: ExecApprovalResolveArgs["client"];
}) {
return params.handlers["exec.approval.resolve"]({
params: {
@@ -858,7 +862,7 @@ describe("exec approval handlers", () => {
} as ExecApprovalResolveArgs["params"],
respond: params.respond as unknown as ExecApprovalResolveArgs["respond"],
context: toExecApprovalResolveContext(params.context),
client: null,
client: params.client ?? null,
req: { id: "req-2", type: "req", method: "exec.approval.resolve" },
isWebchatConnect: execApprovalNoop,
});
@@ -1145,6 +1149,83 @@ describe("exec approval handlers", () => {
await requestPromise;
});
it("lists and resolves only exec approvals owned by the caller", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
const context = {
broadcast: (_event: string, _payload: unknown) => {},
};
const ownerClient = {
connId: "conn-owner",
connect: {
client: { id: "client-owner" },
device: { id: "device-owner" },
},
} as unknown as ExecApprovalResolveArgs["client"];
const otherClient = {
connId: "conn-other",
connect: {
client: { id: "client-other" },
device: { id: "device-other" },
},
} as unknown as ExecApprovalResolveArgs["client"];
const visible = manager.create({ command: "echo visible" }, 60_000, "approval-abcd-visible");
visible.requestedByDeviceId = "device-owner";
visible.requestedByConnId = "conn-owner";
visible.requestedByClientId = "client-owner";
void manager.register(visible, 60_000);
const hidden = manager.create({ command: "echo hidden" }, 60_000, "approval-abcd-hidden");
hidden.requestedByDeviceId = "device-other";
hidden.requestedByConnId = "conn-other";
hidden.requestedByClientId = "client-other";
void manager.register(hidden, 60_000);
const listRespond = vi.fn();
await listExecApprovals({ handlers, respond: listRespond, client: ownerClient });
expect(mockCallArg(listRespond)).toBe(true);
const approvals = mockCallArg(listRespond, 0, 1) as Array<Record<string, unknown>>;
expect(approvals.map((entry) => entry.id)).toEqual(["approval-abcd-visible"]);
const resolveRespond = vi.fn();
await resolveExecApproval({
handlers,
id: "approval-abcd",
respond: resolveRespond,
context,
client: ownerClient,
});
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(manager.getSnapshot(visible.id)?.decision).toBe("allow-once");
expect(manager.getSnapshot(hidden.id)?.decision).toBeUndefined();
const hiddenRespond = vi.fn();
await resolveExecApproval({
handlers,
id: hidden.id,
respond: hiddenRespond,
context,
client: ownerClient,
});
expect(mockCallArg(hiddenRespond)).toBe(false);
expectRecordFields(mockCallArg(hiddenRespond, 0, 2), {
code: "INVALID_REQUEST",
message: "unknown or expired approval id",
});
expect(manager.getSnapshot(hidden.id)?.decision).toBeUndefined();
const otherRespond = vi.fn();
await resolveExecApproval({
handlers,
id: hidden.id,
respond: otherRespond,
context,
client: otherClient,
});
expect(otherRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
});
it("returns not found for stale exec.approval.get ids", async () => {
const { handlers, respond, context } = createExecApprovalFixture();
@@ -1974,6 +2055,57 @@ describe("exec approval handlers", () => {
await requestPromise;
});
it("does not count iOS push delivery to hidden approval targets as a route", async () => {
const iosPushDelivery = {
handleRequested: vi.fn(
async (
_request: unknown,
opts?: {
isTargetVisible?: (target: { deviceId: string; scopes: readonly string[] }) => boolean;
},
) =>
opts?.isTargetVisible?.({
deviceId: "device-other",
scopes: ["operator.approvals"],
}) ?? true,
),
handleResolved: vi.fn(async () => {}),
handleExpired: vi.fn(async () => {}),
};
const { manager, handlers, respond, context } = createForwardingExecApprovalFixture({
iosPushDelivery,
});
const expireSpy = vi.spyOn(manager, "expire");
await requestExecApproval({
handlers,
respond,
context,
client: {
connId: "conn-owner",
connect: {
client: { id: "client-owner" },
device: { id: "device-owner" },
scopes: ["operator.approvals"],
},
} as unknown as ExecApprovalRequestArgs["client"],
params: {
timeoutMs: 60_000,
id: "approval-ios-hidden-push",
host: "gateway",
},
});
expect(iosPushDelivery.handleRequested).toHaveBeenCalledTimes(1);
expect(expireSpy).toHaveBeenCalledWith("approval-ios-hidden-push", "no-approval-route");
expect(lastMockCallArg(respond)).toBe(true);
expectRecordFields(lastMockCallArg(respond, 1), {
id: "approval-ios-hidden-push",
decision: null,
});
expect(lastMockCallArg(respond, 2)).toBeUndefined();
});
it("sends iOS cleanup delivery on resolve", async () => {
const iosPushDelivery = {
handleRequested: vi.fn(async () => true),
+7 -1
View File
@@ -7,7 +7,7 @@ import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.
import type { createSubsystemLogger } from "../../logging/subsystem.js";
import type { WizardSession } from "../../wizard/session.js";
import type { ChatAbortControllerEntry } from "../chat-abort.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js";
import type { NodeRegistry } from "../node-registry.js";
import type { PluginNodeCapabilitySurface } from "../plugin-node-capability.js";
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
@@ -29,6 +29,7 @@ export type GatewayClient = {
isDeviceTokenAuth?: boolean;
internal?: {
allowModelOverride?: boolean;
approvalRuntime?: boolean;
pluginRuntimeOwnerId?: string;
};
};
@@ -66,6 +67,11 @@ export type GatewayRequestContext = {
nodeUnsubscribeAll: (nodeId: string) => void;
hasConnectedTalkNode: () => boolean;
hasExecApprovalClients?: (excludeConnId?: string) => boolean;
getApprovalClientConnIds?: <TPayload>(params?: {
excludeConnId?: string;
filter?: (client: GatewayClient, record?: ExecApprovalRecord<TPayload>) => boolean;
record?: ExecApprovalRecord<TPayload>;
}) => ReadonlySet<string>;
disconnectClientsForDevice?: (deviceId: string, opts?: { role?: string }) => void;
disconnectClientsUsingSharedGatewayAuth?: () => void;
enforceSharedGatewayAuthGenerationForConfigWrite?: (nextConfig: OpenClawConfig) => void;
+2 -1
View File
@@ -16,7 +16,7 @@ import type { PluginRuntime } from "../plugins/runtime/types.js";
import type { PluginLogger } from "../plugins/types.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
import { ADMIN_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js";
import type { ErrorShape } from "./protocol/index.js";
import { PROTOCOL_VERSION } from "./protocol/version.js";
@@ -266,6 +266,7 @@ function createSyntheticOperatorClient(params?: {
},
internal: {
allowModelOverride: params?.allowModelOverride === true,
...(params?.scopes?.includes(APPROVALS_SCOPE) ? { approvalRuntime: true } : {}),
...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}),
},
};
+25 -4
View File
@@ -68,6 +68,11 @@ type GatewayRequestContextParams = {
export function createGatewayRequestContext(
params: GatewayRequestContextParams,
): GatewayRequestContext {
const hasApprovalScope = (gatewayClient: GatewayClient): boolean => {
const scopes = Array.isArray(gatewayClient.connect.scopes) ? gatewayClient.connect.scopes : [];
return scopes.includes("operator.admin") || scopes.includes("operator.approvals");
};
return {
deps: params.deps,
// Keep cron reads live so config hot reload can swap cron/store state without rebuilding
@@ -101,15 +106,31 @@ export function createGatewayRequestContext(
if (excludeConnId && gatewayClient.connId === excludeConnId) {
continue;
}
const scopes = Array.isArray(gatewayClient.connect.scopes)
? gatewayClient.connect.scopes
: [];
if (scopes.includes("operator.admin") || scopes.includes("operator.approvals")) {
if (hasApprovalScope(gatewayClient)) {
return true;
}
}
return false;
},
getApprovalClientConnIds: (opts = {}) => {
const connIds = new Set<string>();
for (const gatewayClient of params.clients) {
if (!gatewayClient.connId) {
continue;
}
if (opts.excludeConnId && gatewayClient.connId === opts.excludeConnId) {
continue;
}
if (!hasApprovalScope(gatewayClient)) {
continue;
}
if (opts.filter && !opts.filter(gatewayClient, opts.record)) {
continue;
}
connIds.add(gatewayClient.connId);
}
return connIds;
},
disconnectClientsForDevice: (deviceId: string, opts?: { role?: string }) => {
for (const gatewayClient of params.clients) {
if (gatewayClient.connect.device?.id !== deviceId) {
@@ -212,6 +212,22 @@ describe("node.invoke approval bypass", () => {
}
};
const approvePendingNodePairings = async (nodeId: string) => {
const { approveNodePairing, listNodePairing } = await import("../infra/node-pairing.js");
const list = await listNodePairing();
let approved = false;
for (const pending of list.pending) {
if (pending.nodeId !== nodeId) {
continue;
}
const result = await approveNodePairing(pending.requestId, {
callerScopes: ["operator.pairing", "operator.write", "operator.admin"],
});
approved ||= Boolean(result && "node" in result);
}
return approved;
};
const connectOperatorWithRetry = async (
scopes: string[],
resolveDevice?: (nonce: string) => NonNullable<Parameters<typeof connectReq>[1]>["device"],
@@ -382,10 +398,12 @@ describe("node.invoke approval bypass", () => {
return client;
};
const pendingClient = await startNodeClient();
await approveAllPendingPairings();
pendingClient.stop();
return await startNodeClient();
let client = await startNodeClient();
if (await approvePendingNodePairings(resolvedDeviceIdentity.deviceId)) {
client.stop();
client = await startNodeClient();
}
return client;
};
test("rejects malformed/forbidden node.invoke payloads before forwarding", async () => {
@@ -7,8 +7,10 @@ import {
setActivePluginRegistry,
} from "../../plugins/runtime.js";
import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
import { ExecApprovalManager } from "../exec-approval-manager.js";
import type { AuthorizedGatewayHttpRequest } from "../http-utils.js";
import { authorizeOperatorScopesForMethod, CLI_DEFAULT_OPERATOR_SCOPES } from "../method-scopes.js";
import { isApprovalRecordVisibleToClient } from "../server-methods/approval-shared.js";
import { makeMockHttpResponse } from "../test-http-response.js";
import { createTestRegistry } from "./__tests__/test-utils.js";
import { createGatewayPluginRequestHandler } from "./plugins-http.js";
@@ -140,6 +142,47 @@ describe("plugin HTTP route runtime scopes", () => {
expect(log.warn).not.toHaveBeenCalled();
});
it("does not give approval-scoped gateway-auth routes global approval visibility", async () => {
const manager = new ExecApprovalManager<{ command: string }>();
const record = manager.create({ command: "echo ok" }, 60_000, "route-hidden-approval");
record.requestedByDeviceId = "device-owner";
record.requestedByConnId = "conn-owner";
record.requestedByClientId = "client-owner";
let observedApprovalRuntime: boolean | undefined;
let observedVisibility: boolean | undefined;
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
createRoute({
path: "/secure-hook",
auth: "gateway",
handler: async () => {
const runtimeClient = getPluginRuntimeGatewayRequestScope()?.client;
observedApprovalRuntime = runtimeClient?.internal?.approvalRuntime;
observedVisibility = isApprovalRecordVisibleToClient({
record,
client: runtimeClient ?? null,
});
return true;
},
}),
],
}),
log: createMockLogger(),
});
const { res } = makeMockHttpResponse();
const handled = await handler({ url: "/secure-hook" } as IncomingMessage, res, undefined, {
gatewayAuthSatisfied: true,
gatewayRequestOperatorScopes: ["operator.approvals"],
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(observedApprovalRuntime).not.toBe(true);
expect(observedVisibility).toBe(false);
});
it("fails closed when gateway-auth route runtime scopes are missing", async () => {
const { handled, res, log } = await invokeRoute({
path: "/secure-hook",
@@ -17,6 +17,7 @@ type HandshakeConnectAuth = {
bootstrapToken?: string;
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
};
export type DeviceTokenCandidateSource = "explicit-device-token" | "shared-token-fallback";
@@ -131,6 +131,28 @@ describe("handshake auth helpers", () => {
}),
).toBe(false);
});
it("requires explicit pairing for browser-origin clients even when locality resolves local", () => {
expect(
shouldAllowSilentLocalPairing({
locality: "browser_container_local",
hasBrowserOriginHeader: true,
isControlUi: true,
isWebchat: true,
reason: "not-paired",
}),
).toBe(false);
expect(
shouldAllowSilentLocalPairing({
locality: "shared_secret_loopback_local",
hasBrowserOriginHeader: true,
isControlUi: false,
isWebchat: true,
reason: "scope-upgrade",
}),
).toBe(false);
});
it("rejects silent role-upgrade for remote clients", () => {
expect(
shouldAllowSilentLocalPairing({
@@ -35,6 +35,7 @@ type HandshakeConnectAuth = {
bootstrapToken?: string;
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
};
function resolveBrowserOriginRateLimitKey(requestOrigin?: string): string {
@@ -1,7 +1,9 @@
import type { IncomingMessage } from "node:http";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import type { HealthSummary } from "../../../commands/health.types.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import { getOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js";
import { PROTOCOL_VERSION } from "../../protocol/index.js";
import type { GatewayRequestContext } from "../../server-methods/types.js";
@@ -74,6 +76,25 @@ function createLogger() {
};
}
function createHealthSummary(): HealthSummary {
return {
ok: true,
ts: 1,
durationMs: 1,
channels: {},
channelOrder: [],
channelLabels: {},
heartbeatSeconds: 0,
defaultAgentId: "main",
agents: [],
sessions: {
path: "",
count: 0,
recent: [],
},
};
}
describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -81,12 +102,12 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
it("uses the injected runtime-aware health refresh after hello", async () => {
let resolveRefresh: (() => void) | undefined;
const refreshHealthSnapshot = vi.fn(
const refreshHealthSnapshot = vi.fn<GatewayRequestContext["refreshHealthSnapshot"]>(
() =>
new Promise((resolve) => {
resolveRefresh = () => resolve({} as never);
resolveRefresh = () => resolve(createHealthSummary());
}),
) as GatewayRequestContext["refreshHealthSnapshot"];
);
const socketSend = vi.fn((_payload: string, cb?: (err?: Error) => void) => {
cb?.();
});
@@ -180,4 +201,197 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
});
resolveRefresh?.();
});
it("does not mark local backend self-pairing clients as approval runtimes", async () => {
const refreshHealthSnapshot = vi.fn<GatewayRequestContext["refreshHealthSnapshot"]>(async () =>
createHealthSummary(),
);
const socketSend = vi.fn((_payload: string, cb?: (err?: Error) => void) => {
cb?.();
});
let onMessage: ((data: string) => void) | undefined;
const socket = {
_receiver: {},
send: socketSend,
on: vi.fn((event: string, handler: (data: string) => void) => {
if (event === "message") {
onMessage = handler;
}
return socket;
}),
} as unknown as WebSocket;
const send = vi.fn();
let client: unknown = null;
const resolvedAuth: ResolvedGatewayAuth = {
mode: "none",
allowTailscale: false,
};
attachGatewayWsMessageHandler({
socket,
upgradeReq: {
headers: { host: "127.0.0.1:19001" },
socket: { localAddress: "127.0.0.1", remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage,
connId: "conn-approval-runtime-spoof",
remoteAddr: "127.0.0.1",
localAddr: "127.0.0.1",
requestHost: "127.0.0.1:19001",
connectNonce: "nonce-approval-runtime-spoof",
getResolvedAuth: () => resolvedAuth,
gatewayMethods: [],
events: [],
extraHandlers: {},
buildRequestContext: () => ({}) as GatewayRequestContext,
refreshHealthSnapshot,
send,
close: vi.fn(),
isClosed: vi.fn(() => false),
clearHandshakeTimer: vi.fn(),
getClient: () => client as never,
setClient: (next) => {
client = next;
return true;
},
setHandshakeState: vi.fn(),
setCloseCause: vi.fn(),
setLastFrameMeta: vi.fn(),
originCheckMetrics: { hostHeaderFallbackAccepted: 0 },
logGateway: createLogger() as never,
logHealth: createLogger() as never,
logWsControl: createLogger() as never,
});
if (onMessage === undefined) {
throw new Error("expected websocket message handler");
}
onMessage(
JSON.stringify({
type: "req",
id: "connect-approval-runtime-spoof",
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "gateway-client",
version: "dev",
platform: "test",
mode: "backend",
},
role: "operator",
scopes: ["operator.approvals"],
caps: [],
},
}),
);
await vi.waitFor(() => {
expect(socketSend).toHaveBeenCalled();
});
const connectedClient = client as {
connect?: { scopes?: string[] };
internal?: { approvalRuntime?: boolean };
} | null;
expect(connectedClient?.connect?.scopes).toEqual(["operator.approvals"]);
expect(connectedClient?.internal?.approvalRuntime).not.toBe(true);
});
it("marks operator approval clients with the server runtime token", async () => {
const refreshHealthSnapshot = vi.fn<GatewayRequestContext["refreshHealthSnapshot"]>(async () =>
createHealthSummary(),
);
const socketSend = vi.fn((_payload: string, cb?: (err?: Error) => void) => {
cb?.();
});
let onMessage: ((data: string) => void) | undefined;
const socket = {
_receiver: {},
send: socketSend,
on: vi.fn((event: string, handler: (data: string) => void) => {
if (event === "message") {
onMessage = handler;
}
return socket;
}),
} as unknown as WebSocket;
const send = vi.fn();
let client: unknown = null;
const resolvedAuth: ResolvedGatewayAuth = {
mode: "none",
allowTailscale: false,
};
attachGatewayWsMessageHandler({
socket,
upgradeReq: {
headers: { host: "127.0.0.1:19001" },
socket: { localAddress: "127.0.0.1", remoteAddress: "127.0.0.1" },
} as unknown as IncomingMessage,
connId: "conn-approval-runtime-token",
remoteAddr: "127.0.0.1",
localAddr: "127.0.0.1",
requestHost: "127.0.0.1:19001",
connectNonce: "nonce-approval-runtime-token",
getResolvedAuth: () => resolvedAuth,
gatewayMethods: [],
events: [],
extraHandlers: {},
buildRequestContext: () => ({}) as GatewayRequestContext,
refreshHealthSnapshot,
send,
close: vi.fn(),
isClosed: vi.fn(() => false),
clearHandshakeTimer: vi.fn(),
getClient: () => client as never,
setClient: (next) => {
client = next;
return true;
},
setHandshakeState: vi.fn(),
setCloseCause: vi.fn(),
setLastFrameMeta: vi.fn(),
originCheckMetrics: { hostHeaderFallbackAccepted: 0 },
logGateway: createLogger() as never,
logHealth: createLogger() as never,
logWsControl: createLogger() as never,
});
if (onMessage === undefined) {
throw new Error("expected websocket message handler");
}
onMessage(
JSON.stringify({
type: "req",
id: "connect-approval-runtime-token",
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "gateway-client",
version: "dev",
platform: "test",
mode: "backend",
},
role: "operator",
scopes: ["operator.approvals"],
caps: [],
auth: {
approvalRuntimeToken: getOperatorApprovalRuntimeToken(),
},
},
}),
);
await vi.waitFor(() => {
expect(socketSend).toHaveBeenCalled();
});
const connectedClient = client as {
internal?: { approvalRuntime?: boolean };
} | null;
expect(connectedClient?.internal?.approvalRuntime).toBe(true);
});
});
@@ -53,7 +53,7 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js";
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
import { ADMIN_SCOPE } from "../../method-scopes.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../method-scopes.js";
import {
isLocalishHost,
isLoopbackAddress,
@@ -65,6 +65,7 @@ import {
resolveNodePairingClientIpSource,
shouldAutoApproveNodePairingFromTrustedCidrs,
} from "../../node-pairing-auto-approve.js";
import { isOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import {
buildPluginNodeCapabilityScopedHostUrl,
@@ -1338,6 +1339,11 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
const sharedGatewaySessionGeneration = usesSharedGatewayAuth
? resolveSharedGatewaySessionGeneration(resolvedAuth, trustedProxies)
: undefined;
const isTrustedApprovalRuntime =
scopes.includes(APPROVALS_SCOPE) &&
connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND &&
isOperatorApprovalRuntimeToken(connectParams.auth?.approvalRuntimeToken);
clearHandshakeTimer();
const nextClient: GatewayWsClient = {
socket,
@@ -1348,6 +1354,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
sharedGatewaySessionGeneration,
presenceKey,
clientIp: reportedClientIp,
...(isTrustedApprovalRuntime ? { internal: { approvalRuntime: true } } : {}),
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
...(Object.keys(pluginNodeCapabilitySurfaces).length > 0
? { pluginNodeCapabilitySurfaces }
+3
View File
@@ -11,4 +11,7 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
sharedGatewaySessionGeneration?: string;
presenceKey?: string;
clientIp?: string;
internal?: {
approvalRuntime?: boolean;
};
};
+1 -1
View File
@@ -4,11 +4,11 @@ import {
registerMemoryCapability,
registerMemoryPromptSection,
} from "../plugins/memory-state.js";
import * as memoryCoreAlias from "./memory-core.js";
import {
buildActiveMemoryPromptSection,
listActiveMemoryPublicArtifacts,
} from "./memory-host-core.js";
import * as memoryCoreAlias from "./memory-core.js";
describe("memory-host-core helpers", () => {
afterEach(() => {
@@ -92,7 +92,9 @@ function makeManifestRegistry(pluginId = "demo"): PluginManifestRegistry {
return { plugins: [plugin], diagnostics: [] };
}
function firstPlugin(snapshot: ReturnType<typeof loadPluginMetadataSnapshot>): PluginManifestRecord {
function firstPlugin(
snapshot: ReturnType<typeof loadPluginMetadataSnapshot>,
): PluginManifestRecord {
const plugin = snapshot.plugins[0];
if (!plugin) {
throw new Error("expected memo test fixture plugin");