mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
Fix chat-channel node exec approval replays
Fixes #77656.
Summary:
- Carry chat turn-source metadata through approved async host=node replays.
- Bind trusted backend replay to node, command, session, agent, and chat target metadata instead of transient WebSocket connection ids.
- Cover Telegram and WeCom-style reconnect replay plus denial cases with gateway, websocket, and agent tests.
- Carry the current-main CLI help assertion fix needed to clear exact-head CI after the rebase.
Verification:
- pnpm test src/gateway/node-invoke-system-run-approval.test.ts src/gateway/server.node-invoke-approval-bypass.test.ts src/agents/bash-tools.exec-host-node.test.ts -- --reporter=verbose
- pnpm test src/cli/channel-auth.test.ts src/cli/plugins-cli.policy.test.ts src/cli/command-registration-policy.test.ts -- --reporter=verbose
- pnpm check:changed
- GitHub CI passed on d1392a873c
- ClawSweeper re-review completed on the approval replay head
This commit is contained in:
@@ -223,6 +223,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Image generation: honor configured web-fetch SSRF policy across OpenAI, Google, MiniMax, OpenRouter, and Vydra provider requests so RFC2544 fake-IP proxy opt-ins reach generation calls. Fixes #79716. (#79765) Thanks @hclsys.
|
||||
- Telegram: persist reply-chain message cache records as a compact append log instead of rewriting the full cache on every inbound message, reducing large-group turn latency.
|
||||
- Telegram/CLI-backend: mirror outbound replies to the session transcript so CLI-backend agent responses create `.jsonl` session files, preventing `sessionId=unknown` on subsequent runs. Fixes #75991.
|
||||
- Gateway/nodes: allow approved chat-channel macOS node exec replays to cross transient agent WebSocket reconnects only when node, agent session, and channel target metadata still match, restoring Telegram/WeCom host=node approvals without opening a general backend replay bypass. Fixes #77656. Thanks @BunsDev.
|
||||
- QQBot: route gateway WebSocket connections through the ambient proxy agent so deployments with `https_proxy`, `HTTPS_PROXY`, or `HTTP_PROXY` can reach the QQ gateway. (#72961) Thanks @xialonglee.
|
||||
- Agents/subagents: treat `sessions_spawn` `model: "default"` as the default-model fallback and ignore ACP-only stream targets for native sub-agent spawns. Fixes #72078. (#72101) Thanks @xialonglee.
|
||||
- Agents/failover: stop retrying assistant-prefill format rejections across auth profiles or model fallbacks, surfacing the deterministic provider error instead of requeueing the lane. Fixes #79688. (#79728) Thanks @hclsys.
|
||||
|
||||
@@ -153,6 +153,10 @@ export function buildNodeSystemRunInvoke(params: {
|
||||
cwd: string | undefined;
|
||||
agentId: string | undefined;
|
||||
sessionKey: string | undefined;
|
||||
turnSourceChannel?: string;
|
||||
turnSourceTo?: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
approved?: boolean;
|
||||
approvalDecision?: "allow-once" | "allow-always" | null;
|
||||
runId?: string;
|
||||
@@ -174,6 +178,14 @@ export function buildNodeSystemRunInvoke(params: {
|
||||
timeoutMs,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.turnSourceChannel != null ? { turnSourceChannel: params.turnSourceChannel } : {}),
|
||||
...(params.turnSourceTo != null ? { turnSourceTo: params.turnSourceTo } : {}),
|
||||
...(params.turnSourceAccountId != null
|
||||
? { turnSourceAccountId: params.turnSourceAccountId }
|
||||
: {}),
|
||||
...(params.turnSourceThreadId != null
|
||||
? { turnSourceThreadId: params.turnSourceThreadId }
|
||||
: {}),
|
||||
approved: params.approved,
|
||||
approvalDecision: params.approvalDecision ?? undefined,
|
||||
runId: params.runId ?? undefined,
|
||||
|
||||
@@ -271,6 +271,10 @@ describe("executeNodeHostCommand", () => {
|
||||
warnings: [],
|
||||
agentId: "requested-agent",
|
||||
sessionKey: "requested-session",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
});
|
||||
|
||||
expect(result.details?.status).toBe("approval-pending");
|
||||
@@ -295,6 +299,10 @@ describe("executeNodeHostCommand", () => {
|
||||
approvalDecision: "allow-once",
|
||||
systemRunPlan: preparedPlan,
|
||||
timeoutMs: 30_000,
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
}),
|
||||
}),
|
||||
{ scopes: ["operator.write", "operator.approvals"] },
|
||||
|
||||
@@ -218,6 +218,10 @@ export async function executeNodeHostCommand(
|
||||
cwd: prepared.cwd,
|
||||
agentId: prepared.agentId,
|
||||
sessionKey: prepared.sessionKey,
|
||||
turnSourceChannel: params.turnSourceChannel,
|
||||
turnSourceTo: params.turnSourceTo,
|
||||
turnSourceAccountId: params.turnSourceAccountId,
|
||||
turnSourceThreadId: params.turnSourceThreadId,
|
||||
approved: approvedByAsk,
|
||||
approvalDecision:
|
||||
approvalDecision === "allow-always" && inlineEvalHit !== null
|
||||
|
||||
@@ -102,6 +102,38 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
|
||||
expect(result.details?.code).toBe(code);
|
||||
}
|
||||
|
||||
function makeChatRecord(overrides: Partial<ExecApprovalRecord["request"]> = {}) {
|
||||
const record = makeRecord("echo SAFE", ["echo", "SAFE"]);
|
||||
record.requestedByConnId = "chat-agent-conn";
|
||||
record.requestedByDeviceId = null;
|
||||
record.requestedByClientId = "gateway-client";
|
||||
record.requestedByDeviceTokenAuth = false;
|
||||
record.request = {
|
||||
...record.request,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
systemRunPlan: {
|
||||
argv: ["echo", "SAFE"],
|
||||
cwd: null,
|
||||
commandText: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
},
|
||||
systemRunBinding: buildSystemRunApprovalBinding({
|
||||
argv: ["echo", "SAFE"],
|
||||
cwd: null,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
}).binding,
|
||||
...overrides,
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
test("rejects cmd.exe /c trailing-arg mismatch against rawCommand", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
@@ -437,6 +469,29 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
|
||||
expectRejectedForwardingResult(result, "APPROVAL_NODE_MISMATCH", "not valid for this node");
|
||||
});
|
||||
|
||||
test("rejects approval ids replayed from a different device token binding", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: {
|
||||
...client,
|
||||
connect: {
|
||||
...client.connect,
|
||||
device: { id: "dev-2" },
|
||||
},
|
||||
},
|
||||
execApprovalManager: manager(makeRecord("echo SAFE")),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectRejectedForwardingResult(result, "APPROVAL_DEVICE_MISMATCH", "not valid for this device");
|
||||
});
|
||||
|
||||
test("accepts trusted backend replay for no-device approval after the request connection changes", () => {
|
||||
const record = makeRecord("echo SAFE", ["echo", "SAFE"]);
|
||||
record.requestedByConnId = "control-ui-conn";
|
||||
@@ -522,6 +577,239 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
|
||||
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
|
||||
});
|
||||
|
||||
test("accepts trusted backend chat replay when stable requester metadata matches", () => {
|
||||
const record = makeChatRecord();
|
||||
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
rawCommand: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: trustedBackendClient,
|
||||
execApprovalManager: manager(record),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectAllowOnceForwardingResult(result);
|
||||
if (!result.ok) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
const forwarded = result.params as Record<string, unknown>;
|
||||
expect(forwarded).not.toHaveProperty("turnSourceChannel");
|
||||
expect(forwarded).not.toHaveProperty("turnSourceTo");
|
||||
expect(forwarded).not.toHaveProperty("turnSourceAccountId");
|
||||
expect(forwarded).not.toHaveProperty("turnSourceThreadId");
|
||||
});
|
||||
|
||||
test("accepts trusted backend chat replay from a non-bridgeable agent client when stable requester metadata matches", () => {
|
||||
const record = makeChatRecord();
|
||||
record.requestedByClientId = "chat-agent";
|
||||
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
rawCommand: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: trustedBackendClient,
|
||||
execApprovalManager: manager(record),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectAllowOnceForwardingResult(result);
|
||||
});
|
||||
|
||||
test("accepts trusted backend WeCom replay when the approved chat agent connection changes", () => {
|
||||
const sessionKey = "agent:main:wecom:conversation:corp-42";
|
||||
const record = makeChatRecord({
|
||||
sessionKey,
|
||||
turnSourceChannel: "wecom",
|
||||
turnSourceTo: "wecom:corp-42:conversation-7",
|
||||
turnSourceAccountId: "corp-42",
|
||||
turnSourceThreadId: "conversation-7",
|
||||
systemRunPlan: {
|
||||
argv: ["echo", "SAFE"],
|
||||
cwd: null,
|
||||
commandText: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
},
|
||||
systemRunBinding: buildSystemRunApprovalBinding({
|
||||
argv: ["echo", "SAFE"],
|
||||
cwd: null,
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
}).binding,
|
||||
});
|
||||
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
rawCommand: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
turnSourceChannel: "wecom",
|
||||
turnSourceTo: "wecom:corp-42:conversation-7",
|
||||
turnSourceAccountId: "corp-42",
|
||||
turnSourceThreadId: "conversation-7",
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: trustedBackendClient,
|
||||
execApprovalManager: manager(record),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectAllowOnceForwardingResult(result);
|
||||
});
|
||||
|
||||
test("rejects trusted backend chat replay when session binding changes", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
rawCommand: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:99999",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: trustedBackendClient,
|
||||
execApprovalManager: manager(makeChatRecord()),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
|
||||
});
|
||||
|
||||
test("rejects trusted backend chat replay when session binding casing changes", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
rawCommand: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:MAIN:telegram:direct:12345",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: trustedBackendClient,
|
||||
execApprovalManager: manager(makeChatRecord()),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
|
||||
});
|
||||
|
||||
test("rejects trusted backend chat replay when agent binding casing changes", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
rawCommand: "echo SAFE",
|
||||
agentId: "Main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: trustedBackendClient,
|
||||
execApprovalManager: manager(makeChatRecord()),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
|
||||
});
|
||||
|
||||
test("rejects trusted backend chat replay when channel target changes", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
rawCommand: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:67890",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: trustedBackendClient,
|
||||
execApprovalManager: manager(makeChatRecord()),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
|
||||
});
|
||||
|
||||
test("rejects trusted backend chat replay without matching approval scope", () => {
|
||||
const result = sanitizeSystemRunParamsForForwarding({
|
||||
rawParams: {
|
||||
command: ["echo", "SAFE"],
|
||||
rawCommand: "echo SAFE",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
runId: "approval-1",
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
nodeId: "node-1",
|
||||
client: {
|
||||
...trustedBackendClient,
|
||||
connect: {
|
||||
...trustedBackendClient.connect,
|
||||
scopes: ["operator.write"],
|
||||
},
|
||||
},
|
||||
execApprovalManager: manager(makeChatRecord()),
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
expectRejectedForwardingResult(result, "APPROVAL_CLIENT_MISMATCH", "not valid for this client");
|
||||
});
|
||||
|
||||
test("rejects no-device approval replay when the original request used device-token auth", () => {
|
||||
const record = makeRecord("echo SAFE", ["echo", "SAFE"]);
|
||||
record.requestedByConnId = "control-ui-conn";
|
||||
|
||||
@@ -23,6 +23,10 @@ type SystemRunParamsLike = {
|
||||
needsScreenRecording?: unknown;
|
||||
agentId?: unknown;
|
||||
sessionKey?: unknown;
|
||||
turnSourceChannel?: unknown;
|
||||
turnSourceTo?: unknown;
|
||||
turnSourceAccountId?: unknown;
|
||||
turnSourceThreadId?: unknown;
|
||||
approved?: unknown;
|
||||
approvalDecision?: unknown;
|
||||
runId?: unknown;
|
||||
@@ -75,15 +79,108 @@ function canBridgeNoDeviceApprovalFromBackend(params: {
|
||||
client: ApprovalClient | null;
|
||||
}): boolean {
|
||||
const requestedByClientId = normalizeNullableString(params.snapshot.requestedByClientId);
|
||||
const request = params.snapshot.request;
|
||||
return (
|
||||
params.snapshot.requestedByDeviceId == null &&
|
||||
params.snapshot.requestedByDeviceTokenAuth !== true &&
|
||||
!hasChatApprovalReplayBinding(request) &&
|
||||
requestedByClientId !== null &&
|
||||
BACKEND_BRIDGEABLE_NO_DEVICE_REQUEST_CLIENT_IDS.has(requestedByClientId) &&
|
||||
isTrustedBackendApprovalClient(params.client)
|
||||
);
|
||||
}
|
||||
|
||||
function hasChatApprovalReplayBinding(request: ExecApprovalRecord["request"]): boolean {
|
||||
return (
|
||||
normalizeComparableString(request.turnSourceChannel, { lowercase: true }) !== null ||
|
||||
normalizeComparableString(request.turnSourceTo) !== null ||
|
||||
normalizeComparableString(request.turnSourceAccountId) !== null ||
|
||||
normalizeComparableString(request.turnSourceThreadId) !== null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeComparableString(
|
||||
value: unknown,
|
||||
opts: { lowercase?: boolean } = {},
|
||||
): string | null {
|
||||
const normalized =
|
||||
typeof value === "number" && Number.isFinite(value)
|
||||
? String(value)
|
||||
: normalizeNullableString(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return opts.lowercase ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function matchesRequiredString(params: {
|
||||
expected: unknown;
|
||||
actual: unknown;
|
||||
lowercase?: boolean;
|
||||
}): boolean {
|
||||
const expected = normalizeComparableString(params.expected, { lowercase: params.lowercase });
|
||||
if (!expected) {
|
||||
return false;
|
||||
}
|
||||
return expected === normalizeComparableString(params.actual, { lowercase: params.lowercase });
|
||||
}
|
||||
|
||||
function matchesOptionalString(params: {
|
||||
expected: unknown;
|
||||
actual: unknown;
|
||||
lowercase?: boolean;
|
||||
}): boolean {
|
||||
const expected = normalizeComparableString(params.expected, { lowercase: params.lowercase });
|
||||
if (!expected) {
|
||||
return true;
|
||||
}
|
||||
return expected === normalizeComparableString(params.actual, { lowercase: params.lowercase });
|
||||
}
|
||||
|
||||
function canBridgeNoDeviceChatApprovalFromBackend(params: {
|
||||
snapshot: ExecApprovalRecord;
|
||||
rawParams: SystemRunParamsLike;
|
||||
client: ApprovalClient | null;
|
||||
}): boolean {
|
||||
if (
|
||||
params.snapshot.requestedByDeviceId != null ||
|
||||
params.snapshot.requestedByDeviceTokenAuth === true ||
|
||||
!isTrustedBackendApprovalClient(params.client)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const request = params.snapshot.request;
|
||||
const plan = request.systemRunPlan ?? null;
|
||||
return (
|
||||
matchesRequiredString({
|
||||
expected: request.turnSourceChannel,
|
||||
actual: params.rawParams.turnSourceChannel,
|
||||
lowercase: true,
|
||||
}) &&
|
||||
matchesRequiredString({
|
||||
expected: request.turnSourceTo,
|
||||
actual: params.rawParams.turnSourceTo,
|
||||
}) &&
|
||||
matchesRequiredString({
|
||||
expected: plan?.sessionKey ?? request.sessionKey,
|
||||
actual: params.rawParams.sessionKey,
|
||||
}) &&
|
||||
matchesOptionalString({
|
||||
expected: plan?.agentId ?? request.agentId,
|
||||
actual: params.rawParams.agentId,
|
||||
}) &&
|
||||
matchesOptionalString({
|
||||
expected: request.turnSourceAccountId,
|
||||
actual: params.rawParams.turnSourceAccountId,
|
||||
}) &&
|
||||
matchesOptionalString({
|
||||
expected: request.turnSourceThreadId,
|
||||
actual: params.rawParams.turnSourceThreadId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
// Defensive allowlist: only forward fields that the node-host `system.run` handler understands.
|
||||
// This prevents future internal control fields from being smuggled through the gateway.
|
||||
@@ -224,7 +321,8 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
|
||||
} else if (
|
||||
snapshot.requestedByConnId &&
|
||||
snapshot.requestedByConnId !== (opts.client?.connId ?? null) &&
|
||||
!canBridgeNoDeviceApprovalFromBackend({ snapshot, client: opts.client })
|
||||
!canBridgeNoDeviceApprovalFromBackend({ snapshot, client: opts.client }) &&
|
||||
!canBridgeNoDeviceChatApprovalFromBackend({ snapshot, rawParams: p, client: opts.client })
|
||||
) {
|
||||
return systemRunApprovalGuardError({
|
||||
code: "APPROVAL_CLIENT_MISMATCH",
|
||||
|
||||
@@ -122,6 +122,54 @@ async function requestAllowOnceApproval(
|
||||
return approvalId;
|
||||
}
|
||||
|
||||
type ChatApprovalContext = {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
turnSourceChannel: string;
|
||||
turnSourceTo: string;
|
||||
turnSourceAccountId?: string;
|
||||
turnSourceThreadId?: string | number;
|
||||
};
|
||||
|
||||
async function requestChatAllowOnceApproval(params: {
|
||||
ws: WebSocket;
|
||||
command: string;
|
||||
nodeId: string;
|
||||
context: ChatApprovalContext;
|
||||
}): Promise<string> {
|
||||
const approvalId = crypto.randomUUID();
|
||||
const commandArgv = params.command.split(/\s+/).filter((part) => part.length > 0);
|
||||
const requestP = rpcReq(params.ws, "exec.approval.request", {
|
||||
id: approvalId,
|
||||
command: params.command,
|
||||
commandArgv,
|
||||
systemRunPlan: {
|
||||
argv: commandArgv,
|
||||
cwd: null,
|
||||
commandText: params.command,
|
||||
agentId: params.context.agentId,
|
||||
sessionKey: params.context.sessionKey,
|
||||
},
|
||||
nodeId: params.nodeId,
|
||||
cwd: null,
|
||||
host: "node",
|
||||
agentId: params.context.agentId,
|
||||
sessionKey: params.context.sessionKey,
|
||||
turnSourceChannel: params.context.turnSourceChannel,
|
||||
turnSourceTo: params.context.turnSourceTo,
|
||||
turnSourceAccountId: params.context.turnSourceAccountId,
|
||||
turnSourceThreadId: params.context.turnSourceThreadId,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
await rpcReq(params.ws, "exec.approval.resolve", {
|
||||
id: approvalId,
|
||||
decision: "allow-once",
|
||||
});
|
||||
const requested = await requestP;
|
||||
expect(requested.ok).toBe(true);
|
||||
return approvalId;
|
||||
}
|
||||
|
||||
describe("node.invoke approval bypass", () => {
|
||||
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
|
||||
let port: number;
|
||||
@@ -195,6 +243,27 @@ describe("node.invoke approval bypass", () => {
|
||||
return await connectOperatorWithRetry(scopes);
|
||||
};
|
||||
|
||||
const connectTrustedBackend = async (scopes: string[]) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
trackConnectChallengeNonce(ws);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
scopes,
|
||||
device: null,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
displayName: "agent",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
},
|
||||
timeoutMs: CONNECT_REQ_TIMEOUT_MS,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
return ws;
|
||||
};
|
||||
|
||||
const connectOperatorWithNewDevice = async (scopes: string[]) => {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
||||
@@ -461,6 +530,101 @@ describe("node.invoke approval bypass", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("bridges no-device chat approvals across backend reconnects only for the same turn source", async () => {
|
||||
let invokeCount = 0;
|
||||
let lastInvokeParams: Record<string, unknown> | null = null;
|
||||
const node = await connectLinuxNode((payload) => {
|
||||
invokeCount += 1;
|
||||
const obj = payload as { paramsJSON?: unknown };
|
||||
const raw = typeof obj?.paramsJSON === "string" ? obj.paramsJSON : "";
|
||||
lastInvokeParams = raw ? (JSON.parse(raw) as Record<string, unknown>) : null;
|
||||
});
|
||||
|
||||
const wsRequest = await connectTrustedBackend(["operator.write", "operator.approvals"]);
|
||||
const wsReplay = await connectTrustedBackend(["operator.write", "operator.approvals"]);
|
||||
|
||||
try {
|
||||
const nodeId = await getConnectedNodeId(wsRequest);
|
||||
const context: ChatApprovalContext = {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:direct:12345",
|
||||
turnSourceChannel: "telegram",
|
||||
turnSourceTo: "telegram:12345",
|
||||
turnSourceAccountId: "work",
|
||||
turnSourceThreadId: "42",
|
||||
};
|
||||
|
||||
const approvalId = await requestChatAllowOnceApproval({
|
||||
ws: wsRequest,
|
||||
command: "echo chat",
|
||||
nodeId,
|
||||
context,
|
||||
});
|
||||
const invoke = await rpcReq(wsReplay, "node.invoke", {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
params: {
|
||||
command: ["echo", "chat"],
|
||||
rawCommand: "echo chat",
|
||||
agentId: context.agentId,
|
||||
sessionKey: context.sessionKey,
|
||||
turnSourceChannel: context.turnSourceChannel,
|
||||
turnSourceTo: context.turnSourceTo,
|
||||
turnSourceAccountId: context.turnSourceAccountId,
|
||||
turnSourceThreadId: context.turnSourceThreadId,
|
||||
runId: approvalId,
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
expect(invoke.ok).toBe(true);
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
if (lastInvokeParams) {
|
||||
break;
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
const forwardedParams = requireRecord(lastInvokeParams, "forwarded invoke params");
|
||||
expect(forwardedParams["approved"]).toBe(true);
|
||||
expect(forwardedParams["approvalDecision"]).toBe("allow-once");
|
||||
expect(forwardedParams["turnSourceTo"]).toBeUndefined();
|
||||
|
||||
const mismatchApprovalId = await requestChatAllowOnceApproval({
|
||||
ws: wsRequest,
|
||||
command: "echo chat",
|
||||
nodeId,
|
||||
context,
|
||||
});
|
||||
const invokeCountBeforeMismatch = invokeCount;
|
||||
const mismatch = await rpcReq(wsReplay, "node.invoke", {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
params: {
|
||||
command: ["echo", "chat"],
|
||||
rawCommand: "echo chat",
|
||||
agentId: context.agentId,
|
||||
sessionKey: context.sessionKey,
|
||||
turnSourceChannel: context.turnSourceChannel,
|
||||
turnSourceTo: "telegram:67890",
|
||||
turnSourceAccountId: context.turnSourceAccountId,
|
||||
turnSourceThreadId: context.turnSourceThreadId,
|
||||
runId: mismatchApprovalId,
|
||||
approved: true,
|
||||
approvalDecision: "allow-once",
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
});
|
||||
expect(mismatch.ok).toBe(false);
|
||||
expect(mismatch.error?.message ?? "").toContain("not valid for this client");
|
||||
await expectNoForwardedInvoke(() => invokeCount > invokeCountBeforeMismatch);
|
||||
} finally {
|
||||
wsRequest.close();
|
||||
wsReplay.close();
|
||||
node.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks cross-node replay on same device", async () => {
|
||||
const invokeCounts = new Map<string, number>();
|
||||
const onInvoke = (payload: unknown) => {
|
||||
|
||||
Reference in New Issue
Block a user