Skip to content

Commit 386d321

Browse files
authored
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
1 parent c9b6b0b commit 386d321

37 files changed

Lines changed: 2268 additions & 88 deletions

.agents/skills/openclaw-testing/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
4242
`pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, or `scripts/committer` until
4343
you have verified pnpm will not reconcile or reinstall dependencies. Use
4444
`node scripts/run-vitest.mjs` for tiny local proof, `node
45-
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
45+
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
4646
after the relevant remote or node-wrapper proof is already clean.
4747
- For Blacksmith Testbox proof, use Crabbox first. `pnpm crabbox:run -- --provider
4848
blacksmith-testbox --timing-json -- <command...>` warms, claims, syncs, runs,

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
2020

2121
### Fixes
2222

23+
- Bind gateway approval access to requester metadata [AI]. (#81380) Thanks @pgondhi987.
2324
- Telegram: let isolated polling drain independent topics, DMs, and status/control commands concurrently while preserving same-lane order. (#81849) Thanks @VACInc.
2425
- 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.
2526
- Build: keep externalized Slack, OpenShell sandbox, and Anthropic Vertex runtime dependency declarations out of the root dist artifact build.

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import {
2+
DEFAULT_TIMING,
3+
type StatusReactionController,
4+
} from "openclaw/plugin-sdk/channel-feedback";
15
import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-message";
2-
import { DEFAULT_TIMING, type StatusReactionController } from "openclaw/plugin-sdk/channel-feedback";
36
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch";
47
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
58
import {

src/acp/control-plane/manager.core.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,7 +1003,10 @@ export class AcpSessionManager {
10031003
sawOutput: sawTurnOutput,
10041004
};
10051005
backendAttempts.push(backendAttempt);
1006-
if (!isFailoverWorthyBackendError(backendAttempt) || !shouldAttemptFailover(backendIdx)) {
1006+
if (
1007+
!isFailoverWorthyBackendError(backendAttempt) ||
1008+
!shouldAttemptFailover(backendIdx)
1009+
) {
10071010
await recordBackendFailure(acpError);
10081011
}
10091012
break;
@@ -1014,13 +1017,7 @@ export class AcpSessionManager {
10141017
if (activeTurn && this.activeTurnBySession.get(actorKey) === activeTurn) {
10151018
this.activeTurnBySession.delete(actorKey);
10161019
}
1017-
if (
1018-
!retryFreshHandle &&
1019-
!skipPostTurnCleanup &&
1020-
runtime &&
1021-
handle &&
1022-
meta
1023-
) {
1020+
if (!retryFreshHandle && !skipPostTurnCleanup && runtime && handle && meta) {
10241021
({ handle, meta } = await this.reconcileRuntimeSessionIdentifiers({
10251022
cfg: input.cfg,
10261023
sessionKey,

src/acp/control-plane/manager.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,10 +1566,12 @@ describe("AcpSessionManager", () => {
15661566
expect(runtimeState.runTurn).toHaveBeenCalledTimes(1);
15671567
});
15681568

1569-
function setupFailoverBackends(params: {
1570-
initialBackend?: "primary-backend" | "fallback-backend";
1571-
primaryUnavailableError?: Error;
1572-
} = {}) {
1569+
function setupFailoverBackends(
1570+
params: {
1571+
initialBackend?: "primary-backend" | "fallback-backend";
1572+
primaryUnavailableError?: Error;
1573+
} = {},
1574+
) {
15731575
const primaryRuntime = createRuntime();
15741576
const fallbackRuntime = createRuntime();
15751577
const sessionKey = "agent:codex:acp:session-1";

src/agents/cli-runner/bundle-mcp-codex.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { normalizeConfiguredMcpServers } from "../../config/mcp-config-normalize
22
import type { OpenClawConfig } from "../../config/types.openclaw.js";
33
import type { BundleMcpConfig, BundleMcpServerConfig } from "../../plugins/bundle-mcp.js";
44
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
5+
import { buildCodexMcpServersConfig } from "../codex-mcp-config.js";
56
import {
67
applyCommonServerConfig,
78
decodeHeaderEnvPlaceholder,
89
normalizeStringRecord,
910
} from "./bundle-mcp-adapter-shared.js";
10-
import { buildCodexMcpServersConfig } from "../codex-mcp-config.js";
1111
import { serializeTomlInlineValue } from "./toml-inline.js";
1212

1313
// Mutable JSON shape structurally compatible with the bundled Codex

src/channels/plugins/read-only.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -669,11 +669,13 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
669669
expect(plugin?.meta.label).toBe("@example/openclaw-external-chat");
670670
expect(plugin?.meta.blurb).toBe("");
671671
expect(plugin?.configSchema).toBeUndefined();
672-
expect(plugin?.config.listAccountIds({
673-
channels: {
674-
"external-chat": { token: "configured" },
675-
},
676-
} as never)).toEqual(["default"]);
672+
expect(
673+
plugin?.config.listAccountIds({
674+
channels: {
675+
"external-chat": { token: "configured" },
676+
},
677+
} as never),
678+
).toEqual(["default"]);
677679
expect(fs.existsSync(setupMarker)).toBe(false);
678680
expect(fs.existsSync(fullMarker)).toBe(false);
679681
});

src/gateway/client.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,7 @@ describe("GatewayClient connect auth payload", () => {
737737
bootstrapToken?: string;
738738
deviceToken?: string;
739739
password?: string;
740+
approvalRuntimeToken?: string;
740741
};
741742
};
742743
};
@@ -811,6 +812,7 @@ describe("GatewayClient connect auth payload", () => {
811812
ws: MockWebSocket,
812813
connectId: string | undefined,
813814
details: Record<string, unknown>,
815+
message = "unauthorized",
814816
) {
815817
ws.emitMessage(
816818
JSON.stringify({
@@ -819,7 +821,7 @@ describe("GatewayClient connect auth payload", () => {
819821
ok: false,
820822
error: {
821823
code: "INVALID_REQUEST",
822-
message: "unauthorized",
824+
message,
823825
details,
824826
},
825827
}),
@@ -844,8 +846,14 @@ describe("GatewayClient connect auth payload", () => {
844846
firstWs: MockWebSocket;
845847
connectId: string | undefined;
846848
failureDetails: Record<string, unknown>;
849+
failureMessage?: string;
847850
}) {
848-
emitConnectFailure(params.firstWs, params.connectId, params.failureDetails);
851+
emitConnectFailure(
852+
params.firstWs,
853+
params.connectId,
854+
params.failureDetails,
855+
params.failureMessage,
856+
);
849857
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
850858
const ws = getLatestWs();
851859
ws.emitOpen();
@@ -887,6 +895,42 @@ describe("GatewayClient connect auth payload", () => {
887895
client.stop();
888896
});
889897

898+
it("retries without approval runtime token when a gateway rejects the auth field", async () => {
899+
const client = new GatewayClient({
900+
url: "ws://127.0.0.1:18789",
901+
token: "shared-token",
902+
approvalRuntimeToken: "runtime-token",
903+
deviceIdentity: null,
904+
});
905+
906+
const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
907+
expectRecordFields(
908+
firstConnect.params?.auth ?? {},
909+
{
910+
token: "shared-token",
911+
approvalRuntimeToken: "runtime-token",
912+
},
913+
"initial connect auth",
914+
);
915+
916+
const retriedAuth = await expectRetriedConnectAuth({
917+
firstWs: ws1,
918+
connectId: firstConnect.id,
919+
failureDetails: {},
920+
failureMessage:
921+
"invalid connect params: at /auth: unexpected property 'approvalRuntimeToken'",
922+
});
923+
expectRecordFields(
924+
retriedAuth,
925+
{
926+
token: "shared-token",
927+
},
928+
"retried connect auth",
929+
);
930+
expect(retriedAuth.approvalRuntimeToken).toBeUndefined();
931+
client.stop();
932+
});
933+
890934
it("waits for socket open before sending connect after an early challenge", () => {
891935
const client = new GatewayClient({
892936
url: "ws://127.0.0.1:18789",

src/gateway/client.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ type SelectedConnectAuth = {
7474
authBootstrapToken?: string;
7575
authDeviceToken?: string;
7676
authPassword?: string;
77+
authApprovalRuntimeToken?: string;
7778
signatureToken?: string;
7879
resolvedDeviceToken?: string;
7980
storedToken?: string;
@@ -130,6 +131,7 @@ export type GatewayClientOptions = {
130131
bootstrapToken?: string;
131132
deviceToken?: string;
132133
password?: string;
134+
approvalRuntimeToken?: string;
133135
instanceId?: string;
134136
clientName?: GatewayClientName;
135137
clientDisplayName?: string;
@@ -221,6 +223,8 @@ export class GatewayClient {
221223
private reconnectTimer: NodeJS.Timeout | null = null;
222224
private pendingDeviceTokenRetry = false;
223225
private deviceTokenRetryBudgetUsed = false;
226+
private approvalRuntimeTokenCompatibilityDisabled = false;
227+
private approvalRuntimeTokenRetryBudgetUsed = false;
224228
private pendingStartupReconnectDelayMs: number | null = null;
225229
private pendingConnectErrorDetailCode: string | null = null;
226230
private pendingConnectErrorDetails: unknown = null;
@@ -506,6 +510,7 @@ export class GatewayClient {
506510
authBootstrapToken,
507511
authDeviceToken,
508512
authPassword,
513+
authApprovalRuntimeToken,
509514
signatureToken,
510515
resolvedDeviceToken,
511516
storedToken,
@@ -516,12 +521,17 @@ export class GatewayClient {
516521
this.pendingDeviceTokenRetry = false;
517522
}
518523
const auth =
519-
authToken || authBootstrapToken || authPassword || resolvedDeviceToken
524+
authToken ||
525+
authBootstrapToken ||
526+
authPassword ||
527+
resolvedDeviceToken ||
528+
authApprovalRuntimeToken
520529
? {
521530
token: authToken,
522531
bootstrapToken: authBootstrapToken,
523532
deviceToken: authDeviceToken ?? resolvedDeviceToken,
524533
password: authPassword,
534+
approvalRuntimeToken: authApprovalRuntimeToken,
525535
}
526536
: undefined;
527537
const signedAtMs = Date.now();
@@ -646,6 +656,19 @@ export class GatewayClient {
646656
this.ws?.close(1013, "gateway starting");
647657
return;
648658
}
659+
if (
660+
this.shouldRetryWithoutApprovalRuntimeToken({
661+
error: err,
662+
authApprovalRuntimeToken,
663+
})
664+
) {
665+
this.approvalRuntimeTokenCompatibilityDisabled = true;
666+
this.approvalRuntimeTokenRetryBudgetUsed = true;
667+
this.backoffMs = Math.min(this.backoffMs, 250);
668+
logDebug("gateway rejected approval runtime auth field; retrying without it");
669+
this.ws?.close(1008, "connect retry");
670+
return;
671+
}
649672
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
650673
const msg = `gateway connect failed: ${String(err)}`;
651674
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE || isGatewayClientStoppedError(err)) {
@@ -763,6 +786,26 @@ export class GatewayClient {
763786
);
764787
}
765788

789+
private shouldRetryWithoutApprovalRuntimeToken(params: {
790+
error: unknown;
791+
authApprovalRuntimeToken?: string;
792+
}): boolean {
793+
if (this.approvalRuntimeTokenRetryBudgetUsed) {
794+
return false;
795+
}
796+
if (!params.authApprovalRuntimeToken) {
797+
return false;
798+
}
799+
if (!(params.error instanceof GatewayClientRequestError)) {
800+
return false;
801+
}
802+
if (params.error.gatewayCode !== "INVALID_REQUEST") {
803+
return false;
804+
}
805+
const message = normalizeLowercaseStringOrEmpty(params.error.message);
806+
return message.includes("invalid connect params") && message.includes("approvalruntimetoken");
807+
}
808+
766809
private isTrustedDeviceRetryEndpoint(): boolean {
767810
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
768811
try {
@@ -787,6 +830,9 @@ export class GatewayClient {
787830
const explicitBootstrapToken = normalizeOptionalString(this.opts.bootstrapToken);
788831
const explicitDeviceToken = normalizeOptionalString(this.opts.deviceToken);
789832
const authPassword = normalizeOptionalString(this.opts.password);
833+
const authApprovalRuntimeToken = this.approvalRuntimeTokenCompatibilityDisabled
834+
? undefined
835+
: normalizeOptionalString(this.opts.approvalRuntimeToken);
790836
const storedAuth = this.loadStoredDeviceAuth(role);
791837
const storedToken = storedAuth?.token ?? null;
792838
const storedScopes = storedAuth?.scopes;
@@ -819,6 +865,7 @@ export class GatewayClient {
819865
authBootstrapToken,
820866
authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined,
821867
authPassword,
868+
authApprovalRuntimeToken,
822869
signatureToken: authToken ?? authBootstrapToken ?? undefined,
823870
resolvedDeviceToken,
824871
storedToken: storedToken ?? undefined,

src/gateway/exec-approval-ios-push.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,31 @@ describe("createExecApprovalIosPushDelivery", () => {
171171
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
172172
});
173173

174+
it("does not target iOS devices rejected by the approval visibility filter", async () => {
175+
mockPairedIosOperator(["operator.approvals", "operator.read"]);
176+
const isTargetVisible = vi.fn(() => false);
177+
178+
const delivery = createExecApprovalIosPushDelivery({ log: {} });
179+
180+
const accepted = await delivery.handleRequested(
181+
{
182+
id: "approval-filtered",
183+
request: { command: "echo ok", host: "gateway", allowedDecisions: ["allow-once"] },
184+
createdAtMs: 1,
185+
expiresAtMs: 2,
186+
},
187+
{ isTargetVisible },
188+
);
189+
190+
expect(accepted).toBe(false);
191+
expect(isTargetVisible).toHaveBeenCalledWith({
192+
deviceId: "ios-device-1",
193+
scopes: ["operator.approvals", "operator.read"],
194+
});
195+
expect(loadApnsRegistrationMock).not.toHaveBeenCalled();
196+
expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled();
197+
});
198+
174199
it("does not treat iOS as a live approval route when every push fails", async () => {
175200
const warn = vi.fn();
176201
mockPairedIosOperator(["operator.approvals", "operator.read"]);

0 commit comments

Comments
 (0)