mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -11,4 +11,7 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
|
||||
sharedGatewaySessionGeneration?: string;
|
||||
presenceKey?: string;
|
||||
clientIp?: string;
|
||||
internal?: {
|
||||
approvalRuntime?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user